From 6c6e5c39b375b8e6dbc2d6c8e818ad39e527d0b0 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Sun, 27 Oct 2024 19:18:29 +0100 Subject: [PATCH 001/194] New version --- catlearn/_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/catlearn/_version.py b/catlearn/_version.py index 8a09eb69..5ce7cf0a 100644 --- a/catlearn/_version.py +++ b/catlearn/_version.py @@ -1,3 +1,3 @@ -__version__ = "5.6.1" +__version__ = "6.0.0" __all__ = ["__version__"] From 370d99ccbe1e6df1b11c659c22ea1bb97d47422b Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Sun, 27 Oct 2024 19:19:27 +0100 Subject: [PATCH 002/194] Debug concatenate of constraints --- catlearn/regression/gp/baseline/baseline.py | 17 +++++++---------- .../regression/gp/fingerprint/fingerprint.py | 17 +++++++---------- 2 files changed, 14 insertions(+), 20 deletions(-) diff --git a/catlearn/regression/gp/baseline/baseline.py b/catlearn/regression/gp/baseline/baseline.py index 1cf3e988..c0f7ef2d 100644 --- a/catlearn/regression/gp/baseline/baseline.py +++ b/catlearn/regression/gp/baseline/baseline.py @@ -92,16 +92,13 @@ def get_constraints(self, atoms, **kwargs): if not self.reduce_dimensions: return not_masked, [] constraints = atoms.constraints - if len(constraints) > 0: - masked = np.concatenate( - [ - c.get_indices() - for c in constraints - if isinstance(c, FixAtoms) - ] - ) - masked = set(masked) - return list(set(not_masked).difference(masked)), list(masked) + if len(constraints): + masked = [ + c.get_indices() for c in constraints if isinstance(c, FixAtoms) + ] + if len(masked): + masked = set(np.concatenate(masked)) + return list(set(not_masked).difference(masked)), list(masked) return not_masked, [] def get_arguments(self): diff --git a/catlearn/regression/gp/fingerprint/fingerprint.py b/catlearn/regression/gp/fingerprint/fingerprint.py index 37c5dc75..a65fae8b 100644 --- a/catlearn/regression/gp/fingerprint/fingerprint.py +++ b/catlearn/regression/gp/fingerprint/fingerprint.py @@ -115,16 +115,13 @@ def get_constraints(self, atoms, **kwargs): if not self.reduce_dimensions: return not_masked, [] constraints = atoms.constraints - if len(constraints) > 0: - masked = np.concatenate( - [ - c.get_indices() - for c in constraints - if isinstance(c, FixAtoms) - ] - ) - masked = set(masked) - return list(set(not_masked).difference(masked)), list(masked) + if len(constraints): + masked = [ + c.get_indices() for c in constraints if isinstance(c, FixAtoms) + ] + if len(masked): + masked = set(np.concatenate(masked)) + return list(set(not_masked).difference(masked)), list(masked) return not_masked, [] def get_arguments(self): From c5d4db708f8c788ced3d9ea2b5c277a558fc9319 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Sun, 27 Oct 2024 19:20:59 +0100 Subject: [PATCH 003/194] Make a function that gets the data from database --- catlearn/regression/gp/calculator/mlcalc.py | 9 +++++++++ catlearn/regression/gp/calculator/mlmodel.py | 9 +++++++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/catlearn/regression/gp/calculator/mlcalc.py b/catlearn/regression/gp/calculator/mlcalc.py index 74e23269..9872a71a 100644 --- a/catlearn/regression/gp/calculator/mlcalc.py +++ b/catlearn/regression/gp/calculator/mlcalc.py @@ -167,6 +167,15 @@ def save_data(self, trajectory="data.traj", **kwarg): self.mlmodel.save_data(trajectory=trajectory, **kwarg) return self + def get_data_atoms(self, **kwargs): + """ + Get the list of atoms in the database. + + Returns: + list: A list of the saved ASE Atoms objects. + """ + return self.mlmodel.get_data_atoms() + def get_training_set_size(self): """ Get the number of atoms objects in the ML model. diff --git a/catlearn/regression/gp/calculator/mlmodel.py b/catlearn/regression/gp/calculator/mlmodel.py index 86b21e50..8e5b7c61 100644 --- a/catlearn/regression/gp/calculator/mlmodel.py +++ b/catlearn/regression/gp/calculator/mlmodel.py @@ -464,8 +464,13 @@ def get_data(self, **kwargs): return features, targets def get_data_atoms(self, **kwargs): - "Get the atoms stored in the data base." - return self.database.get_atoms() + """ + Get the list of atoms in the database. + + Returns: + list: A list of the saved ASE Atoms objects. + """ + return self.database.get_data_atoms() def reset_database(self, **kwargs): """ From 5eaf2b9818dcf43b5058bdce1433b8b555a096f2 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Sun, 27 Oct 2024 19:21:10 +0100 Subject: [PATCH 004/194] Get atoms from database and use a new write function --- catlearn/regression/gp/calculator/database.py | 41 +++++++++++++------ 1 file changed, 29 insertions(+), 12 deletions(-) diff --git a/catlearn/regression/gp/calculator/database.py b/catlearn/regression/gp/calculator/database.py index 3dd93d56..c52fc9dc 100644 --- a/catlearn/regression/gp/calculator/database.py +++ b/catlearn/regression/gp/calculator/database.py @@ -1,7 +1,7 @@ import numpy as np from scipy.spatial.distance import cdist from ase.constraints import FixAtoms -from ase.io import write +from ase.io.trajectory import TrajectoryWriter from .copy_atoms import copy_atoms @@ -95,22 +95,23 @@ def get_constraints(self, atoms, **kwargs): if not self.reduce_dimensions: return not_masked constraints = atoms.constraints - if len(constraints) > 0: - index_mask = [ + if len(constraints): + masked = [ c.get_indices() for c in constraints if isinstance(c, FixAtoms) ] - index_mask = set(np.concatenate(index_mask)) - return list(set(not_masked).difference(index_mask)) + if len(masked): + masked = set(np.concatenate(masked)) + return list(set(not_masked).difference(masked)) return not_masked - def get_atoms(self, **kwargs): + def get_data_atoms(self, **kwargs): """ Get the list of atoms in the database. Returns: list: A list of the saved ASE Atoms objects. """ - return self.atoms_list.copy() + return [self.copy_atoms(atoms) for atoms in self.atoms_list] def get_features(self, **kwargs): """ @@ -130,18 +131,29 @@ def get_targets(self, **kwargs): """ return np.array(self.targets) - def save_data(self, trajectory="data.traj", **kwargs): + def save_data(self, trajectory="data.traj", mode="w", **kwargs): """ Save the ASE Atoms data to a trajectory. Parameters: - trajectory : str + trajectory : str or TrajectoryWriter instance The name of the trajectory file where the data is saved. + Or a TrajectoryWriter instance where the data is saved to. + mode : str + The mode of the trajectory file. Returns: self: The updated object itself. """ - write(trajectory, self.get_atoms()) + if trajectory is None: + return self + if isinstance(trajectory, str): + with TrajectoryWriter(trajectory, mode=mode) as traj: + for atoms in self.atoms_list: + traj.write(atoms) + elif isinstance(trajectory, TrajectoryWriter): + for atoms in self.atoms_list: + trajectory.write(atoms) return self def copy_atoms(self, atoms, **kwargs): @@ -177,7 +189,11 @@ def make_atoms_feature(self, atoms, **kwargs): return self.fingerprint(atoms).get_vector() def make_target( - self, atoms, use_derivatives=True, use_negative_forces=True, **kwargs + self, + atoms, + use_derivatives=True, + use_negative_forces=True, + **kwargs, ): """ Calculate the target as the energy and forces if selected. @@ -200,7 +216,8 @@ def make_target( e = atoms.get_potential_energy() if use_derivatives: not_masked = self.get_constraints(atoms) - f = (atoms.get_forces()[not_masked]).reshape(-1) + f = atoms.get_forces(apply_constraint=False) + f = f[not_masked].reshape(-1) if use_negative_forces: return np.concatenate([[e], -f]).reshape(-1) return np.concatenate([[e], f]).reshape(-1) From a62a90a64860b18c54d63371e700a234b57ed518 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Sun, 27 Oct 2024 19:21:22 +0100 Subject: [PATCH 005/194] Enable get_uncertainty from StoredDataCalculator --- catlearn/regression/gp/calculator/copy_atoms.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/catlearn/regression/gp/calculator/copy_atoms.py b/catlearn/regression/gp/calculator/copy_atoms.py index 1bf0a6f3..8cbc9d73 100644 --- a/catlearn/regression/gp/calculator/copy_atoms.py +++ b/catlearn/regression/gp/calculator/copy_atoms.py @@ -76,6 +76,20 @@ def get_property(self, name, atoms=None, allow_calculation=True): return None # Return the property result = self.results[name] - if isinstance(result, np.ndarray): + if isinstance(result, (np.ndarray, list)): result = result.copy() return result + + def get_uncertainty(self, atoms=None, **kwargs): + """ + Get the predicted uncertainty of the energy. + + Parameters: + atoms : ASE Atoms (optional) + The ASE Atoms instance which is used + if the uncertainty is not stored. + + Returns: + float: The predicted uncertainty of the energy. + """ + return self.get_property("uncertainty", atoms=atoms) From 19b23ec27f6436514506120feee413262326912d Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Sun, 27 Oct 2024 19:25:13 +0100 Subject: [PATCH 006/194] Relocate NEB methods and make structure class --- catlearn/optimize/neb/nebimage.py | 104 ----------- catlearn/structures/__init__.py | 4 + .../{optimize => structures}/neb/__init__.py | 1 - .../{optimize => structures}/neb/avgewneb.py | 14 +- .../{optimize => structures}/neb/ewneb.py | 20 +- .../neb/improvedneb.py | 47 ++--- .../neb/interpolate_band.py | 29 ++- .../{optimize => structures}/neb/maxewneb.py | 20 +- .../{optimize => structures}/neb/orgneb.py | 168 ++++++++++++----- catlearn/structures/structure.py | 171 ++++++++++++++++++ 10 files changed, 356 insertions(+), 222 deletions(-) delete mode 100644 catlearn/optimize/neb/nebimage.py create mode 100644 catlearn/structures/__init__.py rename catlearn/{optimize => structures}/neb/__init__.py (92%) rename catlearn/{optimize => structures}/neb/avgewneb.py (61%) rename catlearn/{optimize => structures}/neb/ewneb.py (71%) rename catlearn/{optimize => structures}/neb/improvedneb.py (52%) rename catlearn/{optimize => structures}/neb/interpolate_band.py (94%) rename catlearn/{optimize => structures}/neb/maxewneb.py (67%) rename catlearn/{optimize => structures}/neb/orgneb.py (63%) create mode 100644 catlearn/structures/structure.py diff --git a/catlearn/optimize/neb/nebimage.py b/catlearn/optimize/neb/nebimage.py deleted file mode 100644 index 5cc14d48..00000000 --- a/catlearn/optimize/neb/nebimage.py +++ /dev/null @@ -1,104 +0,0 @@ -from ...regression.gp.calculator.copy_atoms import copy_atoms - - -class NEBImage: - def __init__(self, atoms): - """ - An image for NEB as a wrapper for the Atoms instance. - The calculated results are stored within so multiple - calculations can be avoided. - - Parameters: - atoms : Atoms instance. - The Atoms instance with a calculator. - """ - self.atoms = atoms - self.cell = self.atoms.cell - self.pbc = self.atoms.pbc - self.reset() - - def get_positions(self, *args, **kwargs): - return self.atoms.get_positions(*args, **kwargs) - - def set_positions(self, *args, **kwargs): - output = self.atoms.set_positions(*args, **kwargs) - self.reset() - return output - - def get_property(self, name, allow_calculation=True, **kwargs): - """ - Get or calculate the requested property. - - Parameters: - name : str - The name of the requested property. - allow_calculation : bool - Whether the property is allowed to be calculated. - - Returns: - float or list: The requested property. - """ - if (self.atoms_saved.calc is not None) and ( - name in self.atoms_saved.calc.results - ): - return self.atoms_saved.calc.get_property( - name, allow_calculation=True, **kwargs - ) - output = self.atoms.calc.get_property( - name, - atoms=self.atoms, - allow_calculation=allow_calculation, - **kwargs - ) - self.store_results() - return output - - def get_potential_energy(self, *args, **kwargs): - if (self.atoms_saved.calc is not None) and ( - "energy" in self.atoms_saved.calc.results - ): - return self.atoms_saved.get_potential_energy(*args, **kwargs) - energy = self.atoms.get_potential_energy(*args, **kwargs) - self.store_results() - return energy - - def get_forces(self, *args, **kwargs): - if (self.atoms_saved.calc is not None) and ( - "force" in self.atoms_saved.calc.results - ): - return self.atoms_saved.get_forces(*args, **kwargs) - force = self.atoms.get_forces(*args, **kwargs) - self.store_results() - return force - - def get_atomic_numbers(self): - return self.atoms.get_atomic_numbers() - - def get_cell(self): - return self.atoms.get_cell() - - def get_tags(self): - return self.atoms.get_tags() - - def store_results(self, **kwargs): - """ - Store the calculated results. - """ - self.atoms_saved = copy_atoms(self.atoms) - self.calc = self.atoms_saved.calc - return self.atoms_saved - - def reset(self, **kwargs): - """ - Reset the stored properties. - """ - self.atoms_saved = self.atoms.copy() - self.calc = None - return self - - def __len__(self): - return len(self.atoms) - - def copy(self): - "Copy and get the Atoms instance." - return self.atoms.copy() diff --git a/catlearn/structures/__init__.py b/catlearn/structures/__init__.py new file mode 100644 index 00000000..ec65aa55 --- /dev/null +++ b/catlearn/structures/__init__.py @@ -0,0 +1,4 @@ +from .structure import Structure + + +__all__ = ["Structure"] diff --git a/catlearn/optimize/neb/__init__.py b/catlearn/structures/neb/__init__.py similarity index 92% rename from catlearn/optimize/neb/__init__.py rename to catlearn/structures/neb/__init__.py index bc7a4840..8e5d0c32 100644 --- a/catlearn/optimize/neb/__init__.py +++ b/catlearn/structures/neb/__init__.py @@ -3,7 +3,6 @@ from .ewneb import EWNEB from .avgewneb import AvgEWNEB from .maxewneb import MaxEWNEB -from .nebimage import NEBImage from .interpolate_band import interpolate, make_interpolation __all__ = [ diff --git a/catlearn/optimize/neb/avgewneb.py b/catlearn/structures/neb/avgewneb.py similarity index 61% rename from catlearn/optimize/neb/avgewneb.py rename to catlearn/structures/neb/avgewneb.py index fccf066a..a3faac11 100644 --- a/catlearn/optimize/neb/avgewneb.py +++ b/catlearn/structures/neb/avgewneb.py @@ -4,13 +4,17 @@ class AvgEWNEB(EWNEB): - def get_parallel_forces(self, tangent, pos_p, pos_m, **kwargs): + def get_spring_constants(self, **kwargs): + # Get the spring constants energies = self.get_energies() + # Get the reference energy if self.use_minimum: e0 = np.min([energies[0], energies[-1]]) else: e0 = np.max([energies[0], energies[-1]]) + # Get the maximum energy emax = np.max(energies) + # Calculate the weighted spring constants k_l = self.k * self.kl_scale if e0 < emax: a = (emax - energies) / (emax - e0) @@ -18,9 +22,5 @@ def get_parallel_forces(self, tangent, pos_p, pos_m, **kwargs): a = 0.5 * (a[1:] + a[:-1]) k = ((1.0 - a) * self.k) + (a * k_l) else: - k = k_l.copy() - forces_parallel = (k[1:] * np.linalg.norm(pos_p, axis=(1, 2))) - ( - k[:-1] * np.linalg.norm(pos_m, axis=(1, 2)) - ) - forces_parallel = forces_parallel.reshape(-1, 1, 1) * tangent - return forces_parallel + k = k_l + return k diff --git a/catlearn/optimize/neb/ewneb.py b/catlearn/structures/neb/ewneb.py similarity index 71% rename from catlearn/optimize/neb/ewneb.py rename to catlearn/structures/neb/ewneb.py index 6f2c05b6..2cbe2041 100644 --- a/catlearn/optimize/neb/ewneb.py +++ b/catlearn/structures/neb/ewneb.py @@ -12,6 +12,9 @@ def __init__( climb=False, remove_rotation_and_translation=False, mic=True, + save_properties=False, + parallel=False, + world=None, **kwargs ): super().__init__( @@ -20,26 +23,29 @@ def __init__( climb=climb, remove_rotation_and_translation=remove_rotation_and_translation, mic=mic, + save_properties=save_properties, + parallel=parallel, + world=world, **kwargs ) self.kl_scale = kl_scale self.use_minimum = use_minimum - def get_parallel_forces(self, tangent, pos_p, pos_m, **kwargs): + def get_spring_constants(self, **kwargs): + # Get the energies energies = self.get_energies() + # Get the reference energy if self.use_minimum: e0 = np.min([energies[0], energies[-1]]) else: e0 = np.max([energies[0], energies[-1]]) + # Get the maximum energy emax = np.max(energies) + # Calculate the weighted spring constants k_l = self.k * self.kl_scale if e0 < emax: a = (emax - energies[:-1]) / (emax - e0) k = np.where(a < 1.0, (1.0 - a) * self.k + a * k_l, k_l) else: - k = k_l.copy() - forces_parallel = (k[1:] * np.linalg.norm(pos_p, axis=(1, 2))) - ( - k[:-1] * np.linalg.norm(pos_m, axis=(1, 2)) - ) - forces_parallel = forces_parallel.reshape(-1, 1, 1) * tangent - return forces_parallel + k = k_l + return k diff --git a/catlearn/optimize/neb/improvedneb.py b/catlearn/structures/neb/improvedneb.py similarity index 52% rename from catlearn/optimize/neb/improvedneb.py rename to catlearn/structures/neb/improvedneb.py index fc3e5cb2..c00f1f69 100644 --- a/catlearn/optimize/neb/improvedneb.py +++ b/catlearn/structures/neb/improvedneb.py @@ -3,28 +3,13 @@ class ImprovedTangentNEB(OriginalNEB): - def __init__( - self, - images, - k=0.1, - climb=False, - remove_rotation_and_translation=False, - mic=True, - **kwargs - ): - super().__init__( - images, - k=k, - climb=climb, - remove_rotation_and_translation=remove_rotation_and_translation, - mic=mic, - **kwargs - ) def get_parallel_forces(self, tangent, pos_p, pos_m, **kwargs): - forces_parallel = (self.k[1:] * np.linalg.norm(pos_p, axis=(1, 2))) - ( - self.k[:-1] * np.linalg.norm(pos_m, axis=(1, 2)) - ) + # Get the spring constants + k = self.get_spring_constants() + # Calculate the parallel forces + forces_parallel = k[1:] * np.linalg.norm(pos_p, axis=(1, 2)) + forces_parallel -= k[:-1] * np.linalg.norm(pos_m, axis=(1, 2)) forces_parallel = forces_parallel.reshape(-1, 1, 1) * tangent return forces_parallel @@ -43,23 +28,19 @@ def get_tangent(self, pos_p, pos_m, **kwargs): abs(energies[i + 1] - energies[i]), abs(energies[i - 1] - energies[i]), ] - tangent[i - 1] = (pos_p[i - 1] * max(energy_dif)) + ( - pos_m[i - 1] * min(energy_dif) - ) + tangent[i - 1] = pos_p[i - 1] * max(energy_dif) + tangent[i - 1] += pos_m[i - 1] * min(energy_dif) elif energies[i + 1] < energies[i - 1]: energy_dif = [ abs(energies[i + 1] - energies[i]), abs(energies[i - 1] - energies[i]), ] - tangent[i - 1] = (pos_p[i - 1] * min(energy_dif)) + ( - pos_m[i - 1] * max(energy_dif) - ) + tangent[i - 1] = pos_p[i - 1] * min(energy_dif) + tangent[i - 1] += pos_m[i - 1] * max(energy_dif) else: - tangent[i - 1] = ( - pos_p[i - 1] / np.linalg.norm(pos_p[i - 1]) - ) + (pos_m[i - 1] / np.linalg.norm(pos_m[i - 1])) - tangent = tangent / np.linalg.norm( - tangent, - axis=(1, 2), - ).reshape(-1, 1, 1) + tangent[i - 1] = pos_p[i - 1] / np.linalg.norm(pos_p[i - 1]) + tangent[i - 1] += pos_m[i - 1] / np.linalg.norm(pos_m[i - 1]) + # Normalization of tangent + tangent_norm = np.linalg.norm(tangent, axis=(1, 2)).reshape(-1, 1, 1) + tangent = tangent / tangent_norm return tangent diff --git a/catlearn/optimize/neb/interpolate_band.py b/catlearn/structures/neb/interpolate_band.py similarity index 94% rename from catlearn/optimize/neb/interpolate_band.py rename to catlearn/structures/neb/interpolate_band.py index 8fe5c765..e06d5c2a 100644 --- a/catlearn/optimize/neb/interpolate_band.py +++ b/catlearn/structures/neb/interpolate_band.py @@ -1,5 +1,7 @@ import numpy as np +from ase.io import read from ase.optimize import FIRE +from ...regression.gp.calculator.copy_atoms import copy_atoms def interpolate( @@ -99,7 +101,7 @@ def make_interpolation( "Make the NEB interpolation path." # Use a premade interpolation path if isinstance(method, (list, np.ndarray)): - images = method.copy() + images = [copy_atoms(image) for image in method] elif isinstance(method, str) and method.lower() not in [ "linear", "idpp", @@ -107,12 +109,11 @@ def make_interpolation( "ends", ]: # Import interpolation from a trajectory file - from ase.io import read - images = read(method, "-{}:".format(n_images)) else: # Make path by the NEB methods interpolation - images = [start.copy() for i in range(n_images - 1)] + [end.copy()] + images = [start.copy() for i in range(1, n_images - 1)] + images = [copy_atoms(start)] + images + [copy_atoms(end)] if method.lower() == "ends": images = make_end_interpolations( images, @@ -182,14 +183,11 @@ def make_idpp_interpolation( len(images) - 1 ) # Use IDPP as calculator - new_images = [] - for i in range(len(images)): - image = images[i].copy() - target = dist0 + i * dist + for i, image in enumerate(images[1:-1]): + target = dist0 + (i + 1) * dist image.calc = IDPP(target=target, mic=mic) - new_images.append(image) # Make default NEB - neb = ImprovedTangentNEB(new_images) + neb = ImprovedTangentNEB(images) # Set local optimizer arguments local_kwargs_default = dict(trajectory="idpp.traj", logfile="idpp.log") if isinstance(local_opt, FIRE): @@ -200,7 +198,7 @@ def make_idpp_interpolation( # Optimize NEB path with IDPP with local_opt(neb, **local_kwargs_default) as opt: opt.run(fmax=fmax, steps=steps) - return new_images + return images def make_rep_interpolation( @@ -219,13 +217,10 @@ def make_rep_interpolation( from ...regression.gp.baseline import RepulsionCalculator # Use Repulsive potential as calculator - new_images = [] - for i in range(len(images)): - image = images[i].copy() + for image in images[1:-1]: image.calc = RepulsionCalculator(power=10, mic=mic) - new_images.append(image) # Make default NEB - neb = ImprovedTangentNEB(new_images) + neb = ImprovedTangentNEB(images) # Set local optimizer arguments local_kwargs_default = dict(trajectory="rep.traj", logfile="rep.log") if isinstance(local_opt, FIRE): @@ -236,7 +231,7 @@ def make_rep_interpolation( # Optimize NEB path with repulsive potential with local_opt(neb, **local_kwargs_default) as opt: opt.run(fmax=fmax, steps=steps) - return new_images + return images def make_end_interpolations(images, mic=False, trust_dist=0.2, **kwargs): diff --git a/catlearn/optimize/neb/maxewneb.py b/catlearn/structures/neb/maxewneb.py similarity index 67% rename from catlearn/optimize/neb/maxewneb.py rename to catlearn/structures/neb/maxewneb.py index 8b56699e..2fbab244 100644 --- a/catlearn/optimize/neb/maxewneb.py +++ b/catlearn/structures/neb/maxewneb.py @@ -12,6 +12,9 @@ def __init__( climb=False, remove_rotation_and_translation=False, mic=True, + save_properties=False, + parallel=False, + world=None, **kwargs ): super().__init__( @@ -20,23 +23,26 @@ def __init__( climb=climb, remove_rotation_and_translation=remove_rotation_and_translation, mic=mic, + save_properties=save_properties, + parallel=parallel, + world=world, **kwargs ) self.kl_scale = kl_scale self.dE = dE - def get_parallel_forces(self, tangent, pos_p, pos_m, **kwargs): + def get_spring_constants(self, **kwargs): + # Get the spring constants energies = self.get_energies() + # Get the maximum energy emax = np.max(energies) + # Calculate the reference energy e0 = emax - self.dE + # Calculate the weighted spring constants k_l = self.k * self.kl_scale if e0 < emax: a = (emax - energies[:-1]) / (emax - e0) k = np.where(a < 1.0, (1.0 - a) * self.k + a * k_l, k_l) else: - k = k_l.copy() - forces_parallel = (k[1:] * np.linalg.norm(pos_p, axis=(1, 2))) - ( - k[:-1] * np.linalg.norm(pos_m, axis=(1, 2)) - ) - forces_parallel = forces_parallel.reshape(-1, 1, 1) * tangent - return forces_parallel + k = k_l + return k diff --git a/catlearn/optimize/neb/orgneb.py b/catlearn/structures/neb/orgneb.py similarity index 63% rename from catlearn/optimize/neb/orgneb.py rename to catlearn/structures/neb/orgneb.py index 7cb33154..7c613053 100644 --- a/catlearn/optimize/neb/orgneb.py +++ b/catlearn/structures/neb/orgneb.py @@ -1,6 +1,7 @@ import numpy as np from ase.calculators.singlepoint import SinglePointCalculator from ase.build import minimize_rotation_and_translation +from ..structure import Structure from ...regression.gp.fingerprint.geometry import mic_distance @@ -12,6 +13,9 @@ def __init__( climb=False, remove_rotation_and_translation=False, mic=True, + save_properties=False, + parallel=False, + world=None, **kwargs ): """ @@ -19,31 +23,59 @@ def __init__( and parallel force. Parameters: - images : List of ASE Atoms instances + images: List of ASE Atoms instances The ASE Atoms instances used as the images of the initial path that is optimized. - k : List of floats or float + k: List of floats or float The (Nimg-1) spring forces acting between each image. - climb : bool + climb: bool Whether to use climbing image in the NEB. - remove_rotation_and_translation : bool + remove_rotation_and_translation: bool Whether to remove rotation and translation in interpolation and when predicting forces. - mic : bool + mic: bool Minimum Image Convention (Shortest distances when periodic boundary conditions are used). + save_properties: bool + Whether to save the properties by making a copy of the images. + parallel: bool + Whether to run the calculations in parallel. + world: ASE communicator instance + The communicator instance for parallelization. """ - self.images = images + # Set images + if save_properties: + self.images = [Structure(image) for image in images] + else: + self.images = images self.nimages = len(images) self.natoms = len(images[0]) + # Set the spring constant if isinstance(k, (int, float)): self.k = np.full(self.nimages - 1, k) else: self.k = k.copy() + # Set the parameters self.climb = climb self.rm_rot_trans = remove_rotation_and_translation self.mic = mic + self.save_properties = save_properties + # Set the parallelization + self.parallel = parallel + if parallel: + if world is None: + from ase.parallel import world, parprint + + self.world = world + if self.nimages % self.world.size != 0: + parprint( + "Warning: The number of images are not chosen optimal for " + "the number of processors when running in parallel!" + ) + else: + self.world = None + # Set the properties self.reset() def interpolate(self, method="linear", mic=True, **kwargs): @@ -63,8 +95,8 @@ def interpolate(self, method="linear", mic=True, **kwargs): from .interpolate_band import interpolate self.images = interpolate( - self.images[0].copy(), - self.images[-1].copy(), + self.images[0], + self.images[-1], n_images=self.nimages, method=method, mic=mic, @@ -81,9 +113,10 @@ def get_positions(self): ((Nimg-2)*Natoms,3) array: Coordinates of all atoms in all the moving images. """ - return np.array( + positions = np.array( [image.get_positions() for image in self.images[1:-1]] - ).reshape(-1, 3) + ) + return positions.reshape(-1, 3) def set_positions(self, positions, **kwargs): """ @@ -107,7 +140,7 @@ def get_potential_energy(self, **kwargs): Returns: float: Sum of energies of moving images. """ - return np.sum(self.get_energies(**kwargs)[1:-1]) + return (self.get_energies(**kwargs)[1:-1]).sum() def get_forces(self, **kwargs): """ @@ -125,7 +158,7 @@ def get_forces(self, **kwargs): self.images[i], ) # Get the forces for each image - forces = self.calculate_forces() + forces = self.calculate_forces(**kwargs) # Get change in the coordinates to the previous and later image position_plus, position_minus = self.get_position_diff() # Calculate the tangent to the moving images @@ -142,10 +175,7 @@ def get_forces(self, **kwargs): forces_new = parallel_forces + perpendicular_forces # Calculate the force of the climbing image if self.climb: - i_max = np.argmax(self.get_energies()[1:-1]) - forces_new[i_max] = forces[i_max] - ( - (2.0 * np.vdot(forces[i_max], tangent[i_max])) * tangent[i_max] - ) + forces_new = self.get_climb_forces(forces_new, forces, tangent) return forces_new.reshape(-1, 3) def get_image_positions(self): @@ -158,6 +188,14 @@ def get_image_positions(self): """ return np.array([image.get_positions() for image in self.images]) + def get_climb_forces(self, forces_new, forces, tangent, **kwargs): + "Get the forces of the climbing image." + i_max = np.argmax(self.get_energies()[1:-1]) + forces_parallel = 2.0 * np.vdot(forces[i_max], tangent[i_max]) + forces_parallel = forces_parallel * tangent[i_max] + forces_new[i_max] = forces[i_max] - forces_parallel + return forces_new + def calculate_forces(self, **kwargs): "Calculate the forces for all the images separately." if self.real_forces is None: @@ -172,12 +210,33 @@ def get_energies(self, **kwargs): def calculate_properties(self, **kwargs): "Calculate the energy and forces for each image." + # Initialize the arrays self.real_forces = np.zeros((self.nimages, self.natoms, 3)) self.energies = np.zeros((self.nimages)) - for i, image in enumerate(self.images): - if (not i == 0) or (not i == self.nimages - 1): - self.real_forces[i] = image.get_forces().copy() - self.energies[i] = image.get_potential_energy() + # Get the energy of the fixed images + self.energies[0] = self.images[0].get_potential_energy() + self.energies[-1] = self.images[-1].get_potential_energy() + # Check if the calculation is done in parallel + if self.parallel: + return self.calculate_properties_parallel(**kwargs) + # Calculate the energy and forces for each image + for i, image in enumerate(self.images[1:-1]): + self.real_forces[i + 1] = image.get_forces().copy() + self.energies[i + 1] = image.get_potential_energy() + return self.energies, self.real_forces + + def calculate_properties_parallel(self, **kwargs): + "Calculate the energy and forces for each image in parallel." + # Calculate the energy and forces for each image + for i, image in enumerate(self.images[1:-1]): + if self.world.rank == (i % self.world.size): + self.real_forces[i + 1] = image.get_forces().copy() + self.energies[i + 1] = image.get_potential_energy() + # Broadcast the results + for i in range(1, self.nimages - 1): + root = (i - 1) % self.world.size + self.world.broadcast(self.energies[i : i + 1], root=root) + self.world.broadcast(self.real_forces[i : i + 1], root=root) return self.energies, self.real_forces def emax(self, **kwargs): @@ -186,23 +245,20 @@ def emax(self, **kwargs): def get_parallel_forces(self, tangent, pos_p, pos_m, **kwargs): "Get the parallel forces between the images." - forces_parallel = np.array( - [ - np.vdot( - (self.k[i + 1] * pos_p[i]) - (self.k[i] * pos_m[i]), - tangent[i], - ) - for i in range(len(tangent)) - ] - ) + # Get the spring constants + k = self.get_spring_constants() + k = k.reshape(-1, 1, 1) + # Calculate the parallel forces + forces_parallel = (k[1:] * pos_p) - (k[:-1] * pos_m) + forces_parallel = (forces_parallel * tangent).sum(axis=(1, 2)) forces_parallel = forces_parallel.reshape(-1, 1, 1) * tangent return forces_parallel def get_perpendicular_forces(self, tangent, forces, **kwargs): "Get the perpendicular forces to the images." - return forces - ( - np.sum(forces * tangent, axis=(1, 2)).reshape(-1, 1, 1) * tangent - ) + f_parallel = (forces * tangent).sum(axis=(1, 2)) + f_parallel = f_parallel.reshape(-1, 1, 1) * tangent + return forces - f_parallel def get_position_diff(self): """ @@ -224,21 +280,23 @@ def get_position_diff(self): def get_tangent(self, pos_p, pos_m, **kwargs): "Calculate the tangent to the moving images." - # Normalization - tangent_m = pos_m / ( - np.linalg.norm(pos_m, axis=(1, 2)).reshape(-1, 1, 1) - ) - tangent_p = pos_p / ( - np.linalg.norm(pos_p, axis=(1, 2)).reshape(-1, 1, 1) - ) + # Normalization factors + pos_m_norm = np.linalg.norm(pos_m, axis=(1, 2)).reshape(-1, 1, 1) + pos_p_norm = np.linalg.norm(pos_p, axis=(1, 2)).reshape(-1, 1, 1) + # Normalization of tangent + tangent_m = pos_m / pos_m_norm + tangent_p = pos_p / pos_p_norm # Sum them tangent = tangent_m + tangent_p - tangent = tangent / np.linalg.norm( - tangent, - axis=(1, 2), - ).reshape(-1, 1, 1) + # Normalization of tangent + tangent_norm = np.linalg.norm(tangent, axis=(1, 2)).reshape(-1, 1, 1) + tangent = tangent / tangent_norm return tangent + def get_spring_constants(self, **kwargs): + "Get the spring constants for the images." + return self.k + def reset(self): "Reset the stored properties." self.energies = None @@ -250,7 +308,7 @@ def get_residual(self, **kwargs): forces = self.get_forces() return np.max(np.linalg.norm(forces, axis=-1)) - def set_calculator(self, calculators): + def set_calculator(self, calculators, copy_calc=False, **kwargs): """ Set the calculators for all the images. @@ -259,6 +317,7 @@ def set_calculator(self, calculators): The calculator used for all the images if a list is given. If a single calculator is given, it is used for all images. """ + self.reset() if isinstance(calculators, (list, tuple)): if len(calculators) != self.nimages - 2: raise Exception( @@ -266,12 +325,29 @@ def set_calculator(self, calculators): "equal to the number of moving images." ) for i, image in enumerate(self.images[1:-1]): - image.calc = calculators[i] + if copy_calc: + image.calc = calculators[i].copy() + else: + image.calc = calculators[i] else: for image in self.images[1:-1]: - image.calc = calculators + if copy_calc: + image.calc = calculators.copy() + else: + image.calc = calculators return self + @property + def calc(self): + """ + The calculator objects. + """ + return [image.calc for image in self.images[1:-1]] + + @calc.setter + def calc(self, calculators): + return self.set_calculator(calculators) + def converged(self, forces, fmax): return np.linalg.norm(forces, axis=1).max() < fmax diff --git a/catlearn/structures/structure.py b/catlearn/structures/structure.py new file mode 100644 index 00000000..cf39fa68 --- /dev/null +++ b/catlearn/structures/structure.py @@ -0,0 +1,171 @@ +import numpy as np +from ase import Atoms +from ..regression.gp.calculator.copy_atoms import copy_atoms + + +class Structure(Atoms): + def __init__(self, atoms, *args, **kwargs): + self.atoms = atoms + self.__dict__.update(atoms.__dict__) + self.store_results() + + def set_positions(self, *args, **kwargs): + self.atoms.set_positions(*args, **kwargs) + self.reset() + return + + def set_scaled_positions(self, *args, **kwargs): + self.atoms.set_scaled_positions(*args, **kwargs) + self.reset() + return + + def set_cell(self, *args, **kwargs): + self.atoms.set_cell(*args, **kwargs) + self.reset() + return + + def set_pbc(self, *args, **kwargs): + self.atoms.set_pbc(*args, **kwargs) + self.reset() + return + + def set_initial_charges(self, *args, **kwargs): + self.atoms.set_initial_charges(*args, **kwargs) + self.reset() + return + + def set_initial_magnetic_moments(self, *args, **kwargs): + self.atoms.set_initial_magnetic_moments(*args, **kwargs) + self.reset() + return + + def set_momenta(self, *args, **kwargs): + self.atoms.set_momenta(*args, **kwargs) + self.reset() + return + + def set_velocities(self, *args, **kwargs): + self.atoms.set_velocities(*args, **kwargs) + self.reset() + return + + def get_property(self, name, allow_calculation=True, **kwargs): + """ + Get or calculate the requested property. + + Parameters: + name : str + The name of the requested property. + allow_calculation : bool + Whether the property is allowed to be calculated. + + Returns: + float or list: The requested property. + """ + if self.is_saved: + if name in self.results: + output = self.atoms_saved.calc.get_property( + name, + atoms=self.atoms_saved, + allow_calculation=True, + **kwargs, + ) + return output + output = self.atoms.calc.get_property( + name, + atoms=self.atoms, + allow_calculation=allow_calculation, + **kwargs, + ) + self.store_results() + return output + + def get_forces(self, *args, **kwargs): + if self.is_saved: + if "force" in self.results: + return self.atoms_saved.get_forces(*args, **kwargs) + forces = self.atoms.get_forces(*args, **kwargs) + self.store_results() + return forces + + def get_potential_energy(self, *args, **kwargs): + if self.is_saved: + if "energy" in self.results: + return self.atoms_saved.get_potential_energy(*args, **kwargs) + energy = self.atoms.get_potential_energy(*args, **kwargs) + self.store_results() + return energy + + def get_uncertainty(self, *args, **kwargs): + if self.is_saved: + if "uncertainty" in self.results: + unc = self.atoms_saved.calc.get_uncertainty( + self.atoms_saved, + *args, + **kwargs, + ) + return unc + unc = self.atoms.calc.get_uncertainty( + self.atoms, + *args, + **kwargs, + ) + self.store_results() + return unc + + def converged(self, forces, fmax): + return np.linalg.norm(forces, axis=1).max() < fmax + + def is_neb(self): + return False + + def __ase_optimizable__(self): + return self + + def set_calculator(self, calc, copy_calc=False, **kwargs): + if copy_calc: + self.atoms.calc = calc.copy() + else: + self.atoms.calc = calc + self.reset() + return + + @property + def calc(self): + """ + The calculator objects. + """ + if self.is_saved: + return self.atoms_saved.calc + return self.atoms.calc + + @calc.setter + def calc(self, calc): + return self.set_calculator(calc) + + def copy(self): + return self.atoms.copy() + + def get_structure(self): + return self.atoms + + def get_atoms(self): + return self.get_structure() + + def get_saved_structure(self): + return self.atoms_saved + + def reset(self): + self.atoms_saved = self.atoms.copy() + self.results = {} + self.is_saved = False + return self + + def store_results(self, **kwargs): + """ + Store the calculated results. + """ + self.atoms_saved = copy_atoms(self.atoms) + self.results = self.atoms_saved.calc.results.copy() + self.is_saved = True + return self.atoms_saved From 904f9ef1ce0fb32373551c159a94c8ec1a7e5fec Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Sun, 27 Oct 2024 19:28:41 +0100 Subject: [PATCH 007/194] Make optimization methods for active learning --- catlearn/optimizer/__init__.py | 18 + catlearn/optimizer/adsorption.py | 337 +++++++++++++++++ catlearn/optimizer/local.py | 248 +++++++++++++ catlearn/optimizer/localcineb.py | 295 +++++++++++++++ catlearn/optimizer/localneb.py | 158 ++++++++ catlearn/optimizer/method.py | 596 ++++++++++++++++++++++++++++++ catlearn/optimizer/parallelopt.py | 245 ++++++++++++ catlearn/optimizer/sequential.py | 190 ++++++++++ 8 files changed, 2087 insertions(+) create mode 100644 catlearn/optimizer/__init__.py create mode 100644 catlearn/optimizer/adsorption.py create mode 100644 catlearn/optimizer/local.py create mode 100644 catlearn/optimizer/localcineb.py create mode 100644 catlearn/optimizer/localneb.py create mode 100644 catlearn/optimizer/method.py create mode 100644 catlearn/optimizer/parallelopt.py create mode 100644 catlearn/optimizer/sequential.py diff --git a/catlearn/optimizer/__init__.py b/catlearn/optimizer/__init__.py new file mode 100644 index 00000000..5a658251 --- /dev/null +++ b/catlearn/optimizer/__init__.py @@ -0,0 +1,18 @@ +from .method import OptimizerMethod +from .local import LocalOptimizer +from .localneb import LocalNEB +from .localcineb import LocalCINEB +from .adsorption import AdsorptionOptimizer +from .sequential import SequentialOptimizer +from .parallelopt import ParallelOptimizer + + +__all__ = [ + "OptimizerMethod", + "LocalOptimizer", + "LocalNEB", + "LocalCINEB", + "AdsorptionOptimizer", + "SequentialOptimizer", + "ParallelOptimizer", +] diff --git a/catlearn/optimizer/adsorption.py b/catlearn/optimizer/adsorption.py new file mode 100644 index 00000000..8e89e53e --- /dev/null +++ b/catlearn/optimizer/adsorption.py @@ -0,0 +1,337 @@ +from .method import OptimizerMethod +from ase.parallel import world +from ase.constraints import FixAtoms, FixBondLengths +import itertools +import numpy as np +from scipy.optimize import dual_annealing + + +class AdsorptionOptimizer(OptimizerMethod): + def __init__( + self, + slab, + adsorbate, + adsorbate2=None, + bounds=None, + opt_kwargs={}, + parallel_run=False, + comm=world, + verbose=False, + **kwargs, + ): + """ + The AdsorptionOptimizer is used to run an global optimization of + an adsorption on a surface. + A single structure will be created and optimized. + Simulated annealing will be used to global optimize the structure. + The AdsorptionOptimizer is applicable to be used with + Bayesian optimization. + + Parameters: + slab: Atoms instance + The slab structure. + adsorbate: Atoms instance + The adsorbate structure. + adsorbate2: Atoms instance (optional) + The second adsorbate structure. + bounds : (6,2) or (12,2) ndarray (optional). + The boundary conditions used for the global optimization in + form of the simulated annealing. + The boundary conditions are the x, y, and z coordinates of + the center of the adsorbate and 3 rotations. + Same boundary conditions can be set for the second adsorbate + if chosen. + opt_kwargs: dict + The keyword arguments for the simulated annealing optimization. + parallel_run: bool + If True, the optimization will be run in parallel. + comm: ASE communicator instance + The communicator instance for parallelization. + verbose: bool + Whether to print the full output (True) or + not (False). + """ + # Create the atoms object from the slab and adsorbate + self.create_slab_ads(slab, adsorbate, adsorbate2) + # Create the boundary conditions + self.setup_bounds(bounds) + # Set the parameters + self.update_arguments( + opt_kwargs=opt_kwargs, + parallel_run=parallel_run, + comm=comm, + verbose=verbose, + **kwargs, + ) + + def get_structures(self): + structures = self.copy_atoms(self.optimizable) + structures.set_constraint(self.constraints_org) + return structures + + def create_slab_ads(self, slab, adsorbate, adsorbate2=None): + """ + Create the structure for the adsorption optimization. + + Parameters: + slab: Atoms object + The slab structure. + adsorbate: Atoms object + The adsorbate structure. + adsorbate2: Atoms object (optional) + The second adsorbate structure. + + Returns: + self: object + The object itself. + """ + # Check the slab and adsorbate are given + if slab is None or adsorbate is None: + raise Exception("The slab and adsorbate must be given!") + # Setup the slab + self.n_slab = len(slab) + self.slab = slab.copy() + self.slab.set_tags(0) + optimizable = self.slab.copy() + # Setup the adsorbate + self.n_ads = len(adsorbate) + self.adsorbate = adsorbate.copy() + self.adsorbate.set_tags(1) + self.adsorbate.cell = optimizable.cell.copy() + self.adsorbate.pbc = optimizable.pbc.copy() + pos_ads = self.adsorbate.get_positions() + pos_ads -= pos_ads.mean(axis=0) + self.adsorbate.set_positions(pos_ads) + optimizable.extend(self.adsorbate.copy()) + # Setup the adsorbate2 + if adsorbate2 is not None: + self.n_ads2 = len(adsorbate2) + self.adsorbate2 = adsorbate2.copy() + self.adsorbate2.set_tags(2) + self.adsorbate2.cell = optimizable.cell.copy() + self.adsorbate2.pbc = optimizable.pbc.copy() + pos_ads2 = self.adsorbate2.get_positions() + pos_ads2 -= pos_ads2.mean(axis=0) + self.adsorbate2.set_positions(pos_ads2) + optimizable.extend(self.adsorbate2.copy()) + else: + self.n_ads2 = 0 + self.adsorbate2 = None + # Get the full number of atoms + self.natoms = len(optimizable) + # Store the positions and cell + self.positions0 = optimizable.get_positions().copy() + self.cell = np.array(optimizable.get_cell()) + # Store the constraints + self.constraints_org = [c.copy() for c in optimizable.constraints] + # Make constraints + constraints = [FixAtoms(indices=list(range(self.n_slab)))] + if self.n_ads > 1: + pairs = list( + itertools.combinations( + range(self.n_slab, self.n_slab + self.n_ads), + 2, + ) + ) + constraints.append(FixBondLengths(indices=pairs)) + if self.n_ads2 > 1: + pairs = list( + itertools.combinations( + range(self.n_slab + self.n_ads, self.natoms), + 2, + ) + ) + constraints.append(FixBondLengths(indices=pairs)) + optimizable.set_constraint(constraints) + # Setup the optimizable structure + self.setup_optimizable(optimizable) + return self + + def setup_bounds(self, bounds=None): + """ + Setup the boundary conditions for the global optimization. + + Parameters: + bounds : (6,2) or (12,2) ndarray (optional). + The boundary conditions used for the global optimization in + form of the simulated annealing. + The boundary conditions are the x, y, and z coordinates of + the center of the adsorbate and 3 rotations. + Same boundary conditions can be set for the second adsorbate + if chosen. + + Returns: + self: object + The object itself. + """ + # Check the bounds are given + if bounds is None: + # Make default bounds + self.bounds = np.array( + [ + [0.0, 1.0], + [0.0, 1.0], + [0.0, 1.0], + [0.0, 2 * np.pi], + [0.0, 2 * np.pi], + [0.0, 2 * np.pi], + ] + ) + else: + self.bounds = bounds.copy() + # Check the bounds have the correct shape + if self.bounds.shape != (6, 2) and self.bounds.shape != (12, 2): + raise Exception("The bounds must have shape (6,2) or (12,2)!") + # Check if the bounds are for two adsorbates + if self.n_ads2 > 0 and self.bounds.shape[0] == 6: + self.bounds = np.concatenate([self.bounds, self.bounds], axis=0) + return self + + def run( + self, + fmax=0.05, + steps=1000000, + max_unc=None, + unc_convergence=None, + **kwargs, + ): + # Perform the simulated annealing + sol = dual_annealing( + self.evaluate_value, + bounds=self.bounds, + maxfun=steps, + **self.opt_kwargs, + ) + # Set the positions + self.evaluate_value(sol["x"]) + # Calculate the maximum force to check convergence + if fmax > self.get_fmax(): + # Check if the optimization is converged + self._converged = self.check_convergence( + converged=True, + max_unc=max_unc, + unc_convergence=unc_convergence, + ) + return self._converged + + def is_energy_minimized(self): + return True + + def is_parallel_allowed(self): + return False + + def update_arguments( + self, + slab=None, + adsorbate=None, + adsorbate2=None, + bounds=None, + opt_kwargs=None, + parallel_run=None, + comm=None, + verbose=None, + **kwargs, + ): + """ + Update the instance with its arguments. + The existing arguments are used if they are not given. + + Parameters: + atoms: Atoms object + The atoms object to be optimized. + parallel_run: bool + If True, the optimization will be run in parallel. + comm: ASE communicator object + The communicator object for parallelization. + verbose: bool + Whether to print the full output (True) or + not (False). + """ + # Set the communicator + if comm is not None: + self.comm = comm + self.rank = comm.rank + self.size = comm.size + # Set the verbose + if verbose is not None: + self.verbose = verbose + # Create the atoms object from the slab and adsorbate + if slab is not None and adsorbate is not None: + self.create_slab_ads(slab, adsorbate, adsorbate2) + # Create the boundary conditions + if bounds is not None: + self.setup_bounds(bounds) + if opt_kwargs is not None: + self.opt_kwargs = opt_kwargs.copy() + if parallel_run is not None: + self.parallel_run = parallel_run + self.check_parallel() + return self + + def rotation_matrix(self, angles, positions): + "Rotate the adsorbate" + theta1, theta2, theta3 = angles + Rz = np.array( + [ + [np.cos(theta1), -np.sin(theta1), 0.0], + [np.sin(theta1), np.cos(theta1), 0.0], + [0.0, 0.0, 1.0], + ] + ) + Ry = np.array( + [ + [np.cos(theta2), 0.0, np.sin(theta2)], + [0.0, 1.0, 0.0], + [-np.sin(theta2), 0.0, np.cos(theta2)], + ] + ) + R = np.matmul(Ry, Rz) + Rz = np.array( + [ + [np.cos(theta3), -np.sin(theta3), 0.0], + [np.sin(theta3), np.cos(theta3), 0.0], + [0.0, 0.0, 1.0], + ] + ) + R = np.matmul(Rz, R).T + positions = np.matmul(positions, R) + return positions + + def evaluate_value(self, x, **kwargs): + "Evaluate the value of the adsorption." + # Get the positions + pos = self.positions0.copy() + # Calculate the positions of the adsorbate + pos_ads = pos[self.n_slab : self.n_slab + self.n_ads] + pos_ads = self.rotation_matrix(x[3:6], pos_ads) + pos_ads += np.sum(self.cell * x[:3].reshape(-1, 1), axis=0) + pos[self.n_slab : self.n_slab + self.n_ads] = pos_ads + # Calculate the positions of the second adsorbate + if self.n_ads2 > 0: + pos_ads2 = pos[self.n_slab + self.n_ads :] + pos_ads2 = self.rotation_matrix(x[9:12], pos_ads2) + pos_ads2 += np.sum(self.cell * x[6:9].reshape(-1, 1), axis=0) + pos[self.n_slab + self.n_ads :] = pos_ads2 + # Set the positions + self.optimizable.set_positions(pos) + # Get the potential energy + return self.optimizable.get_potential_energy() + + def get_arguments(self): + "Get the arguments of the class itself." + # Get the arguments given to the class in the initialization + arg_kwargs = dict( + slab=self.slab, + adsorbate=self.adsorbate, + adsorbate2=self.adsorbate2, + bounds=self.bounds, + opt_kwargs=self.opt_kwargs, + parallel_run=self.parallel_run, + comm=self.comm, + verbose=self.verbose, + ) + # Get the constants made within the class + constant_kwargs = dict(steps=self.steps, _converged=self._converged) + # Get the objects made within the class + object_kwargs = dict() + return arg_kwargs, constant_kwargs, object_kwargs diff --git a/catlearn/optimizer/local.py b/catlearn/optimizer/local.py new file mode 100644 index 00000000..caed089e --- /dev/null +++ b/catlearn/optimizer/local.py @@ -0,0 +1,248 @@ +from .method import OptimizerMethod +import ase +from ase.parallel import world +from ase.optimize import FIRE +import numpy as np + + +class LocalOptimizer(OptimizerMethod): + def __init__( + self, + atoms, + local_opt=FIRE, + local_opt_kwargs={}, + parallel_run=False, + comm=world, + verbose=False, + **kwargs, + ): + """ + The LocalOptimizer is used to run a local optimization on + a given structure. + The LocalOptimizer is applicable to be used with Bayesian optimization. + + Parameters: + atoms: Atoms instance + The instance to be optimized. + local_opt: ASE optimizer object + The local optimizer object. + local_opt_kwargs: dict + The keyword arguments for the local optimizer. + parallel_run: bool + If True, the optimization will be run in parallel. + comm: ASE communicator instance + The communicator object for parallelization. + verbose: bool + Whether to print the full output (True) or + not (False). + """ + # Set the parameters + self.update_arguments( + atoms=atoms, + local_opt=local_opt, + local_opt_kwargs=local_opt_kwargs, + parallel_run=parallel_run, + comm=comm, + verbose=verbose, + **kwargs, + ) + + def run( + self, + fmax=0.05, + steps=1000, + max_unc=None, + dtrust=None, + unc_convergence=None, + **kwargs, + ): + # Run the local optimization + with self.local_opt( + self.optimizable, **self.local_opt_kwargs + ) as optimizer: + if max_unc is None and dtrust is None: + optimizer.run(fmax=fmax, steps=steps) + else: + optimizer = self.run_max_unc( + optimizer=optimizer, + fmax=fmax, + steps=steps, + max_unc=max_unc, + dtrust=dtrust, + **kwargs, + ) + # Check if the optimization is converged + self._converged = self.check_convergence( + converged=optimizer.converged(), + max_unc=max_unc, + dtrust=dtrust, + unc_convergence=unc_convergence, + ) + # Return whether the optimization is converged + return self._converged + + def run_max_unc( + self, + optimizer, + fmax=0.05, + steps=1000, + max_unc=None, + dtrust=None, + **kwargs, + ): + """ + Run the optimization with a maximum uncertainty. + + Parameters: + optimizer: ASE optimizer object + The optimizer object. + fmax: float + The maximum force allowed on an atom. + steps: int + The maximum number of steps allowed. + max_unc: float + The maximum uncertainty allowed on a structure. + dtrust: float + The distance trust criterion. + + Returns: + optimizer: ASE optimizer instance + The optimizer instance. + """ + # Make a copy of the atoms + while self.steps < steps: + # Check if the maximum number of steps is reached + if self.steps >= steps: + self.message("The maximum number of steps is reached.") + break + # Run a local optimization step + self.run_max_unc_step(optimizer, fmax=fmax, **kwargs) + # Check if the uncertainty is above the maximum allowed + if max_unc is not None: + # Get the uncertainty of the atoms + unc = self.get_uncertainty() + if unc > max_unc: + self.message("Uncertainty is above the maximum allowed.") + break + # Check if the structures are within the trust distance + if dtrust is not None: + within_dtrust = self.is_within_dtrust(dtrust=dtrust) + if not within_dtrust: + self.message("Outside of the trust distance.") + break + # Check if there is a problem with the calculation + energy = self.get_potential_energy() + if np.isnan(energy): + self.message("The energy is NaN.") + break + # Check if the optimization is converged + if optimizer.converged(): + break + return optimizer + + def setup_local_optimizer(self, local_opt=None, local_opt_kwargs={}): + """ + Setup the local optimizer. + + Parameters: + local_opt: ASE optimizer object + The local optimizer object. + local_opt_kwargs: dict + The keyword arguments for the local optimizer. + """ + self.local_opt_kwargs = dict() + if not self.verbose: + self.local_opt_kwargs["logfile"] = None + if local_opt is None: + local_opt = FIRE + self.local_opt_kwargs.update( + dict(dt=0.05, maxstep=0.2, a=1.0, astart=1.0, fa=0.999) + ) + self.local_opt = local_opt + self.local_opt_kwargs.update(local_opt_kwargs) + return self + + def is_energy_minimized(self): + return True + + def is_parallel_allowed(self): + return False + + def update_arguments( + self, + atoms=None, + local_opt=None, + local_opt_kwargs={}, + parallel_run=None, + comm=None, + verbose=None, + **kwargs, + ): + """ + Update the instance with its arguments. + The existing arguments are used if they are not given. + + Parameters: + atoms: Atoms instance + The instance to be optimized. + local_opt: ASE optimizer object + The local optimizer object. + local_opt_kwargs: dict + The keyword arguments for the local optimizer. + parallel_run: bool + If True, the optimization will be run in parallel. + comm: ASE communicator instance + The communicator object for parallelization. + verbose: bool + Whether to print the full output (True) or + not (False). + """ + # Set the communicator + if comm is not None: + self.comm = comm + self.rank = comm.rank + self.size = comm.size + # Set the verbose + if verbose is not None: + self.verbose = verbose + if atoms is not None: + self.setup_optimizable(atoms) + if local_opt is not None and local_opt_kwargs is not None: + self.setup_local_optimizer(local_opt, local_opt_kwargs) + elif local_opt is not None: + self.setup_local_optimizer(self.local_opt) + elif local_opt_kwargs is not None: + self.setup_local_optimizer(self.local_opt, local_opt_kwargs) + if parallel_run is not None: + self.parallel_run = parallel_run + self.check_parallel() + return self + + def run_max_unc_step(self, optimizer, fmax=0.05, **kwargs): + """ + Run a local optimization step. + The ASE optimizer is dependent on the ASE version. + """ + if ase.__version__ >= "3.23": + optimizer.run(fmax=fmax, steps=1, **kwargs) + else: + optimizer.run(fmax=fmax, steps=self.steps + 1, **kwargs) + self.steps += 1 + return optimizer + + def get_arguments(self): + "Get the arguments of the class itself." + # Get the arguments given to the class in the initialization + arg_kwargs = dict( + atoms=self.optimizable, + local_opt=self.local_opt, + local_opt_kwargs=self.local_opt_kwargs, + parallel_run=self.parallel_run, + comm=self.comm, + verbose=self.verbose, + ) + # Get the constants made within the class + constant_kwargs = dict(steps=self.steps, _converged=self._converged) + # Get the objects made within the class + object_kwargs = dict() + return arg_kwargs, constant_kwargs, object_kwargs diff --git a/catlearn/optimizer/localcineb.py b/catlearn/optimizer/localcineb.py new file mode 100644 index 00000000..a8a51cfb --- /dev/null +++ b/catlearn/optimizer/localcineb.py @@ -0,0 +1,295 @@ +from ase.parallel import world +from ase.io import read +from ase.optimize import FIRE +from .localneb import LocalNEB +from .sequential import SequentialOptimizer +from ..structures.neb import ImprovedTangentNEB, make_interpolation + + +class LocalCINEB(SequentialOptimizer): + def __init__( + self, + start, + end, + neb_method=ImprovedTangentNEB, + neb_kwargs={}, + n_images=15, + climb=True, + neb_interpolation="linear", + neb_interpolation_kwargs={}, + reuse_ci_path=False, + local_opt=FIRE, + local_opt_kwargs={}, + parallel_run=False, + comm=world, + verbose=False, + **kwargs, + ): + """ + The LocalNEB is used to run a local optimization of NEB. + The LocalNEB is applicable to be used with Bayesian optimization. + + Parameters: + start : Atoms instance or ASE Trajectory file. + The Atoms must have the calculator attached with energy. + Initial end-point of the NEB path. + end : Atoms instance or ASE Trajectory file. + The Atoms must have the calculator attached with energy. + Final end-point of the NEB path. + neb_method : NEB class object or str + The NEB implemented class object used for the ML-NEB. + A string can be used to select: + - 'improvedtangentneb' (default) + - 'ewneb' + neb_kwargs : dict + A dictionary with the arguments used in the NEB object + to create the instance. + Climb must not be included. + n_images : int + Number of images of the path (if not included a path before). + The number of images include the 2 end-points of the NEB path. + climb : bool + Whether to use the climbing image in the NEB. + It is strongly recommended to have climb=True. + neb_interpolation : str + The interpolation method used to create the NEB path. + The default is 'linear'. + neb_interpolation_kwargs : dict + The keyword arguments for the interpolation method. + reuse_ci_path : bool + Whether to remove the non-climbing image method when the NEB + without climbing image is converged. + local_opt: ASE optimizer object + The local optimizer object. + local_opt_kwargs: dict + The keyword arguments for the local optimizer. + parallel_run: bool + If True, the optimization will be run in parallel. + comm: ASE communicator instance + The communicator object for parallelization. + verbose: bool + Whether to print the full output (True) or + not (False). + """ + # Save the end points for creating the NEB + self.setup_endpoints(start, end) + # Build the optimizer methods and NEB within + methods = self.build_method( + neb_method, + neb_kwargs=neb_kwargs, + climb=climb, + n_images=n_images, + neb_interpolation=neb_interpolation, + neb_interpolation_kwargs=neb_interpolation_kwargs, + reuse_ci_path=reuse_ci_path, + local_opt=local_opt, + local_opt_kwargs=local_opt_kwargs, + parallel_run=parallel_run, + comm=comm, + verbose=verbose, + **kwargs, + ) + # Set the parameters + self.update_arguments( + methods=methods, + remove_methods=reuse_ci_path, + parallel_run=parallel_run, + comm=comm, + verbose=verbose, + **kwargs, + ) + + def setup_endpoints(self, start, end, **kwargs): + """ + Setup the start and end points for the NEB calculation. + """ + # Load the start and end points from trajectory files + if isinstance(start, str): + start = read(start) + if isinstance(end, str): + end = read(end) + # Save the start point with calculators + start.get_forces() + self.start = self.copy_atoms(start) + # Save the end point with calculators + end.get_forces() + self.end = self.copy_atoms(end) + return self + + def setup_neb( + self, + neb_method, + neb_kwargs={}, + climb=True, + n_images=15, + k=3.0, + remove_rotation_and_translation=False, + mic=True, + neb_interpolation="linear", + neb_interpolation_kwargs={}, + parallel=False, + comm=None, + **kwargs, + ): + """ + Setup the NEB instance. + """ + # Create the neb method if it is a string + if neb_method is None: + neb_method = ImprovedTangentNEB + elif isinstance(neb_method, str): + if neb_method.lower() == "improvedtangentneb": + neb_method = ImprovedTangentNEB + elif neb_method.lower() == "ewneb": + from ..structures.neb.ewneb import EWNEB + + neb_method = EWNEB + else: + raise Exception( + "The NEB method {} is not implemented.".format(neb_method) + ) + self.neb_method = neb_method + # Create default dictionary for creating the NEB + self.neb_kwargs = dict( + k=k, + remove_rotation_and_translation=remove_rotation_and_translation, + mic=mic, + save_properties=False, + parallel=parallel, + world=comm, + ) + # Save the dictionary for creating the NEB + self.neb_kwargs.update(neb_kwargs) + # Save the number of images + self.n_images = n_images + # Save the instances for creating the NEB interpolation + self.neb_interpolation = neb_interpolation + # Create default dictionary for creating the NEB interpolation + self.neb_interpolation_kwargs = dict( + mic=mic, + remove_rotation_and_translation=remove_rotation_and_translation, + ) + # Save the dictionary for creating the NEB interpolation + self.neb_interpolation_kwargs.update(neb_interpolation_kwargs) + # Make the images for the NEB from the interpolation + images = make_interpolation( + start=self.start, + end=self.end, + n_images=self.n_images, + method=self.neb_interpolation, + **self.neb_interpolation_kwargs, + ) + # Create the NEB + neb = self.neb_method(images, climb=climb, **self.neb_kwargs) + return neb + + def build_method( + self, + neb_method, + neb_kwargs={}, + climb=True, + n_images=15, + k=3.0, + remove_rotation_and_translation=False, + mic=True, + neb_interpolation="linear", + neb_interpolation_kwargs={}, + local_opt=FIRE, + local_opt_kwargs={}, + parallel_run=False, + comm=world, + verbose=False, + **kwargs, + ): + "Build the optimization method." + # Save the instances for creating the local optimizer + self.local_opt = local_opt + self.local_opt_kwargs = local_opt_kwargs + # Save the instances for creating the NEB + self.climb = climb + # Setup NEB without climbing image + neb_noclimb = self.setup_neb( + neb_method=neb_method, + neb_kwargs=neb_kwargs, + climb=False, + n_images=n_images, + k=k, + remove_rotation_and_translation=remove_rotation_and_translation, + mic=mic, + neb_interpolation=neb_interpolation, + neb_interpolation_kwargs=neb_interpolation_kwargs, + parallel=parallel_run, + comm=comm, + **kwargs, + ) + # Build the optimizer method without climbing image + method_noclimb = LocalNEB( + neb_noclimb, + local_opt=local_opt, + local_opt_kwargs=local_opt_kwargs, + parallel_run=parallel_run, + comm=comm, + verbose=verbose, + ) + # Return the method without climbing image + methods = [method_noclimb] + if not climb: + return methods + # Setup NEB with climbing image + neb_climb = self.setup_neb( + neb_method=neb_method, + neb_kwargs=neb_kwargs, + climb=True, + n_images=n_images, + k=k, + remove_rotation_and_translation=remove_rotation_and_translation, + mic=mic, + neb_interpolation=neb_interpolation, + neb_interpolation_kwargs=neb_interpolation_kwargs, + parallel=parallel_run, + comm=comm, + **kwargs, + ) + # Build the optimizer method with climbing image + method_climb = LocalNEB( + neb_climb, + local_opt=local_opt, + local_opt_kwargs=local_opt_kwargs, + parallel_run=parallel_run, + comm=comm, + verbose=verbose, + ) + # Return the without and with climbing image + methods.append(method_climb) + return methods + + def is_energy_minimized(self): + return self.methods[-1].is_energy_minimized() + + def is_parallel_allowed(self): + return True + + def get_arguments(self): + "Get the arguments of the class itself." + # Get the arguments given to the class in the initialization + arg_kwargs = dict( + start=self.start, + end=self.end, + neb_method=self.neb_method, + neb_kwargs=self.neb_kwargs, + n_images=self.n_images, + climb=self.climb, + neb_interpolation=self.neb_interpolation, + neb_interpolation_kwargs=self.neb_interpolation_kwargs, + reuse_ci_path=self.remove_methods, + local_opt=self.local_opt, + local_opt_kwargs=self.local_opt_kwargs, + parallel_run=self.parallel_run, + comm=self.comm, + verbose=self.verbose, + ) + # Get the constants made within the class + constant_kwargs = dict(steps=self.steps, _converged=self._converged) + # Get the objects made within the class + object_kwargs = dict() + return arg_kwargs, constant_kwargs, object_kwargs diff --git a/catlearn/optimizer/localneb.py b/catlearn/optimizer/localneb.py new file mode 100644 index 00000000..05a53867 --- /dev/null +++ b/catlearn/optimizer/localneb.py @@ -0,0 +1,158 @@ +from .local import LocalOptimizer +from ase.parallel import world +from ase.optimize import FIRE +import numpy as np + + +class LocalNEB(LocalOptimizer): + def __init__( + self, + neb, + local_opt=FIRE, + local_opt_kwargs={}, + parallel_run=False, + comm=world, + verbose=False, + **kwargs, + ): + """ + The LocalNEB is used to run a local optimization of NEB. + The LocalNEB is applicable to be used with Bayesian optimization. + + Parameters: + neb: NEB instance + The NEB object to be optimized. + local_opt: ASE optimizer object + The local optimizer object. + local_opt_kwargs: dict + The keyword arguments for the local optimizer. + parallel_run: bool + If True, the optimization will be run in parallel. + comm: ASE communicator instance + The communicator object for parallelization. + verbose: bool + Whether to print the full output (True) or + not (False). + """ + # Set the parameters + self.update_arguments( + neb=neb, + local_opt=local_opt, + local_opt_kwargs=local_opt_kwargs, + parallel_run=parallel_run, + comm=comm, + verbose=verbose, + **kwargs, + ) + + def update_optimizable(self, structures, **kwargs): + # Get the positions of the NEB images + positions = [image.get_positions() for image in structures[1:-1]] + positions = np.asarray(positions).reshape(-1, 3) + # Set the positions of the NEB images + self.optimizable.set_positions(positions) + # Reset the optimization + self.reset_optimization() + return self + + def get_structures(self): + return [self.copy_atoms(image) for image in self.optimizable.images] + + def get_candidates(self): + return self.optimizable.images[1:-1] + + def set_calculator(self, calculator, copy_calc=False, **kwargs): + if isinstance(calculator, list): + if len(calculator) != len(self.optimizable.images[1:-1]): + raise Exception( + "The number of calculators should be equal to " + "the number of moving images!" + ) + for image, calc in zip(self.optimizable.images[1:-1], calculator): + if copy_calc: + image.calc = calc.copy() + else: + image.calc = calc + else: + for image in self.optimizable.images[1:-1]: + if copy_calc: + image.calc = calculator.copy() + else: + image.calc = calculator + return self + + def get_calculator(self): + return [image.calc for image in self.optimizable.images[1:-1]] + + def is_energy_minimized(self): + return False + + def is_parallel_allowed(self): + return True + + def update_arguments( + self, + neb=None, + local_opt=None, + local_opt_kwargs={}, + parallel_run=None, + comm=None, + verbose=None, + **kwargs, + ): + """ + Update the instance with its arguments. + The existing arguments are used if they are not given. + + Parameters: + neb: NEB instance + The NEB object to be optimized. + local_opt: ASE optimizer object + The local optimizer object. + local_opt_kwargs: dict + The keyword arguments for the local optimizer. + parallel_run: bool + If True, the optimization will be run in parallel. + comm: ASE communicator instance + The communicator object for parallelization. + verbose: bool + Whether to print the full output (True) or + not (False). + """ + # Set the communicator + if comm is not None: + self.comm = comm + self.rank = comm.rank + self.size = comm.size + # Set the verbose + if verbose is not None: + self.verbose = verbose + if neb is not None: + self.setup_optimizable(neb) + if local_opt is not None and local_opt_kwargs is not None: + self.setup_local_optimizer(local_opt, local_opt_kwargs) + elif local_opt is not None: + self.setup_local_optimizer(self.local_opt) + elif local_opt_kwargs is not None: + self.setup_local_optimizer(self.local_opt, local_opt_kwargs) + if parallel_run is not None: + self.parallel_run = parallel_run + self.check_parallel() + return self + + def get_arguments(self): + "Get the arguments of the class itself." + # Get the arguments given to the class in the initialization + arg_kwargs = dict( + neb=self.optimizable, + local_opt=self.local_opt, + local_opt_kwargs=self.local_opt_kwargs, + parallel_run=self.parallel_run, + comm=self.comm, + verbose=self.verbose, + ) + # Get the constants made within the class + constant_kwargs = dict(steps=self.steps, _converged=self._converged) + # Get the objects made within the class + object_kwargs = dict() + return arg_kwargs, constant_kwargs, object_kwargs diff --git a/catlearn/optimizer/method.py b/catlearn/optimizer/method.py new file mode 100644 index 00000000..06be1691 --- /dev/null +++ b/catlearn/optimizer/method.py @@ -0,0 +1,596 @@ +import numpy as np +from ase.parallel import world +from ..regression.gp.calculator.copy_atoms import copy_atoms +from ..structures.structure import Structure + + +class OptimizerMethod: + def __init__( + self, + optimizable, + parallel_run=False, + comm=world, + verbose=False, + **kwargs, + ): + """ + The OptimizerMethod class is a base class for all optimization methods. + The OptimizerMethod is used to run an optimization on a given + optimizable. + The OptimizerMethod is applicable to be used with Bayesian + optimization. + + Parameters: + optimizable: optimizable instance + The instance to be optimized. + Often, an Atoms or NEB instance. + Here, it assumed to be an Atoms instance. + parallel_run: bool + If True, the optimization will be run in parallel. + comm: ASE communicator instance + The communicator object for parallelization. + verbose: bool + Whether to print the full output (True) or + not (False). + """ + # Set the parameters + self.update_arguments( + optimizable=optimizable, + parallel_run=parallel_run, + comm=comm, + verbose=verbose, + **kwargs, + ) + + def update_optimizable(self, structures, **kwargs): + """ + Update the optimizable instance by given + dependent structures. + + Parameters: + structures: Atoms instance or list of Atoms instances + The structures that the optimizable instance is dependent on. + """ + # Check if the structures are a list + if isinstance(structures, list): + raise NotImplementedError( + "The method does not support multiple structures" + ) + # Update optimizable by setting the positions of the optimizable + self.optimizable.set_positions(structures.get_positions()) + # Reset the optimization + self.reset_optimization() + return self + + def get_optimizable(self): + """ + Get the optimizable that are considered for the optimizer. + + Returns: + optimizable: The optimizable instance. + Often, an Atoms or NEB instance. + """ + return self.optimizable + + def get_structures(self): + """ + Get the structures that optimizable instance is dependent on. + + Returns: + structures: Atoms instance or list of Atoms instances + The structures that the optimizable instance is dependent on. + """ + return self.copy_atoms(self.optimizable) + + def get_candidates(self): + """ + Get the candidate structure instances. + It is used for Bayesian optimization. + """ + return [self.optimizable] + + def reset_optimization(self): + """ + Reset the optimization. + """ + self.steps = 0 + self._converged = False + return self + + def setup_optimizable(self, optimizable): + """ + Set the optimizable instance. + + Parameters: + optimizable: optimizable instance + The instance to be optimized. + Often, an Atoms or NEB instance. + """ + self.optimizable = optimizable + self.reset_optimization() + return self + + def set_calculator(self, calculator, copy_calc=False, **kwargs): + """ + Set the calculator for the optimizable instance. + + Parameters: + calculator: ASE calculator instance + The calculator to be set. + copy_calc: bool + If True, the calculator will be copied. + """ + if copy_calc: + self.optimizable.calc = calculator.copy() + else: + self.optimizable.calc = calculator + return self + + def get_calculator(self): + """ + Get the calculator of the optimizable instance. + """ + return self.optimizable.calc + + @property + def calc(self): + """ + The calculator instance. + """ + return self.get_calculator() + + @calc.setter + def calc(self, calculators): + return self.set_calculator(calculators) + + def get_potential_energy(self, per_candidate=False, **kwargs): + """ + Get the potential energy of the optimizable. + + Parameters: + per_candidate: bool + If True, the potential energy of each candidate is returned. + Else, the potential energy of the optimizable is returned. + + Returns: + energy: float or list + The potential energy of the optimizable. + """ + if per_candidate: + energy = [ + atoms.get_potential_energy(**kwargs) + for atoms in self.get_candidates() + ] + else: + energy = self.optimizable.get_potential_energy(**kwargs) + return energy + + def get_forces(self, per_candidate=False, **kwargs): + """ + Get the forces of the optimizable. + + Parameters: + per_candidate: bool + If True, the forces of each candidate is returned. + Else, the forces of the optimizable is returned + + Returns: + force: (N,3) array or list of (N,3) arrays + The forces of the optimizable. + """ + if per_candidate: + force = [ + atoms.get_forces(**kwargs) for atoms in self.get_candidates() + ] + else: + force = self.optimizable.get_forces(**kwargs) + return force + + def get_fmax(self, per_candidate=False, **kwargs): + """ + Get the maximum force of an atom in the optimizable. + + Parameters: + per_candidate: bool + If True, the maximum force of each candidate is returned. + Else, the maximum force of the optimizable is returned. + + Returns: + fmax: float or list + The maximum force of the optimizable. + """ + force = self.get_forces(per_candidate=per_candidate, **kwargs) + fmax = np.linalg.norm(force, axis=-1).max(axis=-1) + return fmax + + def get_uncertainty(self, per_candidate=False, **kwargs): + """ + Get the uncertainty of the optimizable. + It is used for Bayesian optimization. + + Parameters: + per_candidate: bool + If True, the uncertainty of each candidate is returned. + Else, the maximum uncertainty of the optimizable is returned. + + Returns: + uncertainty: float or list + The uncertainty of the optimizable. + """ + uncertainty = [] + for atoms in self.get_candidates(): + if isinstance(atoms, Structure): + unc = atoms.get_uncertainty(**kwargs) + else: + unc = atoms.calc.get_property( + "uncertainty", + atoms=atoms, + **kwargs, + ) + uncertainty.append(unc) + if not per_candidate: + uncertainty = np.max(uncertainty) + return uncertainty + + def get_property( + self, + name, + allow_calculation=True, + per_candidate=False, + **kwargs, + ): + """ + Get or calculate the requested property. + + Parameters: + name: str + The name of the requested property. + allow_calculation: bool + Whether the property is allowed to be calculated. + per_candidate: bool + If True, the property of each candidate is returned. + Else, the property of the optimizable is returned. + + Returns: + float or list: The requested property. + """ + if per_candidate: + output = [] + for atoms in self.get_candidates(): + if name == "energy": + result = atoms.get_potential_energy(**kwargs) + elif name == "forces": + result = atoms.get_forces(**kwargs) + elif name == "fmax": + force = atoms.get_forces(**kwargs) + result = np.linalg.norm(force, axis=-1).max() + elif name == "uncertainty" and isinstance(atoms, Structure): + result = atoms.get_uncertainty(**kwargs) + else: + result = atoms.calc.get_property( + name, + atoms=atoms, + allow_calculation=allow_calculation, + **kwargs, + ) + output.append(result) + else: + if name == "energy": + output = self.get_potential_energy( + per_candidate=per_candidate, + **kwargs, + ) + elif name == "forces": + output = self.get_forces(per_candidate=per_candidate, **kwargs) + elif name == "fmax": + output = self.get_fmax(per_candidate=per_candidate, **kwargs) + elif name == "uncertainty": + output = self.get_uncertainty( + per_candidate=per_candidate, + **kwargs, + ) + else: + output = self.optimizable.calc.get_property( + name, + atoms=self.optimizable, + allow_calculation=allow_calculation, + **kwargs, + ) + return output + + def get_properties( + self, + properties, + allow_calculation=True, + per_candidate=False, + **kwargs, + ): + """ + Get or calculate the requested properties. + + Parameters: + properties: list of str + The names of the requested properties. + allow_calculation: bool + Whether the properties are allowed to be calculated. + per_candidate: bool + If True, the properties of each candidate are returned. + Else, the properties of the optimizable are returned. + + Returns: + dict: The requested properties. + """ + + if per_candidate: + results = {name: [] for name in properties} + for atoms in self.get_candidates(): + for name in properties: + if name == "energy": + output = atoms.get_potential_energy(**kwargs) + elif name == "forces": + output = atoms.get_forces(**kwargs) + elif name == "fmax": + force = atoms.get_forces(**kwargs) + output = np.linalg.norm(force, axis=-1).max() + elif name == "uncertainty" and isinstance( + atoms, Structure + ): + output = atoms.get_uncertainty(**kwargs) + else: + output = atoms.calc.get_property( + name, + atoms=atoms, + allow_calculation=allow_calculation, + **kwargs, + ) + results[name].append(output) + else: + results = {} + for name in properties: + results[name] = self.get_property( + name=name, + allow_calculation=allow_calculation, + per_candidate=per_candidate, + **kwargs, + ) + return results + + def is_within_dtrust(self, per_candidate=False, dtrust=2.0, **kwargs): + """ + Get whether the structures are within a trust distance to the database. + It is used for Bayesian optimization. + + Parameters: + per_candidate: bool + If True, the distance of each candidate is returned. + Else, the maximum distance of the optimizable is returned. + dtrust: float + The distance trust criterion. + + Returns: + within_dtrust: float or list + Whether the structures are within a trust distance to + the database. + """ + within_dtrust = [] + for atoms in self.get_candidates(): + if isinstance(atoms, Structure): + real_atoms = atoms.get_structure() + within = real_atoms.calc.is_in_database( + real_atoms, + dtol=dtrust, + **kwargs, + ) + else: + within = atoms.calc.is_in_database( + atoms, + dtol=dtrust, + **kwargs, + ) + within_dtrust.append(within) + if not per_candidate: + if False in within_dtrust: + within_dtrust = False + else: + within_dtrust = True + return within_dtrust + + def get_number_of_steps(self): + """ + Get the number of steps that have been run. + """ + return self.steps + + def converged(self): + """ + Check if the optimization is converged. + """ + return self._converged + + def is_energy_minimized(self): + """ + Check if the optimization method minimizes the energy. + """ + return True + + def is_parallel_allowed(self): + """ + Check if the optimization method allows parallelization. + """ + return False + + def run( + self, + fmax=0.05, + steps=1000, + max_unc=None, + dtrust=None, + unc_convergence=None, + **kwargs, + ): + """ + Run the optimization. + + Parameters: + fmax: float + The maximum force allowed on an atom. + steps: int + The maximum number of steps allowed. + max_unc: float (optional) + Maximum uncertainty for continuation of the optimization. + dtrust: float (optional) + The distance trust criterion. + unc_convergence: float (optional) + The uncertainty convergence criterion for convergence. + + Returns: + coverged: bool + Whether the optimization is converged. + """ + raise NotImplementedError("The run method is not implemented") + + def run_max_unc(self, **kwargs): + """ + Run the optimization with a maximum uncertainty. + The uncertainty is checked at each optimization step if requested. + The trust distance is checked at each optimization step if requested. + It is used for Bayesian optimization. + """ + raise NotImplementedError("The run_max_unc method is not implemented") + + def check_convergence( + self, + converged, + max_unc=None, + dtrust=None, + unc_convergence=None, + **kwargs, + ): + """ + Check if the optimization is converged also in terms of uncertainty. + The uncertainty is used for Bayesian optimization. + + Parameters: + converged: bool + Whether the optimization is converged. + max_unc: float (optional) + The maximum uncertainty allowed. + dtrust: float (optional) + The distance trust criterion. + unc_convergence: float (optional) + The uncertainty convergence criterion for convergence. + + Returns: + converged: bool + Whether the optimization is converged. + """ + # Check if the optimization is converged at all + if not converged: + return False + # Check if the optimization is converged in terms of uncertainty + if max_unc is not None or unc_convergence is not None: + unc = self.get_uncertainty() + if max_unc is not None and unc > max_unc: + return False + if unc_convergence is not None and unc > unc_convergence: + return False + # Check if the optimization is converged in terms of database distance + if dtrust is not None: + within_dtrust = self.is_within_dtrust(dtrust=dtrust) + if not within_dtrust: + return False + return converged + + def update_arguments( + self, + optimizable=None, + parallel_run=None, + comm=None, + verbose=None, + **kwargs, + ): + """ + Update the instance with its arguments. + The existing arguments are used if they are not given. + + Parameters: + optimizable: optimizable instance + The instance to be optimized. + Often, an Atoms or NEB instance. + Here, it assumed to be an Atoms instance. + parallel_run: bool + If True, the optimization will be run in parallel. + comm: ASE communicator instance + The communicator object for parallelization. + verbose: bool + Whether to print the full output (True) or + not (False). + """ + # Set the communicator + if comm is not None: + self.comm = comm + self.rank = comm.rank + self.size = comm.size + # Set the verbose + if verbose is not None: + self.verbose = verbose + if optimizable is not None: + self.setup_optimizable(optimizable) + if parallel_run is not None: + self.parallel_run = parallel_run + self.check_parallel() + return self + + def copy_atoms(self, atoms): + "Copy an atoms instance." + return copy_atoms(atoms) + + def message(self, message): + "Print a message." + if self.verbose and self.rank == 0: + print(message) + return self + + def check_parallel(self): + "Check if the parallelization is allowed." + if self.parallel_run and not self.is_parallel_allowed(): + self.message("Parallel run is not supported for this method!") + return self + + def get_arguments(self): + "Get the arguments of the class itself." + # Get the arguments given to the class in the initialization + arg_kwargs = dict( + optimizable=self.optimizable, + parallel_run=self.parallel_run, + comm=self.comm, + verbose=self.verbose, + ) + # Get the constants made within the class + constant_kwargs = dict(steps=self.steps, _converged=self._converged) + # Get the objects made within the class + object_kwargs = dict() + return arg_kwargs, constant_kwargs, object_kwargs + + def copy(self): + "Copy the object." + # Get all arguments + arg_kwargs, constant_kwargs, object_kwargs = self.get_arguments() + # Make a clone + clone = self.__class__(**arg_kwargs) + # Check if constants have to be saved + if len(constant_kwargs.keys()): + for key, value in constant_kwargs.items(): + clone.__dict__[key] = value + # Check if objects have to be saved + if len(object_kwargs.keys()): + for key, value in object_kwargs.items(): + clone.__dict__[key] = value.copy() + return clone + + def __repr__(self): + arg_kwargs = self.get_arguments()[0] + str_kwargs = ",".join( + [f"{key}={value}" for key, value in arg_kwargs.items()] + ) + return "{}({})".format(self.__class__.__name__, str_kwargs) diff --git a/catlearn/optimizer/parallelopt.py b/catlearn/optimizer/parallelopt.py new file mode 100644 index 00000000..6b5da03f --- /dev/null +++ b/catlearn/optimizer/parallelopt.py @@ -0,0 +1,245 @@ +from .method import OptimizerMethod +from ase.parallel import world, broadcast +import numpy as np + + +class ParallelOptimizer(OptimizerMethod): + def __init__( + self, + method, + chains=None, + parallel_run=True, + comm=world, + verbose=False, + **kwargs, + ): + """ + The ParallelOptimizer is used to run an optimization in parallel. + The ParallelOptimizer is applicable to be used with + Bayesian optimization. + + Parameters: + method: OptimizerMethod instance + The optimization method to be used. + chains: int + The number of optimization that will be run in parallel. + parallel_run: bool + If True, the optimization will be run in parallel. + comm: ASE communicator instance + The communicator instance for parallelization. + verbose: bool + Whether to print the full output (True) or + not (False). + """ + # Set the number of chains + if chains is None: + if parallel_run: + chains = comm.size + else: + chains = 1 + # Set the parameters + self.update_arguments( + method=method, + chains=chains, + parallel_run=parallel_run, + comm=comm, + verbose=verbose, + **kwargs, + ) + + def update_optimizable(self, structures, **kwargs): + self.method.update_optimizable(structures, **kwargs) + self.methods = [self.method.copy() for _ in range(self.chains)] + self.reset_optimization() + return self + + def get_optimizable(self): + return self.method.get_optimizable() + + def get_structures(self): + return self.method.get_structures() + + def get_candidates(self): + return self.candidates + + def run( + self, + fmax=0.05, + steps=1000000, + max_unc=None, + dtrust=None, + unc_convergence=None, + **kwargs, + ): + # Set the rank + rank = 0 + # Make list of properties + candidates = [None] * self.chains + converged = [False] * self.chains + used_steps = [self.steps] * self.chains + values = [np.inf] * self.chains + # Run the optimizations + for chain, method in enumerate(self.methods): + if self.rank == rank: + # Set the random seed + np.random.RandomState(chain + 1) + # Run the optimization + method.run( + fmax=fmax, + steps=steps, + max_unc=max_unc, + dtrust=dtrust, + **kwargs, + ) + # Update the number of steps + used_steps[chain] += method.get_number_of_steps() + # Get the candidates + candidates[chain] = method.get_candidates() + # Check if the optimization is converged + converged[chain] = method.converged() + # Get the values + if self.method.is_energy_minimized(): + values[chain] = method.get_potential_energy() + else: + values[chain] = method.get_fmax() + + # Update the rank + rank += 1 + if rank == self.size: + rank = 0 + # Broadcast the saved instances + rank = 0 + for chain, method in enumerate(self.methods): + self.methods[chain] = broadcast( + self.methods[chain], + root=rank, + comm=self.comm, + ) + candidates[chain] = broadcast( + candidates[chain], + root=rank, + comm=self.comm, + ) + converged[chain] = broadcast( + converged[chain], + root=rank, + comm=self.comm, + ) + used_steps[chain] = broadcast( + used_steps[chain], + root=rank, + comm=self.comm, + ) + values[chain] = broadcast( + values[chain], + root=rank, + comm=self.comm, + ) + # Update the rank + rank += 1 + if rank == self.size: + rank = 0 + # Set the candidates + self.candidates = [] + for candidate_inner in candidates: + for candidate in candidate_inner: + self.candidates.append(candidate) + # Check the minimum value + i_min = np.argmin(values) + self.method = self.methods[i_min] + self.steps = np.max(used_steps) + # Check if the optimization is converged + self._converged = self.check_convergence( + converged=converged[i_min], + max_unc=max_unc, + dtrust=dtrust, + unc_convergence=unc_convergence, + ) + return self._converged + + def set_calculator(self, calculator, copy_calc=False, **kwargs): + self.method.set_calculator(calculator, copy_calc=copy_calc, **kwargs) + for method in self.methods: + method.set_calculator(calculator, copy_calc=copy_calc, **kwargs) + return self + + def is_energy_minimized(self): + return self.methods[-1].is_energy_minimized() + + def is_parallel_allowed(self): + return True + + def update_arguments( + self, + method=None, + chains=None, + parallel_run=None, + comm=None, + verbose=None, + **kwargs, + ): + """ + Update the instance with its arguments. + The existing arguments are used if they are not given. + + Parameters: + method: OptimizerMethod instance + The optimization method to be used. + chains: int + The number of optimization that will be run in parallel. + parallel_run: bool + If True, the optimization will be run in parallel. + comm: ASE communicator instance + The communicator instance for parallelization. + verbose: bool + Whether to print the full output (True) or + not (False). + """ + # Set the communicator + if comm is not None: + self.comm = comm + self.rank = comm.rank + self.size = comm.size + # Set the verbose + if verbose is not None: + self.verbose = verbose + if chains is not None: + self.chains = chains + if method is not None: + self.method = method.copy() + self.methods = [method.copy() for _ in range(self.chains)] + self.setup_optimizable() + if parallel_run is not None: + self.parallel_run = parallel_run + self.check_parallel() + if verbose is not None: + self.verbose = verbose + if self.chains % self.size != 0: + self.message( + "The number of chains should be divisible by " + "the number of processors!" + ) + return self + + def setup_optimizable(self, **kwargs): + self.optimizable = self.method.get_optimizable() + self.structures = self.method.get_structures() + self.candidates = self.method.get_candidates() + self.reset_optimization() + return self + + def get_arguments(self): + "Get the arguments of the class itself." + # Get the arguments given to the class in the initialization + arg_kwargs = dict( + method=self.method, + chains=self.chains, + parallel_run=self.parallel_run, + comm=self.comm, + verbose=self.verbose, + ) + # Get the constants made within the class + constant_kwargs = dict(steps=self.steps, _converged=self._converged) + # Get the objects made within the class + object_kwargs = dict() + return arg_kwargs, constant_kwargs, object_kwargs diff --git a/catlearn/optimizer/sequential.py b/catlearn/optimizer/sequential.py new file mode 100644 index 00000000..51510225 --- /dev/null +++ b/catlearn/optimizer/sequential.py @@ -0,0 +1,190 @@ +from .method import OptimizerMethod +from ase.parallel import world + + +class SequentialOptimizer(OptimizerMethod): + def __init__( + self, + methods, + remove_methods=False, + parallel_run=False, + comm=world, + verbose=False, + **kwargs, + ): + """ + The SequentialOptimizer is used to run multiple optimizations in + sequence for a given structure. + The SequentialOptimizer is applicable to be used with + Bayesian optimization. + + Parameters: + methods: List of OptimizerMethod objects + The list of optimization methods to be used. + remove_methods: bool + Whether to remove the methods that have converged. + parallel_run: bool + If True, the optimization will be run in parallel. + comm: ASE communicator instance + The communicator object for parallelization. + verbose: bool + Whether to print the full output (True) or + not (False). + """ + # Set the parameters + self.update_arguments( + methods=methods, + remove_methods=remove_methods, + parallel_run=parallel_run, + comm=comm, + verbose=verbose, + **kwargs, + ) + + def update_optimizable(self, structures, **kwargs): + # Update optimizable for the first method + self.methods[0].update_optimizable(structures, **kwargs) + self.optimizable = self.methods[0].get_optimizable() + # Reset the optimization and update the optimizable + self.setup_optimizable() + return self + + def get_optimizable(self): + return self.optimizable + + def get_structures(self): + if isinstance(self.structures, list): + return [self.copy_atoms(struc) for struc in self.structures] + return self.copy_atoms(self.structures) + + def get_candidates(self): + return self.candidates + + def run( + self, + fmax=0.05, + steps=1000000, + max_unc=None, + dtrust=None, + unc_convergence=None, + **kwargs, + ): + # Get number of methods + n_methods = len(self.methods) + # Run the optimizations + for i, method in enumerate(self.methods): + # Update the structures if not the first method + if i > 0: + method.update_optimizable(self.structures) + # Run the optimization + method.run( + fmax=fmax, + steps=steps, + max_unc=max_unc, + dtrust=dtrust, + **kwargs, + ) + # Get the structures + self.optimizable = method.get_optimizable() + self.structures = method.get_structures() + self.candidates = method.get_candidates() + # Update the number of steps + self.steps += method.get_number_of_steps() + steps -= method.get_number_of_steps() + # Check if the optimization method is converged + converged = method.converged() + # Check if the optimization is converged + converged = self.check_convergence( + converged=method.converged(), + max_unc=max_unc, + dtrust=dtrust, + unc_convergence=unc_convergence, + ) + if not converged: + break + # Check if the complete optimization is converged + if i + 1 == n_methods: + self._converged = True + break + # Check if the method should be removed + if self.remove_methods and i + 1 < n_methods: + self.methods = self.methods[1:] + return self._converged + + def set_calculator(self, calculator, copy_calc=False, **kwargs): + for method in self.methods: + method.set_calculator(calculator, copy_calc=copy_calc, **kwargs) + return self + + def setup_optimizable(self, **kwargs): + self.optimizable = self.methods[0].get_optimizable() + self.structures = self.methods[0].get_structures() + self.candidates = self.methods[0].get_candidates() + self.reset_optimization() + return self + + def is_energy_minimized(self): + return self.methods[-1].is_energy_minimized() + + def is_parallel_allowed(self): + return False + + def update_arguments( + self, + methods=None, + remove_methods=None, + parallel_run=None, + comm=None, + verbose=None, + **kwargs, + ): + """ + Update the instance with its arguments. + The existing arguments are used if they are not given. + + Parameters: + methods: List of OptimizerMethod objects + The list of optimization methods to be used. + remove_methods: bool + Whether to remove the methods that have converged. + parallel_run: bool + If True, the optimization will be run in parallel. + comm: ASE communicator instance + The communicator object for parallelization. + verbose: bool + Whether to print the full output (True) or + not (False). + """ + # Set the communicator + if comm is not None: + self.comm = comm + self.rank = comm.rank + self.size = comm.size + # Set the verbose + if verbose is not None: + self.verbose = verbose + if remove_methods is not None: + self.remove_methods = remove_methods + if methods is not None: + self.methods = methods + self.setup_optimizable() + if parallel_run is not None: + self.parallel_run = parallel_run + self.check_parallel() + return self + + def get_arguments(self): + "Get the arguments of the class itself." + # Get the arguments given to the class in the initialization + arg_kwargs = dict( + methods=self.methods, + remove_methods=self.remove_methods, + parallel_run=self.parallel_run, + comm=self.comm, + verbose=self.verbose, + ) + # Get the constants made within the class + constant_kwargs = dict(steps=self.steps, _converged=self._converged) + # Get the objects made within the class + object_kwargs = dict() + return arg_kwargs, constant_kwargs, object_kwargs From 9af796f177a58ea483a8c93c055ae8ae4b069ddf Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Sun, 27 Oct 2024 19:30:38 +0100 Subject: [PATCH 008/194] Relocate the acquisition function classes --- catlearn/{optimize => activelearning}/acquisition.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename catlearn/{optimize => activelearning}/acquisition.py (100%) diff --git a/catlearn/optimize/acquisition.py b/catlearn/activelearning/acquisition.py similarity index 100% rename from catlearn/optimize/acquisition.py rename to catlearn/activelearning/acquisition.py From 287198be3cdefcf6d801965a788766a8d6faccc6 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Sun, 27 Oct 2024 19:30:59 +0100 Subject: [PATCH 009/194] Make a general active learning class and construct previous used classes from that --- catlearn/activelearning/__init__.py | 37 + catlearn/activelearning/activelearning.py | 1382 +++++++++++++++++++++ catlearn/activelearning/adsorption.py | 349 ++++++ catlearn/activelearning/local.py | 261 ++++ catlearn/activelearning/mlgo.py | 378 ++++++ catlearn/activelearning/mlneb.py | 368 ++++++ 6 files changed, 2775 insertions(+) create mode 100644 catlearn/activelearning/__init__.py create mode 100644 catlearn/activelearning/activelearning.py create mode 100644 catlearn/activelearning/adsorption.py create mode 100644 catlearn/activelearning/local.py create mode 100644 catlearn/activelearning/mlgo.py create mode 100644 catlearn/activelearning/mlneb.py diff --git a/catlearn/activelearning/__init__.py b/catlearn/activelearning/__init__.py new file mode 100644 index 00000000..70312252 --- /dev/null +++ b/catlearn/activelearning/__init__.py @@ -0,0 +1,37 @@ +from .acquisition import ( + Acquisition, + AcqEnergy, + AcqUncertainty, + AcqUCB, + AcqLCB, + AcqIter, + AcqUME, + AcqUUCB, + AcqULCB, + AcqEI, + AcqPI, +) +from .activelearning import ActiveLearning +from .local import LocalAL +from .mlneb import MLNEB +from .adsorption import AdsorptionAL +from .mlgo import MLGO + +__all__ = [ + "Acquisition", + "AcqEnergy", + "AcqUncertainty", + "AcqUCB", + "AcqLCB", + "AcqIter", + "AcqUME", + "AcqUUCB", + "AcqULCB", + "AcqEI", + "AcqPI", + "ActiveLearning", + "LocalAL", + "MLNEB", + "AdsorptionAL", + "MLGO", +] diff --git a/catlearn/activelearning/activelearning.py b/catlearn/activelearning/activelearning.py new file mode 100644 index 00000000..4e3feee3 --- /dev/null +++ b/catlearn/activelearning/activelearning.py @@ -0,0 +1,1382 @@ +import numpy as np +from ase.io import read +from ase.parallel import world, broadcast +from ase.io.trajectory import TrajectoryWriter +import datetime +from ..regression.gp.calculator.copy_atoms import copy_atoms + + +class ActiveLearning: + def __init__( + self, + method, + ase_calc, + mlcalc=None, + acq=None, + is_minimization=True, + use_database_check=True, + save_memory=False, + parallel_run=False, + copy_calc=False, + verbose=True, + apply_constraint=True, + force_consistent=False, + scale_fmax=0.8, + use_fmax_convergence=True, + unc_convergence=0.05, + use_method_unc_conv=True, + use_restart=True, + check_unc=True, + check_energy=True, + check_fmax=True, + n_evaluations_each=1, + trajectory="predicted.traj", + trainingset="evaluated.traj", + converged_trajectory="converged.traj", + tabletxt="ml_summary.txt", + prev_calculations=None, + restart=False, + comm=world, + **kwargs, + ): + """ + A Bayesian optimizer that is used for accelerating quantum mechanincal + simulation methods with an active learning approach. + + Parameters: + method: OptimizationMethod instance + The quantum mechanincal simulation method instance. + ase_calc: ASE calculator instance + ASE calculator as implemented in ASE. + mlcalc: ML-calculator instance + The ML-calculator instance used as surrogate surface. + The default BOCalculator instance is used if mlcalc is None. + acq: Acquisition class instance + The Acquisition instance used for calculating the + acq. function and choose a candidate to calculate next. + The default AcqUME instance is used if acq is None. + is_minimization: bool + Whether it is a minimization that is performed. + Alternative is a maximization. + use_database_check: bool + Whether to check if the new structure is within the database. + If it is in the database, the structure is rattled. + save_memory: bool + Whether to only train the ML calculator and store all objects + on one CPU. + If save_memory==True then parallel optimization of + the hyperparameters can not be achived. + If save_memory==False no MPI object is used. + parallel_run: bool + Whether to run method in parallel on multiple CPUs (True) or + in sequence on 1 CPU (False). + copy_calc: bool + Whether to copy the calculator for each candidate + in the method. + verbose: bool + Whether to print on screen the full output (True) or + not (False). + apply_constraint: boolean + Whether to apply the constrains of the ASE Atoms instance + to the calculated forces. + By default (apply_constraint=True) forces are 0 for + constrained atoms and directions. + force_consistent: boolean or None. + Use force-consistent energy calls (as opposed to the energy + extrapolated to 0 K). + By default force_consistent=False. + scale_fmax: float + The scaling of the fmax for the ML-NEB runs. + It makes the path converge tighter on surrogate surface. + use_fmax_convergence: bool + Whether to use the maximum force as an convergence criterion. + unc_convergence: float + Maximum uncertainty for convergence in + the Bayesian optimization (in eV). + use_method_unc_conv: bool + Whether to use the unc_convergence as a convergence criterion + in the optimization method. + use_restart: bool + Use the result from last robust iteration. + check_unc: bool + Check if the uncertainty is large for the restarted result and + if it is then use the previous initial. + check_energy: bool + Check if the energy is larger for the restarted result than + the previous. + check_fmax: bool + Check if the maximum force is larger for the restarted result + than the initial interpolation and if so then replace it. + n_evaluations_each: int + Number of evaluations for each iteration. + trajectory: str or TrajectoryWriter instance + Trajectory filename to store the predicted data. + Or the TrajectoryWriter instance to store the predicted data. + trainingset: str or TrajectoryWriter instance + Trajectory filename to store the evaluated training data. + Or the TrajectoryWriter instance to store the evaluated + training data. + converged_trajectory: str or TrajectoryWriter instance + Trajectory filename to store the converged structures. + Or the TrajectoryWriter instance to store the converged + structures. + tabletxt: str + Name of the .txt file where the summary table is printed. + It is not saved to the file if tabletxt=None. + prev_calculations: Atoms list or ASE Trajectory file. + The user can feed previously calculated data + for the same hypersurface. + The previous calculations must be fed as an Atoms list + or Trajectory filename. + restart: bool + Whether to restart the Bayesian optimization. + comm: MPI communicator. + The MPI communicator. + """ + # Setup the ASE calculator + self.ase_calc = ase_calc + # Set the initial parameters + self.reset() + # Setup the method + self.setup_method(method) + # Setup the ML calculator + self.setup_mlcalc( + mlcalc, + save_memory=save_memory, + ) + # Setup the acquisition function + self.setup_acq( + acq, + is_minimization=is_minimization, + unc_convergence=unc_convergence, + ) + # Set the arguments + self.update_arguments( + is_minimization=is_minimization, + use_database_check=use_database_check, + save_memory=save_memory, + parallel_run=parallel_run, + copy_calc=copy_calc, + verbose=verbose, + apply_constraint=apply_constraint, + force_consistent=force_consistent, + scale_fmax=scale_fmax, + use_fmax_convergence=use_fmax_convergence, + unc_convergence=unc_convergence, + use_method_unc_conv=use_method_unc_conv, + use_restart=use_restart, + check_unc=check_unc, + check_energy=check_energy, + check_fmax=check_fmax, + n_evaluations_each=n_evaluations_each, + trajectory=trajectory, + trainingset=trainingset, + converged_trajectory=converged_trajectory, + tabletxt=tabletxt, + comm=comm, + **kwargs, + ) + # Use previous calculations to train ML calculator + self.use_prev_calculations(prev_calculations) + # Restart the bayesian optimization + self.restart_optimization(restart, prev_calculations) + + def run( + self, + fmax=0.05, + steps=200, + ml_steps=200, + max_unc=None, + dtrust=None, + seed=None, + **kwargs, + ): + """ + Run the active learning optimization. + + Parameters: + fmax: float + Convergence criteria (in eV/Angs). + steps: int + Maximum number of evaluations. + ml_steps: int + Maximum number of steps for the optimization method + on the predicted landscape. + max_unc: float (optional) + Maximum uncertainty for continuation of the optimization. + dtrust: float (optional) + The trust distance for the optimization method. + seed: int (optional) + The random seed. + + Returns: + converged: bool + Whether the Bayesian optimization is converged. + """ + # Set the random seed + if seed is not None: + np.random.RandomState(seed) + # Check if the method is converged + if self.converged(): + self.message_system("Bayesian optimization is converged.") + return self.best_structures + # Check if there are any training data + self.extra_initial_data() + # Run the active learning + for step in range(1, steps + 1): + # Check if the method is converged + if self.converged(): + self.message_system("Bayesian optimization is converged.") + self.save_trajectory( + self.converged_trajectory, + self.best_structures, + mode="w", + ) + break + # Train and optimize ML model + self.train_mlmodel() + # Run the method + candidates, method_converged = self.find_next_candidates( + fmax=self.scale_fmax * fmax, + step=step, + ml_steps=ml_steps, + max_unc=max_unc, + dtrust=dtrust, + ) + # Evaluate candidate + self.evaluate_candidates(candidates) + # Print the results for this iteration + self.print_statement() + # Check for convergence + self._converged = self.check_convergence( + fmax, + method_converged, + ) + # State if the Bayesian optimization did not converge + if not self.converged(): + self.message_system("Bayesian optimization did not converge!") + # Return and broadcast the best atoms + self.broadcast_best_structures() + return self.converged() + + def converged(self): + "Whether the Bayesian optimization is converged." + return self._converged + + def get_number_of_steps(self): + """ + Get the number of steps that have been run. + """ + return self.steps + + def reset(self, **kwargs): + """ + Reset the initial parameters for the Bayesian optimizer. + """ + # Set initial parameters + self.steps = 0 + self._converged = False + self.unc = np.nan + self.energy_pred = np.nan + # Set the header for the summary table + self.make_hdr_table() + # Set the writing mode + self.mode = "w" + return self + + def setup_method(self, method, **kwargs): + """ + Setup the optimization method. + + Parameters: + method: OptimizationMethod instance. + The quantum mechanincal simulation method instance. + + Returns: + self: The object itself. + """ + # Save the method + self.method = method + self.structures = self.get_structures() + if isinstance(self.structures, list): + self.n_structures = len(self.structures) + else: + self.n_structures = 1 + self.best_structures = self.get_structures() + self._converged = self.method.converged() + # Set the evaluated candidate and its calculator + self.candidate = self.get_candidates()[0].copy() + self.candidate.calc = self.ase_calc + # Store the best candidate data + self.bests_data = { + "atoms": self.candidate.copy(), + "energy": None, + "fmax": None, + "uncertainty": None, + } + return self + + def setup_mlcalc( + self, + mlcalc=None, + save_memory=False, + fp=None, + atoms=None, + prior=None, + baseline=None, + database_reduction=False, + calc_forces=True, + bayesian=True, + kappa=2.0, + **kwargs, + ): + """ + Setup the ML calculator. + + Parameters: + mlcalc: ML-calculator instance (optional) + The ML-calculator instance used as surrogate surface. + A default ML-model is used if mlcalc is None. + save_memory: bool + Whether to only train the ML calculator and store + all objects on one CPU. + If save_memory==True then parallel optimization of + the hyperparameters can not be achived. + If save_memory==False no MPI object is used. + fp: Fingerprint class instance (optional) + The fingerprint instance used for the ML model. + The default InvDistances instance is used if fp is None. + atoms: Atoms object (optional if fp is not None) + The Atoms object from the optimization method. + It is used to setup the fingerprint if it is None. + prior: Prior class instance (optional) + The prior mean instance used for the ML model. + The default Prior_max instance is used if prior is None. + baseline: Baseline class instance (optional) + The baseline instance used for the ML model. + The default is None. + database_reduction: bool + Whether to reduce the database. + calc_forces: bool + Whether to calculate the forces for all energy predictions. + bayesian: bool + Whether to use the Bayesian optimization calculator. + kappa: float + The scaling of the uncertainty relative to the energy. + Default is 2.0. + + Returns: + self: The object itself. + """ + if mlcalc is not None: + self.mlcalc = mlcalc + return self + from ..regression.gp.calculator.mlmodel import get_default_mlmodel + from ..regression.gp.calculator.bocalc import BOCalculator + from ..regression.gp.calculator.mlcalc import MLCalculator + from ..regression.gp.means.max import Prior_max + from ..regression.gp.fingerprint.invdistances import InvDistances + + # Check if the save_memory is given + if save_memory is None: + try: + save_memory = self.save_memory + except Exception: + raise Exception("The save_memory is not given.") + # Setup the fingerprint + if fp is None: + # Check if the Atoms object is given + if atoms is None: + try: + atoms = self.get_structures(get_all=False) + except Exception: + raise Exception("The Atoms object is not given or stored.") + # Can only use distances if there are more than one atom + if len(atoms) > 1: + if atoms.pbc.any(): + periodic_softmax = True + else: + periodic_softmax = False + fp = InvDistances( + reduce_dimensions=True, + use_derivatives=True, + periodic_softmax=periodic_softmax, + wrap=False, + ) + # Setup the prior mean + if prior is None: + prior = Prior_max(add=1.0) + # Setup the ML model + mlmodel = get_default_mlmodel( + model="tp", + prior=prior, + fp=fp, + baseline=baseline, + use_derivatives=True, + parallel=(not save_memory), + database_reduction=database_reduction, + ) + # Setup the ML calculator + if bayesian: + self.mlcalc = BOCalculator( + mlmodel=mlmodel, + calc_forces=calc_forces, + kappa=kappa, + ) + else: + self.mlcalc = MLCalculator( + mlmodel=mlmodel, + calc_forces=calc_forces, + ) + return self + + def setup_acq( + self, + acq=None, + is_minimization=True, + kappa=2.0, + unc_convergence=0.05, + **kwargs, + ): + """ + Setup the acquisition function. + + Parameters: + acq : Acquisition class instance. + The Acquisition instance used for calculating the acq. function + and choose a candidate to calculate next. + The default AcqUME instance is used if acq is None. + is_minimization : bool + Whether it is a minimization that is performed. + kappa : float + The kappa parameter in the acquisition function. + unc_convergence : float + Maximum uncertainty for convergence (in eV). + """ + # Select an acquisition function + if acq is None: + # Setup the acquisition function + if is_minimization: + from .acquisition import AcqULCB + + self.acq = AcqULCB( + objective="min", + kappa=kappa, + unc_convergence=unc_convergence, + ) + else: + from .acquisition import AcqUUCB + + self.acq = AcqUUCB( + objective="max", + kappa=kappa, + unc_convergence=unc_convergence, + ) + else: + self.acq = acq.copy() + # Check if the objective is the same + objective = self.get_objective_str() + if acq.objective != objective: + raise Exception( + "The objective of the acquisition function " + "does not match the Bayesian optimizer." + ) + return self + + def get_structures( + self, + get_all=True, + ): + """ + Get the list of ASE Atoms object from the method. + + Parameters: + get_all : bool + Whether to get all structures or just the first one. + + Returns: + Atoms object or list of Atoms objects. + """ + structures = self.method.get_structures() + if isinstance(structures, list): + if get_all: + return structures + return structures[0] + return structures + + def get_candidates(self): + """ + Get the list of candidates from the method. + The candidates are used for the evaluation. + + Returns: + List of Atoms objects. + """ + return self.method.get_candidates() + + def use_prev_calculations(self, prev_calculations=None, **kwargs): + """ + Use previous calculations to restart ML calculator. + + Parameters: + prev_calculations: Atoms list or ASE Trajectory file. + The user can feed previously calculated data + for the same hypersurface. + The previous calculations must be fed as an Atoms list + or Trajectory filename. + """ + if prev_calculations is None: + return self + if isinstance(prev_calculations, str): + prev_calculations = read(prev_calculations, ":") + # Add calculations to the ML model + self.add_training(prev_calculations) + return self + + def update_method(self, structures, **kwargs): + """ + Update the method with structures. + + Parameters: + structures: Atoms instance or list of Atoms instances + The structures that the optimizable instance is dependent on. + + Returns: + self: The object itself. + """ + # Initiate the method with given structure(s) + self.method.update_optimizable(structures) + # Set the ML calculator in the method + self.set_mlcalc() + return self + + def set_mlcalc(self, copy_calc=None, **kwargs): + """ + Set the ML calculator in the method. + """ + # Set copy_calc if it is not given + if copy_calc is None: + copy_calc = self.copy_calc + # Set the ML calculator in the method + self.method.set_calculator(self.mlcalc, copy_calc=copy_calc) + return self + + def get_data_atoms(self, **kwargs): + """ + Get the list of atoms in the database. + + Returns: + list: A list of the saved ASE Atoms objects. + """ + return self.mlcalc.get_data_atoms() + + def update_arguments( + self, + method=None, + ase_calc=None, + mlcalc=None, + acq=None, + is_minimization=None, + use_database_check=None, + save_memory=None, + parallel_run=None, + copy_calc=None, + verbose=None, + apply_constraint=None, + force_consistent=None, + scale_fmax=None, + use_fmax_convergence=None, + unc_convergence=None, + use_method_unc_conv=None, + use_restart=None, + check_unc=None, + check_energy=None, + check_fmax=None, + n_evaluations_each=None, + trajectory=None, + trainingset=None, + converged_trajectory=None, + tabletxt=None, + comm=None, + **kwargs, + ): + """ + Update the instance with its arguments. + The existing arguments are used if they are not given. + + Parameters: + method: OptimizationMethod instance. + The quantum mechanincal simulation method instance. + ase_calc: ASE calculator instance. + ASE calculator as implemented in ASE. + mlcalc: ML-calculator instance. + The ML-calculator instance used as surrogate surface. + The default BOCalculator instance is used if mlcalc is None. + acq: Acquisition class instance. + The Acquisition instance used for calculating the + acq. function and choose a candidate to calculate next. + The default AcqUME instance is used if acq is None. + is_minimization: bool + Whether it is a minimization that is performed. + Alternative is a maximization. + use_database_check: bool + Whether to check if the new structure is within the database. + If it is in the database, the structure is rattled. + save_memory: bool + Whether to only train the ML calculator and store all objects + on one CPU. + If save_memory==True then parallel optimization of + the hyperparameters can not be achived. + If save_memory==False no MPI object is used. + parallel_run: bool + Whether to run method in parallel on multiple CPUs (True) or + in sequence on 1 CPU (False). + copy_calc: bool + Whether to copy the calculator for each candidate + in the method. + verbose: bool + Whether to print on screen the full output (True) or + not (False). + apply_constraint: boolean + Whether to apply the constrains of the ASE Atoms instance + to the calculated forces. + By default (apply_constraint=True) forces are 0 for + constrained atoms and directions. + force_consistent: boolean or None. + Use force-consistent energy calls (as opposed to the energy + extrapolated to 0 K). + By default force_consistent=False. + scale_fmax: float + The scaling of the fmax for the ML-NEB runs. + It makes the path converge tighter on surrogate surface. + use_fmax_convergence: bool + Whether to use the maximum force as an convergence criterion. + unc_convergence: float + Maximum uncertainty for convergence in + the Bayesian optimization (in eV). + use_method_unc_conv: bool + Whether to use the unc_convergence as a convergence criterion + in the optimization method. + use_restart: bool + Use the result from last robust iteration. + check_unc: bool + Check if the uncertainty is large for the restarted result and + if it is then use the previous initial. + check_energy: bool + Check if the energy is larger for the restarted result than + the previous. + check_fmax: bool + Check if the maximum force is larger for the restarted result + than the initial interpolation and if so then replace it. + n_evaluations_each: int + Number of evaluations for each iteration. + trajectory: str or TrajectoryWriter instance + Trajectory filename to store the predicted data. + Or the TrajectoryWriter instance to store the predicted data. + trainingset: str or TrajectoryWriter instance + Trajectory filename to store the evaluated training data. + Or the TrajectoryWriter instance to store the evaluated + training data. + converged_trajectory: str or TrajectoryWriter instance + Trajectory filename to store the converged structures. + Or the TrajectoryWriter instance to store the converged + structures. + tabletxt: str + Name of the .txt file where the summary table is printed. + It is not saved to the file if tabletxt=None. + prev_calculations: Atoms list or ASE Trajectory file. + The user can feed previously calculated data + for the same hypersurface. + The previous calculations must be fed as an Atoms list + or Trajectory filename. + restart: bool + Whether to restart the Bayesian optimization. + comm: MPI communicator. + The MPI communicator. + + Returns: + self: The updated object itself. + """ + # Fixed parameters + if is_minimization is not None: + self.is_minimization = is_minimization + if use_database_check is not None: + self.use_database_check = use_database_check + if save_memory is not None: + self.save_memory = save_memory + if comm is not None: + # Setup parallelization + self.parallel_setup(comm) + if parallel_run is not None: + self.parallel_run = parallel_run + if copy_calc is not None: + self.copy_calc = copy_calc + if verbose is not None: + # Whether to have the full output + self.verbose = verbose + self.set_verbose(verbose=verbose) + if apply_constraint is not None: + self.apply_constraint = apply_constraint + if force_consistent is not None: + self.force_consistent = force_consistent + if scale_fmax is not None: + self.scale_fmax = abs(float(scale_fmax)) + if use_fmax_convergence is not None: + self.use_fmax_convergence = use_fmax_convergence + if unc_convergence is not None: + self.unc_convergence = abs(float(unc_convergence)) + if use_method_unc_conv is not None: + self.use_method_unc_conv = use_method_unc_conv + if use_restart is not None: + self.use_restart = use_restart + if check_unc is not None: + self.check_unc = check_unc + if check_energy is not None: + self.check_energy = check_energy + if check_fmax is not None: + self.check_fmax = check_fmax + if n_evaluations_each is not None: + self.n_evaluations_each = int(abs(n_evaluations_each)) + if self.n_evaluations_each < 1: + self.n_evaluations_each = 1 + if trajectory is not None: + self.trajectory = trajectory + if trainingset is not None: + self.trainingset = trainingset + if converged_trajectory is not None: + self.converged_trajectory = converged_trajectory + if tabletxt is not None: + self.tabletxt = str(tabletxt) + # Set ASE calculator + if ase_calc is not None: + self.ase_calc = ase_calc + if method is None: + self.setup_method(self.method) + # Update the optimization method + if method is not None: + self.setup_method(method) + # Set the machine learning calculator + if mlcalc is not None: + self.setup_mlcalc(mlcalc) + # Set the acquisition function + if acq is not None: + self.setup_acq( + acq, + is_minimization=self.is_minimization, + unc_convergence=self.unc_convergence, + ) + # Check if the method and BO is compatible + self.check_attributes() + return self + + def find_next_candidates( + self, + fmax=0.05, + step=1, + ml_steps=200, + max_unc=None, + dtrust=None, + **kwargs, + ): + "Run the method on the ML surrogate surface." + # Convergence of the NEB + method_converged = False + # If memeory is saved the method is only performed on one CPU + if not self.parallel_run and self.rank != 0: + return None, method_converged + # Check if the previous structure were better + self.initiate_structure(step=step) + # Run the method + method_converged = self.run_method( + fmax=fmax, + ml_steps=ml_steps, + max_unc=max_unc, + dtrust=dtrust, + ) + # Get the candidates + candidates = self.choose_candidates() + return candidates, method_converged + + def run_method( + self, + fmax=0.05, + ml_steps=750, + max_unc=None, + dtrust=None, + **kwargs, + ): + "Run the method on the surrogate surface." + # Set the uncertainty convergence for the method + if self.use_method_unc_conv: + unc_convergence = self.unc_convergence + else: + unc_convergence = None + # Run the method + self.method.run( + fmax=fmax, + steps=ml_steps, + max_unc=max_unc, + dtrust=dtrust, + unc_convergence=unc_convergence, + **kwargs, + ) + # Check if the method converged + method_converged = self.method.converged() + # Get the atoms from the method run + self.structures = self.get_structures() + # Write atoms to trajectory + self.save_trajectory(self.trajectory, self.structures, mode=self.mode) + # Set the mode to append + self.mode = "a" + return method_converged + + def initiate_structure(self, step=1, **kwargs): + "Initiate the method with right structure." + # Do not use the temporary structure + if not self.use_restart or step == 1: + self.message_system("The initial structure is used.") + self.update_method(self.best_structures) + return + # Reuse the temporary structure if it passes tests + self.update_method(self.structures) + # Get uncertainty and fmax + uncmax_tmp, energy_tmp, fmax_tmp = self.get_predictions() + use_tmp = True + # Check uncertainty is low enough + if self.check_unc: + if uncmax_tmp > self.unc_convergence: + self.message_system( + "The uncertainty is too large to use the last structure." + ) + use_tmp = False + # Check fmax is lower than previous structure + if use_tmp and (self.check_fmax or self.check_energy): + self.update_method(self.best_structures) + energy_best, fmax_best = self.get_predictions()[1:] + if self.check_fmax: + if fmax_tmp > fmax_best: + self.message_system( + "The fmax is too large to use the last structure." + ) + use_tmp = False + if use_tmp and self.check_energy: + if energy_tmp > energy_best: + self.message_system( + "The energy is too large to use the last structure." + ) + use_tmp = False + # Check if the temporary structure passed the tests + if use_tmp: + self.copy_best_structures() + self.message_system("The last structure is used.") + self.update_method(self.best_structures) + return + + def get_predictions(self, **kwargs): + "Get the maximum uncertainty, energy, and fmax prediction." + uncmax = None + energy = None + fmax = None + if self.check_unc: + uncmax = self.method.get_uncertainty() + if self.check_energy: + energy = self.method.get_potential_energy() + if self.check_fmax: + fmax = np.max(self.method.get_fmax()) + return uncmax, energy, fmax + + def get_candidate_predictions(self, **kwargs): + """ + Get the energies, uncertainties, and fmaxs with the ML calculator + for the candidates. + """ + properties = ["fmax", "uncertainty", "energy"] + results = self.method.get_properties( + properties=properties, + allow_calculation=True, + per_candidate=True, + **kwargs, + ) + energies = np.array(results["energy"]).reshape(-1) + uncertainties = np.array(results["uncertainty"]).reshape(-1) + fmaxs = np.array(results["fmax"]).reshape(-1) + return energies, uncertainties, fmaxs + + def parallel_setup(self, comm, **kwargs): + "Setup the parallelization." + self.comm = comm + self.rank = comm.rank + self.size = comm.size + return self + + def add_training(self, atoms_list, **kwargs): + "Add atoms_list data to ML model on rank=0." + self.mlcalc.add_training(atoms_list) + return self.mlcalc + + def train_mlmodel(self, point_interest=None, **kwargs): + "Train the ML model" + if self.save_memory: + if self.rank != 0: + return self.mlcalc + # Update database with the points of interest + if point_interest is not None: + self.update_database_arguments(point_interest=point_interest) + else: + self.update_database_arguments(point_interest=self.best_structures) + # Train the ML model + self.mlcalc.train_model() + return self.mlcalc + + def save_data(self, **kwargs): + "Save the training data to a file." + self.mlcalc.save_data(trajectory=self.trainingset) + return self + + def save_trajectory(self, trajectory, structures, mode="w", **kwargs): + "Save the trajectory of the data." + if trajectory is None: + return self + if isinstance(trajectory, str): + with TrajectoryWriter(trajectory, mode=mode) as traj: + if isinstance(structures, list): + for struc in structures: + traj.write(struc) + else: + traj.write(structures) + elif isinstance(trajectory, TrajectoryWriter): + if isinstance(structures, list): + for struc in structures: + trajectory.write(struc) + else: + trajectory.write(structures) + else: + self.message_system( + "The trajectory type is not supported. " + "The trajectory is not saved!" + ) + return self + + def evaluate_candidates(self, candidates, **kwargs): + "Evaluate the candidates." + # Check if the candidates are a list + if not isinstance(candidates, (list, np.ndarray)): + candidates = [candidates] + # Evaluate the candidates + for candidate in candidates: + # Get energy and uncertainty and remove it from the list + self.energy_pred = self.pred_energies[0] + self.pred_energies = self.pred_energies[1:] + self.unc = self.uncertainties[0] + self.uncertainties = self.uncertainties[1:] + # Evaluate the candidate + self.evaluate(candidate) + return self + + def evaluate(self, candidate, **kwargs): + "Evaluate the ASE atoms with the ASE calculator." + # Ensure that the candidate is not already in the database + if self.use_database_check: + candidate = self.ensure_not_in_database(candidate) + # Update the evaluated candidate + self.update_candidate(candidate) + # Calculate the energies and forces + self.message_system("Performing evaluation.", end="\r") + forces = self.candidate.get_forces( + apply_constraint=self.apply_constraint + ) + self.energy_true = self.candidate.get_potential_energy( + force_consistent=self.force_consistent + ) + self.e_dev = abs(self.energy_true - self.energy_pred) + self.steps += 1 + self.message_system("Single-point calculation finished.") + # Store the data + self.true_fmax = np.nanmax(np.linalg.norm(forces, axis=1)) + self.add_training([self.candidate]) + self.save_data() + # Make a reference energy + if self.steps == 1: + atoms_ref = self.get_data_atoms()[0] + self.e_ref = atoms_ref.get_potential_energy() + # Store the best evaluated candidate + self.store_best_data(self.candidate) + # Make the summary table + self.make_summary_table() + return + + def update_candidate(self, candidate, dtol=1e-8, **kwargs): + "Update the evaluated candidate with given candidate." + # Broadcast the system to all cpus + if self.rank == 0: + candidate = candidate.copy() + candidate = broadcast(candidate, root=0, comm=self.comm) + # Update the evaluated candidate with given candidate + # Set positions + self.candidate.set_positions(candidate.get_positions()) + # Set cell + cell_old = self.candidate.get_cell() + cell_new = candidate.get_cell() + if np.linalg.norm(cell_old - cell_new) > dtol: + self.candidate.set_cell(cell_new) + # Set pbc + pbc_old = self.candidate.get_pbc() + pbc_new = candidate.get_pbc() + if (pbc_old == pbc_new).all(): + self.candidate.set_pbc(pbc_new) + # Set initial charges + ini_charge_old = self.candidate.get_initial_charges() + ini_charge_new = candidate.get_initial_charges() + if np.linalg.norm(ini_charge_old - ini_charge_new) > dtol: + self.candidate.set_initial_charges(ini_charge_new) + # Set initial magmoms + ini_magmom_old = self.candidate.get_initial_magnetic_moments() + ini_magmom_new = candidate.get_initial_magnetic_moments() + if np.linalg.norm(ini_magmom_old - ini_magmom_new) > dtol: + self.candidate.set_initial_magnetic_moments(ini_magmom_new) + # Set momenta + momenta_old = self.candidate.get_momenta() + momenta_new = candidate.get_momenta() + if np.linalg.norm(momenta_old - momenta_new) > dtol: + self.candidate.set_momenta(momenta_new) + # Set velocities + velocities_old = self.candidate.get_velocities() + velocities_new = candidate.get_velocities() + if np.linalg.norm(velocities_old - velocities_new) > dtol: + self.candidate.set_velocities(velocities_new) + return candidate + + def extra_initial_data(self, **kwargs): + """ + Get an initial structure for the Bayesian optimization + if the ML calculator does not have any training points. + """ + # Check if the training set is empty + if self.get_training_set_size(): + return self + # Calculate the initial structure + self.evaluate(self.get_structures(get_all=False)) + # Print summary table + self.print_statement() + return self + + def update_database_arguments(self, point_interest=None, **kwargs): + "Update the arguments in the database." + self.mlcalc.update_database_arguments( + point_interest=point_interest, + **kwargs, + ) + return self + + def ensure_not_in_database(self, atoms, perturb=0.01, **kwargs): + "Ensure the ASE Atoms object is not in database by perturb it." + # Return atoms if it does not exist + if atoms is None: + return atoms + # Check if atoms object is in the database + if self.is_in_database(atoms, **kwargs): + # Get positions + pos = atoms.get_positions() + # Rattle the positions + pos += np.random.uniform( + low=-perturb, + high=perturb, + size=pos.shape, + ) + atoms.set_positions(pos) + self.message_system( + "The system is rattled, since it is already in the database." + ) + return atoms + + def store_best_data(self, atoms, **kwargs): + "Store the best candidate." + update = True + # Check if the energy is better than the previous best + if self.is_minimization: + best_energy = self.bests_data["energy"] + if best_energy is not None and self.energy_true > best_energy: + update = False + # Update the best data + if update: + self.bests_data["atoms"] = atoms.copy() + self.bests_data["energy"] = self.energy_true + self.bests_data["fmax"] = self.true_fmax + self.bests_data["uncertainty"] = self.unc + return self + + def get_training_set_size(self): + "Get the size of the training set" + return self.mlcalc.get_training_set_size() + + def choose_candidates(self, **kwargs): + "Use acquisition functions to chose the next training points" + # Get the energies and uncertainties + energies, uncertainties, fmaxs = self.get_candidate_predictions() + # Store the uncertainty predictions + self.umax = np.max(uncertainties) + self.umean = np.mean(uncertainties) + # Calculate the acquisition function for each candidate + acq_values = self.acq.calculate( + energy=energies, + uncertainty=uncertainties, + fmax=fmaxs, + ) + # Chose the candidates given by the Acq. class + i_cand = self.acq.choose(acq_values) + i_cand = i_cand[: self.n_evaluations_each] + # Reverse the order of the candidates so the best is last + if self.n_evaluations_each > 1: + i_cand = i_cand[::-1] + # The next training points + candidates = self.get_candidates() + candidates = [candidates[i].copy() for i in i_cand] + self.pred_energies = energies[i_cand] + self.uncertainties = uncertainties[i_cand] + return candidates + + def check_convergence(self, fmax, method_converged, **kwargs): + "Check if the convergence criteria are fulfilled" + converged = True + if self.rank == 0: + # Check if the method converged + if not method_converged: + converged = False + # Check the force criterion is met if it is requested + if self.use_fmax_convergence and self.true_fmax > fmax: + converged = False + # Check the uncertainty criterion is met + if self.umax > self.unc_convergence: + converged = False + # Check the true energy deviation + # match the uncertainty prediction + uci = 2.0 * self.unc_convergence + if self.e_dev > uci: + converged = False + # Check if the energy is the minimum + if self.is_minimization: + e_dif = abs(self.energy_true - self.bests_data["energy"]) + if e_dif > uci: + converged = False + # Check the convergence + if converged: + self.message_system("Optimization is converged.") + self.copy_best_structures() + # Broadcast convergence statement if MPI is used + converged = broadcast(converged, root=0, comm=self.comm) + return converged + + def copy_best_structures(self): + "Copy the best atoms." + self.best_structures = self.get_structures() + return self.best_structures + + def get_best_structures(self): + "Get the best atoms." + return self.best_structures + + def broadcast_best_structures(self): + "Broadcast the best atoms." + self.best_structures = broadcast( + self.best_structures, + root=0, + comm=self.comm, + ) + return self.best_structures + + def copy_atoms(self, atoms): + "Copy the ASE Atoms instance with calculator." + return copy_atoms(atoms) + + def get_objective_str(self, **kwargs): + "Get what the objective is for the Bayesian optimization." + if not self.is_minimization: + return "max" + return "min" + + def set_verbose(self, verbose, **kwargs): + "Set verbose of MLModel." + self.mlcalc.mlmodel.update_arguments(verbose=verbose) + return self + + def is_in_database(self, atoms, **kwargs): + "Check if the ASE Atoms is in the database." + return self.mlcalc.is_in_database(atoms, **kwargs) + + def save_mlcalc(self, filename="mlcalc.pkl", **kwargs): + """ + Save the ML calculator object to a file. + + Parameters: + filename : str + The name of the file where the object is saved. + + Returns: + self: The object itself. + """ + self.mlcalc.save_mlcalc(filename, **kwargs) + return self + + def check_attributes(self, **kwargs): + """ + Check that the Bayesian optimization and the method + agree upon the attributes. + """ + if self.parallel_run != self.method.parallel_run: + raise Exception( + "Bayesian optimizer and Optimization method does " + "not agree whether to run in parallel!" + ) + return self + + def message_system(self, message, obj=None, end="\n"): + "Print output once." + if self.verbose is True: + if self.rank == 0: + if obj is None: + print(message, end=end) + else: + print(message, obj, end=end) + return + + def make_hdr_table(self, **kwargs): + "Make the header of the summary table for the optimization process." + hdr_list = [ + " {:<6} ".format("Step"), + " {:<11s} ".format("Date"), + " {:<16s} ".format("True energy/[eV]"), + " {:<16s} ".format("Uncertainty/[eV]"), + " {:<15s} ".format("True error/[eV]"), + " {:<16s} ".format("True fmax/[eV/Å]"), + ] + # Write the header + hdr = "|" + "|".join(hdr_list) + "|" + self.print_list = [hdr] + return hdr + + def make_summary_table(self, **kwargs): + "Make the summary of the optimization process as table." + now = datetime.datetime.now().strftime("%d %H:%M:%S") + # Make the row + msg = [ + " {:<6d} ".format(self.steps), + " {:<11s} ".format(now), + " {:16.4f} ".format(self.energy_true - self.e_ref), + " {:16.4f} ".format(self.unc), + " {:15.4f} ".format(self.e_dev), + " {:16.4f} ".format(self.true_fmax), + ] + msg = "|" + "|".join(msg) + "|" + self.print_list.append(msg) + msg = "\n".join(self.print_list) + return msg + + def save_summary_table(self, msg=None, **kwargs): + "Save the summary table in the .txt file." + if self.tabletxt is not None: + with open(self.tabletxt, "w") as thefile: + if msg is None: + msg = "\n".join(self.print_list) + thefile.writelines(msg) + return + + def print_statement(self, **kwargs): + "Print the Global optimization process as a table" + msg = "" + if not self.save_memory or self.rank == 0: + msg = "\n".join(self.print_list) + self.save_summary_table(msg) + self.message_system(msg) + return msg + + def restart_optimization( + self, + restart=False, + prev_calculations=None, + **kwargs, + ): + "Restart the Bayesian optimization." + # Check if the optimization should be restarted + if not restart: + return self + # Check if the previous calculations are given + if prev_calculations is not None: + self.message_system( + "Warning: Given previous calculations does " + "not work with restart!" + ) + # Load the previous calculations from trajectory + try: + self.structures = read( + self.trajectory, + f"-{self.n_structures}:", + ) + prev_calculations = read(self.trainingset, ":") + except Exception: + self.message_system( + "Warning: Restart is not possible! " + "Reinitalizing Bayesian optimization." + ) + # Set the writing mode + self.mode = "a" + return self + + def get_arguments(self): + "Get the arguments of the class itself." + # Get the arguments given to the class in the initialization + arg_kwargs = dict( + method=self.method, + ase_calc=self.ase_calc, + mlcalc=self.mlcalc, + acq=self.acq, + is_minimization=self.is_minimization, + use_database_check=self.use_database_check, + save_memory=self.save_memory, + parallel_run=self.parallel_run, + copy_calc=self.copy_calc, + verbose=self.verbose, + apply_constraint=self.apply_constraint, + force_consistent=self.force_consistent, + scale_fmax=self.scale_fmax, + use_fmax_convergence=self.use_fmax_convergence, + unc_convergence=self.unc_convergence, + use_method_unc_conv=self.use_method_unc_conv, + use_restart=self.use_restart, + check_unc=self.check_unc, + check_energy=self.check_energy, + check_fmax=self.check_fmax, + n_evaluations_each=self.n_evaluations_each, + trajectory=self.trajectory, + trainingset=self.trainingset, + converged_trajectory=self.converged_trajectory, + tabletxt=self.tabletxt, + comm=self.comm, + ) + # Get the constants made within the class + constant_kwargs = dict() + # Get the objects made within the class + object_kwargs = dict() + return arg_kwargs, constant_kwargs, object_kwargs + + def copy(self): + "Copy the object." + # Get all arguments + arg_kwargs, constant_kwargs, object_kwargs = self.get_arguments() + # Make a clone + clone = self.__class__(**arg_kwargs) + # Check if constants have to be saved + if len(constant_kwargs.keys()): + for key, value in constant_kwargs.items(): + clone.__dict__[key] = value + # Check if objects have to be saved + if len(object_kwargs.keys()): + for key, value in object_kwargs.items(): + clone.__dict__[key] = value.copy() + return clone + + def __repr__(self): + arg_kwargs = self.get_arguments()[0] + str_kwargs = ",".join( + [f"{key}={value}" for key, value in arg_kwargs.items()] + ) + return "{}({})".format(self.__class__.__name__, str_kwargs) diff --git a/catlearn/activelearning/adsorption.py b/catlearn/activelearning/adsorption.py new file mode 100644 index 00000000..b836dc75 --- /dev/null +++ b/catlearn/activelearning/adsorption.py @@ -0,0 +1,349 @@ +from ase.parallel import world +from .activelearning import ActiveLearning +from ..optimizer import AdsorptionOptimizer +from ..optimizer import ParallelOptimizer +from ..regression.gp.baseline.repulsive import RepulsionCalculator + + +class AdsorptionAL(ActiveLearning): + def __init__( + self, + slab, + adsorbate, + ase_calc, + mlcalc=None, + adsorbate2=None, + bounds=None, + opt_kwargs={}, + chains=None, + acq=None, + use_database_check=True, + save_memory=False, + parallel_run=False, + copy_calc=False, + verbose=True, + apply_constraint=True, + force_consistent=False, + scale_fmax=0.8, + use_fmax_convergence=True, + unc_convergence=0.05, + use_method_unc_conv=True, + check_unc=True, + check_energy=True, + check_fmax=True, + n_evaluations_each=1, + trajectory="predicted.traj", + trainingset="evaluated.traj", + converged_trajectory="converged.traj", + tabletxt="ml_summary.txt", + prev_calculations=None, + restart=False, + comm=world, + **kwargs, + ): + """ + A Bayesian optimizer that is used for accelerating local optimization + of an atomic structure with an active learning approach. + + Parameters: + slab: Atoms instance + The slab structure. + Can either be a surface or a nanoparticle. + adsorbate: Atoms instance + The adsorbate structure. + ase_calc: ASE calculator instance. + ASE calculator as implemented in ASE. + mlcalc: ML-calculator instance. + The ML-calculator instance used as surrogate surface. + The default BOCalculator instance is used if mlcalc is None. + adsorbate2: Atoms instance (optional) + The second adsorbate structure. + Optimize both adsorbates simultaneously. + The two adsorbates will have different tags. + bounds: (6,2) array or (12,2) array (optional) + The bounds for the optimization. + The first 3 rows are the x, y, z scaled coordinates for + the center of the adsorbate. + The next 3 rows are the three rotation angles in radians. + If two adsorbates are optimized, the next 6 rows are for + the second adsorbate. + opt_kwargs: dict + The keyword arguments for the simulated annealing optimizer. + chains: int (optional) + The number of optimization that will be run in parallel. + It is only used if parallel_run=True. + acq: Acquisition class instance. + The Acquisition instance used for calculating the + acq. function and choose a candidate to calculate next. + The default AcqUME instance is used if acq is None. + use_database_check: bool + Whether to check if the new structure is within the database. + If it is in the database, the structure is rattled. + save_memory: bool + Whether to only train the ML calculator and store all objects + on one CPU. + If save_memory==True then parallel optimization of + the hyperparameters can not be achived. + If save_memory==False no MPI object is used. + parallel_run: bool + Whether to run method in parallel on multiple CPUs (True) or + in sequence on 1 CPU (False). + copy_calc: bool + Whether to copy the calculator for each candidate + in the method. + verbose: bool + Whether to print on screen the full output (True) or + not (False). + apply_constraint: boolean + Whether to apply the constrains of the ASE Atoms instance + to the calculated forces. + By default (apply_constraint=True) forces are 0 for + constrained atoms and directions. + force_consistent: boolean or None. + Use force-consistent energy calls (as opposed to the energy + extrapolated to 0 K). + By default force_consistent=False. + scale_fmax: float + The scaling of the fmax for the ML-NEB runs. + It makes the path converge tighter on surrogate surface. + use_fmax_convergence: bool + Whether to use the maximum force as an convergence criterion. + unc_convergence: float + Maximum uncertainty for convergence in + the Bayesian optimization (in eV). + use_method_unc_conv: bool + Whether to use the unc_convergence as a convergence criterion + in the optimization method. + check_unc: bool + Check if the uncertainty is large for the restarted result and + if it is then use the previous initial. + check_energy: bool + Check if the energy is larger for the restarted result than + the previous. + check_fmax: bool + Check if the maximum force is larger for the restarted result + than the initial interpolation and if so then replace it. + n_evaluations_each: int + Number of evaluations for each candidate. + trajectory: str or TrajectoryWriter instance + Trajectory filename to store the predicted data. + Or the TrajectoryWriter instance to store the predicted data. + trainingset: str or TrajectoryWriter instance + Trajectory filename to store the evaluated training data. + Or the TrajectoryWriter instance to store the evaluated + training data. + converged_trajectory: str or TrajectoryWriter instance + Trajectory filename to store the converged structures. + Or the TrajectoryWriter instance to store the converged + structures. + tabletxt: str + Name of the .txt file where the summary table is printed. + It is not saved to the file if tabletxt=None. + prev_calculations: Atoms list or ASE Trajectory file. + The user can feed previously calculated data + for the same hypersurface. + The previous calculations must be fed as an Atoms list + or Trajectory filename. + restart: bool + Whether to restart the Bayesian optimization. + comm: MPI communicator. + The MPI communicator. + """ + # Build the optimizer method + method = self.build_method( + slab=slab, + adsorbate=adsorbate, + adsorbate2=adsorbate2, + bounds=bounds, + opt_kwargs=opt_kwargs, + chains=chains, + parallel_run=parallel_run, + comm=comm, + verbose=verbose, + ) + # Initialize the BayesianOptimizer + super().__init__( + method=method, + ase_calc=ase_calc, + mlcalc=mlcalc, + acq=acq, + is_minimization=True, + use_database_check=use_database_check, + save_memory=save_memory, + parallel_run=parallel_run, + copy_calc=copy_calc, + verbose=verbose, + apply_constraint=apply_constraint, + force_consistent=force_consistent, + scale_fmax=scale_fmax, + use_fmax_convergence=use_fmax_convergence, + unc_convergence=unc_convergence, + use_method_unc_conv=use_method_unc_conv, + use_restart=False, + check_unc=check_unc, + check_energy=check_energy, + check_fmax=check_fmax, + n_evaluations_each=n_evaluations_each, + trajectory=trajectory, + trainingset=trainingset, + converged_trajectory=converged_trajectory, + tabletxt=tabletxt, + prev_calculations=prev_calculations, + restart=restart, + comm=comm, + **kwargs, + ) + + def build_method( + self, + slab, + adsorbate, + adsorbate2=None, + bounds=None, + opt_kwargs={}, + chains=None, + parallel_run=False, + comm=world, + verbose=False, + **kwargs, + ): + "Build the optimization method." + # Save the instances for creating the adsorption optimizer + self.slab = self.copy_atoms(slab) + self.adsorbate = self.copy_atoms(adsorbate) + if adsorbate2 is not None: + self.adsorbate2 = self.copy_atoms(adsorbate2) + else: + self.adsorbate2 = None + self.bounds = bounds + self.opt_kwargs = opt_kwargs.copy() + self.chains = chains + # Build the optimizer method + method = AdsorptionOptimizer( + slab=slab, + adsorbate=adsorbate, + adsorbate2=adsorbate2, + bounds=bounds, + opt_kwargs=opt_kwargs, + parallel_run=False, + comm=comm, + verbose=verbose, + ) + if parallel_run: + method = ParallelOptimizer( + method, + chains=chains, + parallel_run=parallel_run, + comm=comm, + verbose=verbose, + ) + return method + + def extra_initial_data(self, **kwargs): + # Check if the training set is empty + if self.get_training_set_size(): + return + # Get the initial structures from repulsion potential + self.method.set_calculator(RepulsionCalculator(r_scale=0.7)) + self.method.run(fmax=0.05, steps=1000) + atoms = self.method.get_candidates()[0] + # Calculate the initial structure + self.evaluate(atoms) + # Print summary table + self.print_statement() + return atoms + + def setup_mlcalc( + self, + mlcalc=None, + save_memory=False, + fp=None, + atoms=None, + prior=None, + baseline=RepulsionCalculator(), + database_reduction=False, + calc_forces=True, + bayesian=True, + kappa=2.0, + **kwargs, + ): + if mlcalc is None: + from ..regression.gp.fingerprint.sorteddistances import ( + SortedDistances, + ) + + # Setup the fingerprint + if fp is None: + # Check if the Atoms object is given + if atoms is None: + try: + atoms = self.get_structures(get_all=False) + except Exception: + raise Exception( + "The Atoms object is not given or stored." + ) + # Can only use distances if there are more than one atom + if len(atoms) > 1: + if atoms.pbc.any(): + periodic_softmax = True + else: + periodic_softmax = False + fp = SortedDistances( + reduce_dimensions=True, + use_derivatives=True, + periodic_softmax=periodic_softmax, + wrap=False, + ) + return super().setup_mlcalc( + mlcalc=mlcalc, + save_memory=save_memory, + fp=fp, + atoms=atoms, + prior=prior, + baseline=baseline, + database_reduction=database_reduction, + calc_forces=calc_forces, + bayesian=bayesian, + kappa=kappa, + **kwargs, + ) + + def get_arguments(self): + "Get the arguments of the class itself." + # Get the arguments given to the class in the initialization + arg_kwargs = dict( + slab=self.slab, + adsorbate=self.adsorbate, + ase_calc=self.ase_calc, + mlcalc=self.mlcalc, + adsorbate2=self.adsorbate2, + bounds=self.bounds, + opt_kwargs=self.opt_kwargs, + chains=self.chains, + acq=self.acq, + use_database_check=self.use_database_check, + save_memory=self.save_memory, + parallel_run=self.parallel_run, + copy_calc=self.copy_calc, + verbose=self.verbose, + apply_constraint=self.apply_constraint, + force_consistent=self.force_consistent, + scale_fmax=self.scale_fmax, + use_fmax_convergence=self.use_fmax_convergence, + unc_convergence=self.unc_convergence, + use_method_unc_conv=self.use_method_unc_conv, + check_unc=self.check_unc, + check_energy=self.check_energy, + check_fmax=self.check_fmax, + n_evaluations_each=self.n_evaluations_each, + trajectory=self.trajectory, + trainingset=self.trainingset, + converged_trajectory=self.converged_trajectory, + tabletxt=self.tabletxt, + comm=self.comm, + ) + # Get the constants made within the class + constant_kwargs = dict() + # Get the objects made within the class + object_kwargs = dict() + return arg_kwargs, constant_kwargs, object_kwargs diff --git a/catlearn/activelearning/local.py b/catlearn/activelearning/local.py new file mode 100644 index 00000000..1fec98d6 --- /dev/null +++ b/catlearn/activelearning/local.py @@ -0,0 +1,261 @@ +from ase.optimize import FIRE +from ase.parallel import world +from .activelearning import ActiveLearning +from ..optimizer import LocalOptimizer + + +class LocalAL(ActiveLearning): + def __init__( + self, + atoms, + ase_calc, + mlcalc=None, + local_opt=FIRE, + local_opt_kwargs={}, + acq=None, + is_minimization=True, + use_database_check=True, + save_memory=False, + parallel_run=False, + copy_calc=False, + verbose=True, + apply_constraint=True, + force_consistent=False, + scale_fmax=0.8, + use_fmax_convergence=True, + unc_convergence=0.05, + use_method_unc_conv=True, + use_restart=True, + check_unc=True, + check_energy=True, + check_fmax=True, + n_evaluations_each=1, + trajectory="predicted.traj", + trainingset="evaluated.traj", + converged_trajectory="converged.traj", + tabletxt="ml_summary.txt", + prev_calculations=None, + restart=False, + comm=world, + **kwargs, + ): + """ + A Bayesian optimizer that is used for accelerating local optimization + of an atomic structure with an active learning approach. + + Parameters: + atoms: Atoms instance + The instance to be optimized. + ase_calc: ASE calculator instance. + ASE calculator as implemented in ASE. + mlcalc: ML-calculator instance. + The ML-calculator instance used as surrogate surface. + The default BOCalculator instance is used if mlcalc is None. + local_opt: ASE optimizer object + The local optimizer object. + local_opt_kwargs: dict + The keyword arguments for the local optimizer. + acq: Acquisition class instance. + The Acquisition instance used for calculating the + acq. function and choose a candidate to calculate next. + The default AcqUME instance is used if acq is None. + is_minimization: bool + Whether it is a minimization that is performed. + Alternative is a maximization. + use_database_check: bool + Whether to check if the new structure is within the database. + If it is in the database, the structure is rattled. + save_memory: bool + Whether to only train the ML calculator and store all objects + on one CPU. + If save_memory==True then parallel optimization of + the hyperparameters can not be achived. + If save_memory==False no MPI object is used. + parallel_run: bool + Whether to run method in parallel on multiple CPUs (True) or + in sequence on 1 CPU (False). + copy_calc: bool + Whether to copy the calculator for each candidate + in the method. + verbose: bool + Whether to print on screen the full output (True) or + not (False). + apply_constraint: boolean + Whether to apply the constrains of the ASE Atoms instance + to the calculated forces. + By default (apply_constraint=True) forces are 0 for + constrained atoms and directions. + force_consistent: boolean or None. + Use force-consistent energy calls (as opposed to the energy + extrapolated to 0 K). + By default force_consistent=False. + scale_fmax: float + The scaling of the fmax for the ML-NEB runs. + It makes the path converge tighter on surrogate surface. + use_fmax_convergence: bool + Whether to use the maximum force as an convergence criterion. + unc_convergence: float + Maximum uncertainty for convergence in + the Bayesian optimization (in eV). + use_method_unc_conv: bool + Whether to use the unc_convergence as a convergence criterion + in the optimization method. + use_restart: bool + Use the result from last robust iteration. + check_unc: bool + Check if the uncertainty is large for the restarted result and + if it is then use the previous initial. + check_energy: bool + Check if the energy is larger for the restarted result than + the previous. + check_fmax: bool + Check if the maximum force is larger for the restarted result + than the initial interpolation and if so then replace it. + n_evaluations_each: int + Number of evaluations for each structure. + trajectory: str or TrajectoryWriter instance + Trajectory filename to store the predicted data. + Or the TrajectoryWriter instance to store the predicted data. + trainingset: str or TrajectoryWriter instance + Trajectory filename to store the evaluated training data. + Or the TrajectoryWriter instance to store the evaluated + training data. + converged_trajectory: str or TrajectoryWriter instance + Trajectory filename to store the converged structures. + Or the TrajectoryWriter instance to store the converged + structures. + tabletxt: str + Name of the .txt file where the summary table is printed. + It is not saved to the file if tabletxt=None. + prev_calculations: Atoms list or ASE Trajectory file. + The user can feed previously calculated data + for the same hypersurface. + The previous calculations must be fed as an Atoms list + or Trajectory filename. + restart: bool + Whether to restart the Bayesian optimization. + comm: MPI communicator. + The MPI communicator. + """ + # Build the optimizer method + method = self.build_method( + atoms=atoms, + local_opt=local_opt, + local_opt_kwargs=local_opt_kwargs, + parallel_run=parallel_run, + comm=comm, + verbose=verbose, + ) + # Initialize the BayesianOptimizer + super().__init__( + method=method, + ase_calc=ase_calc, + mlcalc=mlcalc, + acq=acq, + is_minimization=is_minimization, + use_database_check=use_database_check, + save_memory=save_memory, + parallel_run=parallel_run, + copy_calc=copy_calc, + verbose=verbose, + apply_constraint=apply_constraint, + force_consistent=force_consistent, + scale_fmax=scale_fmax, + use_fmax_convergence=use_fmax_convergence, + unc_convergence=unc_convergence, + use_method_unc_conv=use_method_unc_conv, + use_restart=use_restart, + check_unc=check_unc, + check_energy=check_energy, + check_fmax=check_fmax, + n_evaluations_each=n_evaluations_each, + trajectory=trajectory, + trainingset=trainingset, + converged_trajectory=converged_trajectory, + tabletxt=tabletxt, + prev_calculations=prev_calculations, + restart=restart, + comm=comm, + **kwargs, + ) + + def build_method( + self, + atoms, + local_opt=FIRE, + local_opt_kwargs={}, + parallel_run=False, + comm=world, + verbose=False, + **kwargs, + ): + "Build the optimization method." + # Save the instances for creating the local optimizer + self.atoms = self.copy_atoms(atoms) + self.local_opt = local_opt + self.local_opt_kwargs = local_opt_kwargs + # Build the optimizer method + method = LocalOptimizer( + atoms, + local_opt=local_opt, + local_opt_kwargs=local_opt_kwargs, + parallel_run=parallel_run, + comm=comm, + verbose=verbose, + ) + return method + + def extra_initial_data(self, **kwargs): + # Check if the training set is empty + if self.get_training_set_size(): + return self + # Get the initial structure if it is calculated + if self.atoms.calc is not None: + results = self.atoms.calc.results + if "energy" in results and "forces" in results: + self.use_prev_calculations([self.atoms]) + return self + # Calculate the initial structure + self.evaluate(self.get_structures(get_all=False)) + # Print summary table + self.print_statement() + return self + + def get_arguments(self): + "Get the arguments of the class itself." + # Get the arguments given to the class in the initialization + arg_kwargs = dict( + atoms=self.atoms, + ase_calc=self.ase_calc, + mlcalc=self.mlcalc, + local_opt=self.local_opt, + local_opt_kwargs=self.local_opt_kwargs, + acq=self.acq, + is_minimization=self.is_minimization, + use_database_check=self.use_database_check, + save_memory=self.save_memory, + parallel_run=self.parallel_run, + copy_calc=self.copy_calc, + verbose=self.verbose, + apply_constraint=self.apply_constraint, + force_consistent=self.force_consistent, + scale_fmax=self.scale_fmax, + use_fmax_convergence=self.use_fmax_convergence, + unc_convergence=self.unc_convergence, + use_method_unc_conv=self.use_method_unc_conv, + use_restart=self.use_restart, + check_unc=self.check_unc, + check_energy=self.check_energy, + check_fmax=self.check_fmax, + n_evaluations_each=self.n_evaluations_each, + trajectory=self.trajectory, + trainingset=self.trainingset, + converged_trajectory=self.converged_trajectory, + tabletxt=self.tabletxt, + comm=self.comm, + ) + # Get the constants made within the class + constant_kwargs = dict() + # Get the objects made within the class + object_kwargs = dict() + return arg_kwargs, constant_kwargs, object_kwargs diff --git a/catlearn/activelearning/mlgo.py b/catlearn/activelearning/mlgo.py new file mode 100644 index 00000000..6e96d317 --- /dev/null +++ b/catlearn/activelearning/mlgo.py @@ -0,0 +1,378 @@ +from ase.parallel import world +from ase.optimize import FIRE +from .adsorption import AdsorptionAL +from ..optimizer import LocalOptimizer + + +class MLGO(AdsorptionAL): + def __init__( + self, + slab, + adsorbate, + ase_calc, + mlcalc=None, + mlcalc_local=None, + adsorbate2=None, + bounds=None, + opt_kwargs={}, + chains=None, + local_opt=FIRE, + local_opt_kwargs={}, + reuse_data_local=True, + acq=None, + use_database_check=True, + save_memory=False, + parallel_run=False, + copy_calc=False, + verbose=True, + apply_constraint=True, + force_consistent=False, + scale_fmax=0.8, + use_fmax_convergence=True, + unc_convergence=0.05, + use_method_unc_conv=True, + use_restart=True, + check_unc=True, + check_energy=True, + check_fmax=True, + n_evaluations_each=1, + trajectory="predicted.traj", + trainingset="evaluated.traj", + converged_trajectory="converged.traj", + tabletxt="ml_summary.txt", + prev_calculations=None, + restart=False, + comm=world, + **kwargs, + ): + """ + A Bayesian optimizer that is used for accelerating local optimization + of an atomic structure with an active learning approach. + + Parameters: + slab: Atoms instance + The slab structure. + Can either be a surface or a nanoparticle. + adsorbate: Atoms instance + The adsorbate structure. + ase_calc: ASE calculator instance. + ASE calculator as implemented in ASE. + mlcalc: ML-calculator instance. + The ML-calculator instance used as surrogate surface. + The default BOCalculator instance is used if mlcalc is None. + mlcalc_local: ML-calculator instance. + The ML-calculator instance used for the local optimization. + The default BOCalculator instance is used + if mlcalc_local is None. + adsorbate2: Atoms instance (optional) + The second adsorbate structure. + Optimize both adsorbates simultaneously. + The two adsorbates will have different tags. + bounds: (6,2) array or (12,2) array (optional) + The bounds for the optimization. + The first 3 rows are the x, y, z scaled coordinates for + the center of the adsorbate. + The next 3 rows are the three rotation angles in radians. + If two adsorbates are optimized, the next 6 rows are for + the second adsorbate. + opt_kwargs: dict + The keyword arguments for the simulated annealing optimizer. + chains: int (optional) + The number of optimization that will be run in parallel. + It is only used if parallel_run=True. + local_opt: ASE optimizer object + The local optimizer object. + local_opt_kwargs: dict + The keyword arguments for the local optimizer. + reuse_data_local: bool + Whether to reuse the data from the global optimization in the + ML-calculator for the local optimization. + acq: Acquisition class instance. + The Acquisition instance used for calculating the + acq. function and choose a candidate to calculate next. + The default AcqUME instance is used if acq is None. + is_minimization: bool + Whether it is a minimization that is performed. + Alternative is a maximization. + use_database_check: bool + Whether to check if the new structure is within the database. + If it is in the database, the structure is rattled. + save_memory: bool + Whether to only train the ML calculator and store all objects + on one CPU. + If save_memory==True then parallel optimization of + the hyperparameters can not be achived. + If save_memory==False no MPI object is used. + parallel_run: bool + Whether to run method in parallel on multiple CPUs (True) or + in sequence on 1 CPU (False). + copy_calc: bool + Whether to copy the calculator for each candidate + in the method. + verbose: bool + Whether to print on screen the full output (True) or + not (False). + apply_constraint: boolean + Whether to apply the constrains of the ASE Atoms instance + to the calculated forces. + By default (apply_constraint=True) forces are 0 for + constrained atoms and directions. + force_consistent: boolean or None. + Use force-consistent energy calls (as opposed to the energy + extrapolated to 0 K). + By default force_consistent=False. + scale_fmax: float + The scaling of the fmax for the ML-NEB runs. + It makes the path converge tighter on surrogate surface. + use_fmax_convergence: bool + Whether to use the maximum force as an convergence criterion. + unc_convergence: float + Maximum uncertainty for convergence in + the Bayesian optimization (in eV). + use_method_unc_conv: bool + Whether to use the unc_convergence as a convergence criterion + in the optimization method. + use_restart: bool + Use the result from last robust iteration in + the local optimization. + check_unc: bool + Check if the uncertainty is large for the restarted result and + if it is then use the previous initial. + check_energy: bool + Check if the energy is larger for the restarted result than + the previous. + check_fmax: bool + Check if the maximum force is larger for the restarted result + than the initial interpolation and if so then replace it. + n_evaluations_each: int + The number of evaluations for each structure. + trajectory: str or TrajectoryWriter instance + Trajectory filename to store the predicted data. + Or the TrajectoryWriter instance to store the predicted data. + trainingset: str or TrajectoryWriter instance + Trajectory filename to store the evaluated training data. + Or the TrajectoryWriter instance to store the evaluated + training data. + converged_trajectory: str or TrajectoryWriter instance + Trajectory filename to store the converged structures. + Or the TrajectoryWriter instance to store the converged + structures. + tabletxt: str + Name of the .txt file where the summary table is printed. + It is not saved to the file if tabletxt=None. + prev_calculations: Atoms list or ASE Trajectory file. + The user can feed previously calculated data + for the same hypersurface. + The previous calculations must be fed as an Atoms list + or Trajectory filename. + restart: bool + Whether to restart the Bayesian optimization. + comm: MPI communicator. + The MPI communicator. + """ + # Initialize the AdsorptionBO + super().__init__( + slab=slab, + adsorbate=adsorbate, + ase_calc=ase_calc, + mlcalc=mlcalc, + adsorbate2=adsorbate2, + bounds=bounds, + opt_kwargs=opt_kwargs, + chains=chains, + acq=acq, + use_database_check=use_database_check, + save_memory=save_memory, + parallel_run=parallel_run, + copy_calc=copy_calc, + verbose=verbose, + apply_constraint=apply_constraint, + force_consistent=force_consistent, + scale_fmax=scale_fmax, + use_fmax_convergence=use_fmax_convergence, + unc_convergence=unc_convergence, + use_method_unc_conv=use_method_unc_conv, + check_unc=check_unc, + check_energy=check_energy, + check_fmax=check_fmax, + n_evaluations_each=n_evaluations_each, + trajectory=trajectory, + trainingset=trainingset, + converged_trajectory=converged_trajectory, + tabletxt=tabletxt, + prev_calculations=prev_calculations, + restart=restart, + comm=comm, + **kwargs, + ) + # Get the atomic structure + atoms = self.get_structures(get_all=False) + # Build the local method + self.build_local_method( + atoms=atoms, + mlcalc_local=mlcalc_local, + local_opt=local_opt, + local_opt_kwargs=local_opt_kwargs, + reuse_data_local=reuse_data_local, + use_restart=use_restart, + ) + # Save the local ML-calculator + self.mlcalc_local = mlcalc_local + + def build_local_method( + self, + atoms, + local_opt=FIRE, + local_opt_kwargs={}, + reuse_data_local=True, + use_restart=True, + **kwargs, + ): + "Build the local optimization method." + # Save the instances for creating the local optimizer + self.atoms = self.copy_atoms(atoms) + self.local_opt = local_opt + self.local_opt_kwargs = local_opt_kwargs + # Save boolean for reusing data in the mlcalc_local + self.reuse_data_local = reuse_data_local + # Build the local optimizer method + self.local_method = LocalOptimizer( + atoms, + local_opt=local_opt, + local_opt_kwargs=local_opt_kwargs, + parallel_run=False, + comm=self.comm, + verbose=self.verbose, + ) + return self.local_method + + def setup_mlcalc_local( + self, + *args, + **kwargs, + ): + return super(AdsorptionAL, self).setup_mlcalc(*args, **kwargs) + + def run( + self, + fmax=0.05, + steps=200, + ml_steps=2000, + max_unc=None, + dtrust=None, + seed=None, + **kwargs, + ): + # Run the Bayesian optimization + super().run( + fmax=fmax, + steps=steps, + ml_steps=ml_steps, + max_unc=max_unc, + dtrust=dtrust, + seed=seed, + **kwargs, + ) + # Check if the Bayesian optimization is converged + if not self.converged(): + return self.converged() + # Switch to the local optimization + self.switch_to_local() + # Adjust the number of steps + steps = steps - self.get_number_of_steps() + if steps <= 0: + return self.converged() + # Run the local Bayesian optimization + super().run( + fmax=fmax, + steps=steps, + ml_steps=ml_steps, + max_unc=max_unc, + dtrust=dtrust, + seed=seed, + **kwargs, + ) + return self.converged() + + def switch_mlcalcs(self, **kwargs): + """ + Switch the ML calculator used for the local optimization. + The data is reused, but without the constraints from Adsorption. + """ + # Get the data from the Bayesian optimization + data = self.get_data_atoms() + if not self.reuse_data_local: + data = data[-1:] + # Get the structures + structures = self.get_structures(get_all=False) + # Setup the ML-calculator for the local optimization + self.setup_mlcalc_local( + mlcalc_local=self.mlcalc_local, + save_memory=self.save_memory, + atoms=structures, + ) + # Remove adsorption constraints + constraints = structures.constraints + for atoms in data: + atoms.set_constraint(constraints) + self.use_prev_calculations(data) + return self + + def switch_to_local(self, **kwargs): + "Switch to the local optimization." + # Reset convergence + self._converged = False + # Switch to the local ML-calculator + self.switch_mlcalcs() + # Store the last structures + self.structures = self.get_structures() + # Use the last structures for the local optimization + self.local_method.update_optimizable(self.structures) + # Switch to the local optimization + self.setup_method(self.local_method) + return self + + def get_arguments(self): + "Get the arguments of the class itself." + # Get the arguments given to the class in the initialization + arg_kwargs = dict( + slab=self.slab, + adsorbate=self.adsorbate, + ase_calc=self.ase_calc, + mlcalc=self.mlcalc, + mlcalc_local=self.mlcalc_local, + adsorbate2=self.adsorbate2, + bounds=self.bounds, + opt_kwargs=self.opt_kwargs, + chains=self.chains, + local_opt=self.local_opt, + local_opt_kwargs=self.local_opt_kwargs, + reuse_data_local=self.reuse_data_local, + acq=self.acq, + use_database_check=self.use_database_check, + save_memory=self.save_memory, + parallel_run=self.parallel_run, + copy_calc=self.copy_calc, + verbose=self.verbose, + apply_constraint=self.apply_constraint, + force_consistent=self.force_consistent, + scale_fmax=self.scale_fmax, + use_fmax_convergence=self.use_fmax_convergence, + unc_convergence=self.unc_convergence, + use_method_unc_conv=self.use_method_unc_conv, + use_restart=self.use_restart, + check_unc=self.check_unc, + check_energy=self.check_energy, + check_fmax=self.check_fmax, + n_evaluations_each=self.n_evaluations_each, + trajectory=self.trajectory, + trainingset=self.trainingset, + converged_trajectory=self.converged_trajectory, + tabletxt=self.tabletxt, + comm=self.comm, + ) + # Get the constants made within the class + constant_kwargs = dict() + # Get the objects made within the class + object_kwargs = dict() + return arg_kwargs, constant_kwargs, object_kwargs diff --git a/catlearn/activelearning/mlneb.py b/catlearn/activelearning/mlneb.py new file mode 100644 index 00000000..9276a440 --- /dev/null +++ b/catlearn/activelearning/mlneb.py @@ -0,0 +1,368 @@ +from ase.optimize import FIRE +from ase.parallel import world +from ase.io import read +import numpy as np +from .activelearning import ActiveLearning +from ..optimizer import LocalCINEB +from ..structures.neb import ImprovedTangentNEB + + +class MLNEB(ActiveLearning): + def __init__( + self, + start, + end, + ase_calc, + mlcalc=None, + neb_method=ImprovedTangentNEB, + neb_kwargs={}, + n_images=15, + climb=True, + neb_interpolation="linear", + neb_interpolation_kwargs={}, + reuse_ci_path=False, + local_opt=FIRE, + local_opt_kwargs={}, + acq=None, + use_database_check=True, + save_memory=False, + parallel_run=False, + copy_calc=False, + verbose=True, + apply_constraint=True, + force_consistent=False, + scale_fmax=0.8, + unc_convergence=0.05, + use_method_unc_conv=True, + use_restart=True, + check_unc=True, + check_energy=False, + check_fmax=True, + n_evaluations_each=1, + trajectory="predicted.traj", + trainingset="evaluated.traj", + converged_trajectory="converged.traj", + tabletxt="ml_summary.txt", + prev_calculations=None, + restart=False, + comm=world, + **kwargs, + ): + """ + A Bayesian optimizer that is used for accelerating nudged elastic band + (NEB) optimization with an active learning approach. + + Parameters: + start : Atoms instance or ASE Trajectory file. + The Atoms must have the calculator attached with energy. + Initial end-point of the NEB path. + end : Atoms instance or ASE Trajectory file. + The Atoms must have the calculator attached with energy. + Final end-point of the NEB path. + ase_calc: ASE calculator instance. + ASE calculator as implemented in ASE. + mlcalc: ML-calculator instance. + The ML-calculator instance used as surrogate surface. + The default BOCalculator instance is used if mlcalc is None. + neb_method : NEB class object or str + The NEB implemented class object used for the ML-NEB. + A string can be used to select: + - 'improvedtangentneb' (default) + - 'ewneb' + neb_kwargs : dict + A dictionary with the arguments used in the NEB object + to create the instance. + Climb must not be included. + n_images : int + Number of images of the path (if not included a path before). + The number of images include the 2 end-points of the NEB path. + climb : bool + Whether to use the climbing image in the NEB. + It is strongly recommended to have climb=True. + neb_interpolation : str + The interpolation method used to create the NEB path. + The default is 'linear'. + neb_interpolation_kwargs : dict + The keyword arguments for the interpolation method. + reuse_ci_path : bool + Whether to restart from the climbing image path when the NEB + without climbing image is converged. + local_opt: ASE optimizer object + The local optimizer object. + local_opt_kwargs: dict + The keyword arguments for the local optimizer. + acq: Acquisition class instance. + The Acquisition instance used for calculating the + acq. function and choose a candidate to calculate next. + The default AcqUME instance is used if acq is None. + use_database_check: bool + Whether to check if the new structure is within the database. + If it is in the database, the structure is rattled. + save_memory: bool + Whether to only train the ML calculator and store all objects + on one CPU. + If save_memory==True then parallel optimization of + the hyperparameters can not be achived. + If save_memory==False no MPI object is used. + parallel_run: bool + Whether to run method in parallel on multiple CPUs (True) or + in sequence on 1 CPU (False). + copy_calc: bool + Whether to copy the calculator for each candidate + in the method. + verbose: bool + Whether to print on screen the full output (True) or + not (False). + apply_constraint: boolean + Whether to apply the constrains of the ASE Atoms instance + to the calculated forces. + By default (apply_constraint=True) forces are 0 for + constrained atoms and directions. + force_consistent: boolean or None. + Use force-consistent energy calls (as opposed to the energy + extrapolated to 0 K). + By default force_consistent=False. + scale_fmax: float + The scaling of the fmax for the ML-NEB runs. + It makes the path converge tighter on surrogate surface. + unc_convergence: float + Maximum uncertainty for convergence in + the Bayesian optimization (in eV). + use_method_unc_conv: bool + Whether to use the unc_convergence as a convergence criterion + in the optimization method. + use_restart: bool + Use the result from last robust iteration. + check_unc: bool + Check if the uncertainty is large for the restarted result and + if it is then use the previous initial. + check_energy: bool + Check if the energy is larger for the restarted result than + the previous. + check_fmax: bool + Check if the maximum force is larger for the restarted result + than the initial interpolation and if so then replace it. + n_evaluations_each: int + Number of evaluations for each iteration. + trajectory: str or TrajectoryWriter instance + Trajectory filename to store the predicted data. + Or the TrajectoryWriter instance to store the predicted data. + trainingset: str or TrajectoryWriter instance + Trajectory filename to store the evaluated training data. + Or the TrajectoryWriter instance to store the evaluated + training data. + converged_trajectory: str or TrajectoryWriter instance + Trajectory filename to store the converged structures. + Or the TrajectoryWriter instance to store the converged + structures. + tabletxt: str + Name of the .txt file where the summary table is printed. + It is not saved to the file if tabletxt=None. + prev_calculations: Atoms list or ASE Trajectory file. + The user can feed previously calculated data + for the same hypersurface. + The previous calculations must be fed as an Atoms list + or Trajectory filename. + restart: bool + Whether to restart the Bayesian optimization. + comm: MPI communicator. + The MPI communicator. + """ + # Save the end points for creating the NEB + self.setup_endpoints(start, end, prev_calculations) + # Build the optimizer method and NEB within + method = self.build_method( + neb_method=neb_method, + neb_kwargs=neb_kwargs, + climb=climb, + n_images=n_images, + neb_interpolation=neb_interpolation, + neb_interpolation_kwargs=neb_interpolation_kwargs, + reuse_ci_path=reuse_ci_path, + local_opt=local_opt, + local_opt_kwargs=local_opt_kwargs, + parallel_run=parallel_run, + comm=comm, + verbose=verbose, + **kwargs, + ) + # Initialize the BayesianOptimizer + super().__init__( + method=method, + ase_calc=ase_calc, + mlcalc=mlcalc, + acq=acq, + is_minimization=False, + use_database_check=use_database_check, + save_memory=save_memory, + parallel_run=parallel_run, + copy_calc=copy_calc, + verbose=verbose, + apply_constraint=apply_constraint, + force_consistent=force_consistent, + scale_fmax=scale_fmax, + use_fmax_convergence=climb, + unc_convergence=unc_convergence, + use_method_unc_conv=use_method_unc_conv, + use_restart=use_restart, + check_unc=check_unc, + check_energy=check_energy, + check_fmax=check_fmax, + n_evaluations_each=n_evaluations_each, + trajectory=trajectory, + trainingset=trainingset, + converged_trajectory=converged_trajectory, + tabletxt=tabletxt, + prev_calculations=self.prev_calculations, + restart=restart, + comm=comm, + **kwargs, + ) + + def setup_endpoints( + self, + start, + end, + prev_calculations, + eps=1e-6, + **kwargs, + ): + """ + Setup the start and end points for the NEB calculation. + """ + # Load the start and end points from trajectory files + if isinstance(start, str): + start = read(start) + if isinstance(end, str): + end = read(end) + # Save the start point with calculators + start.get_forces() + self.start = self.copy_atoms(start) + # Save the end point with calculators + end.get_forces() + self.end = self.copy_atoms(end) + # Save in previous calculations + self.prev_calculations = [self.start, self.end] + if prev_calculations is not None: + if isinstance(prev_calculations, str): + prev_calculations = read(prev_calculations, ":") + # Check if end points are in the previous calculations + if len(prev_calculations): + pos = prev_calculations[0].get_positions() + if np.linalg.norm(pos - self.start.get_positions()) < eps: + prev_calculations = prev_calculations[1:] + if len(prev_calculations): + pos = prev_calculations[0].get_positions() + if np.linalg.norm(pos - self.end.get_positions()) < eps: + prev_calculations = prev_calculations[1:] + # Save the previous calculations + self.prev_calculations += list(prev_calculations) + return self + + def build_method( + self, + neb_method, + neb_kwargs={}, + climb=True, + n_images=15, + k=3.0, + remove_rotation_and_translation=False, + mic=True, + neb_interpolation="linear", + neb_interpolation_kwargs={}, + reuse_ci_path=True, + local_opt=FIRE, + local_opt_kwargs={}, + parallel_run=False, + comm=world, + verbose=False, + **kwargs, + ): + "Build the optimization method." + # Save the instances for creating the local optimizer + self.local_opt = local_opt + self.local_opt_kwargs = local_opt_kwargs + # Save the instances for creating the NEB + self.neb_method = neb_method + self.neb_kwargs = dict( + k=k, + remove_rotation_and_translation=remove_rotation_and_translation, + mic=mic, + save_properties=True, + parallel=parallel_run, + world=comm, + ) + self.neb_kwargs.update(neb_kwargs) + self.n_images = n_images + self.neb_interpolation = neb_interpolation + self.neb_interpolation_kwargs = dict( + mic=mic, + remove_rotation_and_translation=remove_rotation_and_translation, + ) + self.neb_interpolation_kwargs.update(neb_interpolation_kwargs) + self.climb = climb + self.reuse_ci_path = reuse_ci_path + # Build the sequential neb optimizer + method = LocalCINEB( + start=self.start, + end=self.end, + neb_method=self.neb_method, + neb_kwargs=self.neb_kwargs, + n_images=self.n_images, + climb=self.climb, + neb_interpolation=self.neb_interpolation, + neb_interpolation_kwargs=self.neb_interpolation_kwargs, + reuse_ci_path=self.reuse_ci_path, + local_opt=self.local_opt, + local_opt_kwargs=self.local_opt_kwargs, + parallel_run=parallel_run, + comm=comm, + verbose=verbose, + ) + return method + + def get_arguments(self): + "Get the arguments of the class itself." + # Get the arguments given to the class in the initialization + arg_kwargs = dict( + start=self.start, + end=self.end, + ase_calc=self.ase_calc, + mlcalc=self.mlcalc, + neb_method=self.neb_method, + neb_kwargs=self.neb_kwargs, + n_images=self.n_images, + climb=self.climb, + neb_interpolation=self.neb_interpolation, + neb_interpolation_kwargs=self.neb_interpolation_kwargs, + reuse_ci_path=self.reuse_ci_path, + local_opt=self.local_opt, + local_opt_kwargs=self.local_opt_kwargs, + acq=self.acq, + is_minimization=self.is_minimization, + use_database_check=self.use_database_check, + save_memory=self.save_memory, + parallel_run=self.parallel_run, + copy_calc=self.copy_calc, + verbose=self.verbose, + apply_constraint=self.apply_constraint, + force_consistent=self.force_consistent, + scale_fmax=self.scale_fmax, + unc_convergence=self.unc_convergence, + use_method_unc_conv=self.use_method_unc_conv, + use_restart=self.use_restart, + check_unc=self.check_unc, + check_energy=self.check_energy, + check_fmax=self.check_fmax, + n_evaluations_each=self.n_evaluations_each, + trajectory=self.trajectory, + trainingset=self.trainingset, + converged_trajectory=self.converged_trajectory, + tabletxt=self.tabletxt, + comm=self.comm, + ) + # Get the constants made within the class + constant_kwargs = dict() + # Get the objects made within the class + object_kwargs = dict() + return arg_kwargs, constant_kwargs, object_kwargs From 6fdc680fe0579e41ead8828b38a3339637eb17f2 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Sun, 27 Oct 2024 19:31:10 +0100 Subject: [PATCH 010/194] Make a general active learning class and construct previous used classes from that --- catlearn/optimize/__init__.py | 31 - catlearn/optimize/mlgo.py | 991 ------------------------------ catlearn/optimize/mlneb.py | 1071 --------------------------------- 3 files changed, 2093 deletions(-) delete mode 100644 catlearn/optimize/__init__.py delete mode 100644 catlearn/optimize/mlgo.py delete mode 100644 catlearn/optimize/mlneb.py diff --git a/catlearn/optimize/__init__.py b/catlearn/optimize/__init__.py deleted file mode 100644 index 772fce19..00000000 --- a/catlearn/optimize/__init__.py +++ /dev/null @@ -1,31 +0,0 @@ -from .mlneb import MLNEB -from .mlgo import MLGO -from .acquisition import ( - Acquisition, - AcqEnergy, - AcqUncertainty, - AcqUCB, - AcqLCB, - AcqIter, - AcqUME, - AcqUUCB, - AcqULCB, - AcqEI, - AcqPI, -) - -__all__ = [ - "MLNEB", - "MLGO", - "Acquisition", - "AcqEnergy", - "AcqUncertainty", - "AcqUCB", - "AcqLCB", - "AcqIter", - "AcqUME", - "AcqUUCB", - "AcqULCB", - "AcqEI", - "AcqPI", -] diff --git a/catlearn/optimize/mlgo.py b/catlearn/optimize/mlgo.py deleted file mode 100644 index 7ee79c61..00000000 --- a/catlearn/optimize/mlgo.py +++ /dev/null @@ -1,991 +0,0 @@ -import numpy as np -import ase -from ase.io import read -from scipy.optimize import dual_annealing -import datetime -from ase.parallel import world, broadcast -from ..regression.gp.calculator.copy_atoms import copy_atoms -from ..regression.gp.baseline.repulsive import RepulsionCalculator - - -class MLGO: - def __init__( - self, - slab, - ads, - ase_calc, - ads2=None, - mlcalc=None, - acq=None, - prev_calculations=None, - use_database_check=True, - apply_constraint=True, - force_consistent=None, - scale_fmax=0.8, - save_memory=False, - local_opt=None, - local_opt_kwargs={}, - opt_kwargs={}, - bounds=None, - initial_points=2, - norelax_points=10, - min_steps=8, - trajectory="evaluated.traj", - tabletxt="mlgo_summary.txt", - full_output=False, - **kwargs, - ): - """ - Machine learning accelerated global adsorption optimization - with active learning. - - Parameters: - slab : ASE Atoms object. - The object of the surface or nanoparticle that - the adsorbate is adsorped to. - The energy and forces for the structure is not needed. - ads : ASE Atoms object. - The object of the adsorbate in vacuum with same cell size and - pbc as for the slab. - The energy and forces for the structure is not needed. - ase_calc : ASE calculator instance. - ASE calculator as implemented in ASE. - See: - https://wiki.fysik.dtu.dk/ase/ase/calculators/calculators.html - ads2 : ASE Atoms object (optional). - The object of a second adsorbate in vacuum that - is adsorbed simultaneously with the other adsorbate. - mlcalc : ML-calculator instance. - The ML-calculator instance used as surrogate surface. - A default ML-model is used if mlcalc is None. - acq : Acquisition instance. - The Acquisition instance used for calculating - the acq. function and choose a candidate to calculate next. - A default Acquisition instance is used if acq is None. - prev_calculations : Atoms list or ASE Trajectory file. - (optional) The user can feed previously calculated data for the - same hypersurface. The previous calculations must be fed as an - Atoms list or Trajectory file. - use_database_check : bool - Whether to check if the new structure is within the database. - If it is in the database, the structure is rattled. - apply_constraint : boolean - Whether to apply the constrains of the ASE Atoms instance - to the calculated forces. - By default (apply_constraint=True) forces are 0 for - constrained atoms and directions. - force_consistent : boolean or None. - Use force-consistent energy calls (as opposed to the energy - extrapolated to 0 K). By default (force_consistent=None) uses - force-consistent energies if available in the calculator, but - falls back to force_consistent=False if not. - scale_fmax : float - The scaling of the fmax for the ML-NEB runs. - It makes the path converge tighter on surrogate surface. - save_memory : bool - Whether to only train the ML calculator and store - all objects on one CPU. - If save_memory==True then parallel optimization of - the hyperparameters can not be achived. - If save_memory==False no MPI object is used. - local_opt : ASE local optimizer Object. - A local optimizer object from ASE. - If None is given then FIRE is used. - local_opt_kwargs : dict. - Arguments used for the ASE local optimizer. - bounds : (6,2) or (12,2) ndarray (optional). - The boundary conditions used for the global optimization in - form of the simulated annealing. - The boundary conditions are the x, y, and z coordinates of - the center of the adsorbate and 3 rotations. - Same boundary conditions can be set for the second adsorbate - if chosen. - initial_points : int. - Number of generated initial structures used for training - the ML calculator if no previous data is given. - norelax_points : int. - The number of structures used for training before - local relaxation of the structures after - the global optimization is activated. - min_steps : int. - The minimum number of iterations before convergence is checked. - opt_kwargs : dict. - Arguments used for the simulated annealing method. - trajectory : string. - Trajectory filename to store the evaluated training data. - tabletxt : string - Name of the .txt file where the summary table is printed. - It is not saved to the file if tabletxt=None. - full_output : bool. - Whether to print on screen the full output (True). - """ - # Setup parallelization - self.parallel_setup(save_memory) - # Setup given parameters - self.setup_slab_ads(slab, ads, ads2) - self.opt_kwargs = opt_kwargs - self.norelax_points = norelax_points - self.min_steps = min_steps - self.use_database_check = use_database_check - self.initial_points = initial_points - self.full_output = full_output - # Set candidate instance with ASE calculator - self.candidate = self.slab_ads.copy() - self.candidate.calc = ase_calc - self.apply_constraint = apply_constraint - self.force_consistent = force_consistent - # Set initial parameters - self.step = 0 - self.error = 0 - self.energies = [] - self.emin = np.inf - self.best_candidate = None - # Boundary conditions for adsorbate position and angles - if bounds is None: - self.bounds = np.array( - [ - [0.0, 1.0], - [0.0, 1.0], - [0.0, 1.0], - [0.0, 2 * np.pi], - [0.0, 2 * np.pi], - [0.0, 2 * np.pi], - ] - ) - else: - self.bounds = bounds.copy() - if len(self.bounds) == 6 and self.ads2 is not None: - self.bounds = np.concatenate([self.bounds, self.bounds], axis=0) - # Make trajectory file for calculated structures - self.trajectory = trajectory - # Summary table file name - self.tabletxt = tabletxt - # Setup the ML calculator - self.set_mlcalc(mlcalc, save_memory=save_memory) - self.set_verbose(verbose=full_output) - # Select an acquisition function - self.set_acq(acq) - # Scale the fmax on the surrogate surface - self.scale_fmax = scale_fmax - # Use restart structures or make one initial point - self.use_prev_calculations(prev_calculations) - # Set local optimizer - self.set_local_opt( - local_opt=local_opt, - local_opt_kwargs=local_opt_kwargs, - ) - - def run( - self, - fmax=0.05, - unc_convergence=0.025, - steps=200, - max_unc=0.25, - ml_steps=2000, - ml_chains=3, - relax=True, - local_steps=500, - seed=0, - **kwargs, - ): - """ - Run the ML adsorption optimizer - - Parameters: - fmax : float - Convergence criteria (in eV/Angs). - unc_convergence: float - Maximum uncertainty for convergence (in eV). - steps : int - Maximum number of evaluations. - max_unc : float - Early stopping criteria. - Maximum uncertainty allowed before local optimization. - ml_steps : int - Maximum number of steps for the global optimization - on the predicted landscape. - ml_chains : int - The number of parallel chains in the simulated annealing. - relax : bool - Whether to perform local optimization after - the global optimization. - local_steps : int - Maximum number of steps for the local optimization - on the predicted landscape. - seed : int (optional) - The random seed. - """ - # Set the random seed - np.random.seed(seed) - # Update the acquisition function - self.acq.update_arguments(unc_convergence=unc_convergence) - # Calculate initial data if enough data is not given - self.extra_initial_data(self.initial_points) - # Run global search - for step in range(1, steps + 1): - # Train ML-Model - self.train_mlmodel() - # Search after and find the next candidate for calculation - candidate = self.find_next_candidate( - ml_chains, - ml_steps, - max_unc, - relax, - fmax * self.scale_fmax, - local_steps, - ) - # Evaluate candidate - self.evaluate(candidate) - # Make print of table - self.print_statement(step) - # Check for convergence - self.converging = self.check_convergence(unc_convergence, fmax) - if self.converging: - break - if self.converging is False: - self.message_system("MLGO did not converge!") - return self.best_candidate - - def get_atoms(self): - "Return the best candidate structure." - return self.best_candidate - - def setup_slab_ads(self, slab, ads, ads2=None): - "Setup slab and adsorbate with their constrains" - # Setup slab - self.slab = slab.copy() - self.slab.set_tags(0) - # Setup adsorbate - self.ads = ads.copy() - self.ads.set_tags(1) - # Center adsorbate structure - pos = self.ads.get_positions() - self.ads.set_positions(pos - np.mean(pos, axis=0)) - self.ads.cell = self.slab.cell.copy() - self.ads.pbc = self.slab.pbc.copy() - # Setup second adsorbate - if ads2: - self.ads2 = ads2.copy() - self.ads2.set_tags(2) - # Center adsorbate structure - pos = self.ads2.get_positions() - self.ads2.set_positions(pos - np.mean(pos, axis=0)) - self.ads2.cell = self.slab.cell.copy() - self.ads2.pbc = self.slab.pbc.copy() - else: - self.ads2 = None - # Number of atoms and the constraint used - self.slab_ads = self.slab.copy() - self.slab_ads.extend(self.ads.copy()) - if self.ads2: - self.slab_ads.extend(self.ads2.copy()) - self.number_atoms = len(self.slab_ads) - return - - def parallel_setup(self, save_memory=False, **kwargs): - "Setup the parallelization." - self.save_memory = save_memory - self.rank = world.rank - self.size = world.size - return self - - def place_ads(self, pos_angles): - "Place the adsorbate in the cell of the surface" - if self.ads2: - ( - x, - y, - z, - theta1, - theta2, - theta3, - x2, - y2, - z2, - theta12, - theta22, - theta32, - ) = pos_angles - else: - x, y, z, theta1, theta2, theta3 = pos_angles - ads = self.rotation_matrix(self.ads.copy(), [theta1, theta2, theta3]) - spos = ads.get_scaled_positions() - ads.set_scaled_positions(spos + np.array([x, y, z])) - slab_ads = self.slab.copy() - slab_ads.extend(ads) - if self.ads2: - ads2 = self.rotation_matrix( - self.ads2.copy(), - [theta12, theta22, theta32], - ) - spos = ads2.get_scaled_positions() - ads2.set_scaled_positions(spos + np.array([x2, y2, z2])) - slab_ads.extend(ads2) - slab_ads.wrap() - return slab_ads - - def rotation_matrix(self, ads, angles): - "Rotate the adsorbate" - theta1, theta2, theta3 = angles - Rz = np.array( - [ - [np.cos(theta1), -np.sin(theta1), 0.0], - [np.sin(theta1), np.cos(theta1), 0.0], - [0.0, 0.0, 1.0], - ] - ) - Ry = np.array( - [ - [np.cos(theta2), 0.0, np.sin(theta2)], - [0.0, 1.0, 0.0], - [-np.sin(theta2), 0.0, np.cos(theta2)], - ] - ) - R = np.matmul(Ry, Rz) - Rz = np.array( - [ - [np.cos(theta3), -np.sin(theta3), 0.0], - [np.sin(theta3), np.cos(theta3), 0.0], - [0.0, 0.0, 1.0], - ] - ) - R = np.matmul(Rz, R).T - ads.set_positions(np.matmul(ads.get_positions(), R)) - return ads - - def evaluate(self, candidate): - "Caculate energy and forces and add training system to ML-model" - # Ensure that the candidate is not already in the database - if self.use_database_check: - candidate = self.ensure_not_in_database(candidate) - # Broadcast the system to all cpus - if self.rank == 0: - candidate = candidate.copy() - candidate = broadcast(candidate, root=0) - # Calculate the energies and forces - self.message_system("Performing evaluation.", end="\r") - self.candidate.set_positions(candidate.get_positions()) - forces = self.candidate.get_forces( - apply_constraint=self.apply_constraint - ) - self.energy_true = self.candidate.get_potential_energy( - force_consistent=self.force_consistent - ) - self.step += 1 - self.message_system("Single-point calculation finished.") - # Store the data - self.max_abs_forces = np.nanmax(np.linalg.norm(forces, axis=1)) - self.add_training([self.candidate]) - self.mlcalc.save_data(trajectory=self.trajectory) - # Best new point - self.best_new_point(self.candidate, self.energy_true) - return - - def add_training(self, atoms_list): - "Add atoms_list data to ML model on rank=0." - self.mlcalc.add_training(atoms_list) - return self.mlcalc - - def best_new_point(self, candidate, energy): - "Best new candidate due to energy" - if self.rank == 0: - if energy <= self.emin: - self.emin = energy - self.best_candidate = copy_atoms(candidate) - self.best_x = self.x.copy() - # Save the energy - self.energies.append(energy) - # Broadcast convergence statement if MPI is used - self.best_candidate, self.emin = broadcast( - [self.best_candidate, self.emin], - root=0, - ) - return self.best_candidate - - def add_random_ads(self): - "Generate a random slab-adsorbate structure from bounds" - sol = dual_annealing( - self.dual_func_random, - self.bounds, - maxfun=100, - **self.opt_kwargs, - ) - self.x = sol["x"].copy() - slab_ads = self.place_ads(sol["x"]) - return slab_ads - - def dual_func_random(self, pos_angles): - "Dual annealing object function for random structure" - slab_ads = self.place_ads(pos_angles) - slab_ads.calc = RepulsionCalculator( - r_scale=0.7, - reduce_dimensions=True, - power=10, - periodic_softmax=True, - wrap=True, - ) - energy = slab_ads.get_potential_energy() - return energy - - def use_prev_calculations(self, prev_calculations): - "Use previous calculations to restart ML calculator." - if prev_calculations is None: - return - if isinstance(prev_calculations, str): - prev_calculations = read(prev_calculations, ":") - # Add calculations to the ML model - self.add_training(prev_calculations) - return - - def set_verbose(self, verbose, **kwargs): - "Set verbose of MLModel." - self.mlcalc.mlmodel.update_arguments(verbose=verbose) - return - - def train_mlmodel(self): - "Train the ML model." - if self.save_memory: - if self.rank != 0: - return self.mlcalc - # Update database with the points of interest - self.update_database_arguments(point_interest=self.best_candidate) - # Train the ML model - self.mlcalc.train_model() - return self.mlcalc - - def is_in_database(self, atoms, **kwargs): - "Check if the ASE Atoms is in the database." - return self.mlcalc.is_in_database(atoms, **kwargs) - - def update_database_arguments(self, point_interest=None, **kwargs): - "Update the arguments in the database." - self.mlcalc.update_database_arguments( - point_interest=point_interest, - **kwargs, - ) - return self - - def ensure_not_in_database(self, atoms, perturb=0.01, **kwargs): - """ - Ensure the ASE Atoms object is not in database by perturb it - if it is. - """ - # Return atoms if it does not exist - if atoms is None: - return atoms - # Check if atoms object is in the database - if self.is_in_database(atoms, **kwargs): - # Get positions - pos = atoms.get_positions() - # Rattle the positions - pos = pos + np.random.uniform( - low=-perturb, - high=perturb, - size=pos.shape, - ) - atoms.set_positions(pos) - self.message_system( - "The system is rattled, since it is already in the database." - ) - return atoms - - def find_next_candidate( - self, - ml_chains, - ml_steps, - max_unc, - relax, - fmax, - local_steps, - **kwargs, - ): - """ - Find the next candidates by using simulated annealing and - then chose the candidate from acquisition. - """ - # Return None if memory is saved and therefore not in parallel - if self.save_memory and self.rank != 0: - return None - # Initialize candidate dictionary - candidate, energy, unc, x = None, None, None, None - candidates = { - "candidates": [], - "energies": [], - "uncertainties": [], - "x": [], - } - r = 0 - # Perform multiple optimizations - for chain in range(ml_chains): - # Set a unique optimization for each chain - np.random.seed(chain) - if not self.save_memory: - r = chain % self.size - if self.rank == r: - # Find candidates from a global simulated annealing search - self.message_system( - "Starting global search!", end="\r", rank=r - ) - candidate, energy, unc, x = self.dual_annealing( - maxiter=ml_steps, - **self.opt_kwargs, - ) - self.message_system("Global search converged", rank=r) - # Do a local relaxation if the conditions are met - if relax and ( - self.get_training_set_size() >= self.norelax_points - ): - if unc <= max_unc: - self.message_system( - "Starting local relaxation", end="\r", rank=r - ) - candidate, energy, unc = self.local_relax( - candidate, - fmax, - max_unc, - local_steps=local_steps, - rank=r, - ) - else: - self.message_system( - "No local relaxation due to high uncertainty", - rank=r, - ) - # Append the newest candidate - candidates = self.append_candidates( - candidates, - candidate, - energy, - unc, - x, - ) - # Broadcast all the candidates - if not self.save_memory: - candidates = self.broadcast_candidates(candidates) - # Print the energies and uncertainties for the new candidates - self.message_system( - "Candidates energies: " + str(candidates["energies"]) - ) - self.message_system( - "Candidates uncertainties: " + str(candidates["uncertainties"]) - ) - # Find the new best candidate from the acquisition function - candidate = self.choose_candidate(candidates) - return candidate - - def choose_candidate(self, candidates): - "Use acquisition functions to chose the next training point" - # Calculate the acquisition function for each candidate - acq_values = self.acq.calculate( - np.array(candidates["energies"]), - np.array(candidates["uncertainties"]), - ) - # Chose the minimum value given by the Acq. class - i_min = self.acq.choose(acq_values)[0] - # The next training point - candidate = candidates["candidates"][i_min].copy() - self.energy = candidates["energies"][i_min] - self.unc = np.abs(candidates["uncertainties"][i_min]) - self.x = candidates["x"][i_min].copy() - return candidate - - def check_convergence(self, unc_convergence, fmax): - "Check if the convergence criteria are fulfilled" - converged = False - if self.rank == 0: - # Check the minimum number of steps have been performed - if self.min_steps <= self.get_training_set_size(): - # Check the force and uncertainty criteria are met - if self.max_abs_forces <= fmax and self.unc < unc_convergence: - # Check the true energy deviation match - # the uncertainty prediction - e_dif = np.abs(self.energy_true - self.energy) - if e_dif <= 2.0 * unc_convergence: - # Check the predicted structure has - # the lowest observed energy - em_dif = np.abs(self.energy - self.emin) - if em_dif <= 2.0 * unc_convergence: - self.message_system("Optimization is converged.") - converged = True - # Broadcast convergence statement if MPI is used - converged = broadcast(converged, root=0) - return converged - - def dual_annealing(self, maxiter=5000, **opt_kwargs): - """ - Find the candidates structures, energy and forces using dual annealing. - """ - # Deactivate force predictions - self.mlcalc.update_arguments(calculate_forces=False) - # Perform simulated annealing - sol = dual_annealing( - self.dual_func, - bounds=self.bounds, - maxfun=maxiter, - **opt_kwargs, - ) - # Reconstruct the final structure - slab_ads = self.place_ads(sol["x"]) - # Get the energy and uncertainty predictions - slab_ads.calc = self.mlcalc - energy, unc = self.get_predictions(slab_ads) - return slab_ads.copy(), energy, unc, sol["x"].copy() - - def dual_func(self, pos_angles): - "Dual annealing object function" - # Construct the structure - slab_ads = self.place_ads(pos_angles) - # Predict the energy and uncertainty - slab_ads.calc = self.mlcalc - energy = slab_ads.get_potential_energy() - unc = slab_ads.calc.get_uncertainty(slab_ads) - # Calculate the acquisition function - return self.acq.calculate(energy, uncertainty=unc) - - def local_relax( - self, - candidate, - fmax, - max_unc, - local_steps=200, - rank=0, - **kwargs, - ): - "Perform a local relaxation of the candidate" - # Activate force predictions and reset calculator - self.mlcalc.update_arguments(calculate_forces=True) - self.mlcalc.reset() - candidate = candidate.copy() - candidate.calc = self.mlcalc - # Initialize local optimization - with self.local_opt(candidate, **self.local_opt_kwargs) as dyn: - if max_unc is False or max_unc is None: - converged, candidate = self.local_relax_no_max_unc( - dyn, - candidate, - fmax=fmax, - local_steps=local_steps, - **kwargs, - ) - else: - converged, candidate = self.local_relax_max_unc( - dyn, - candidate, - fmax=fmax, - max_unc=max_unc, - local_steps=local_steps, - rank=rank, - **kwargs, - ) - # Calculate the energy and uncertainty - energy, unc = self.get_predictions(candidate) - return candidate.copy(), energy, unc - - def local_relax_no_max_unc( - self, - dyn, - candidate, - fmax, - local_steps=200, - **kwargs, - ): - "Run the local optimization without checking uncertainties." - dyn.run(fmax=fmax, steps=local_steps) - return dyn.converged(), candidate - - def local_relax_max_unc( - self, - dyn, - candidate, - fmax, - max_unc, - local_steps=200, - rank=0, - **kwargs, - ): - "Run the local optimization with checking uncertainties." - for i in range(1, local_steps + 1): - candidate_backup = candidate.copy() - # Take a step in local relaxation on surrogate surface - if ase.__version__ >= "3.23": - dyn.run(fmax=fmax, steps=1) - else: - dyn.run(fmax=fmax, steps=i) - energy, unc = self.get_predictions(candidate) - # Check if the uncertainty is too large - if unc >= max_unc: - self.message_system( - "Relaxation on surrogate surface stopped due " - "to high uncertainty!", - rank=rank, - ) - break - # Check if there is a problem with prediction - if np.isnan(energy): - candidate = candidate_backup.copy() - candidate.calc = self.mlcalc - self.message_system( - "Stopped due to NaN value in prediction!", rank=rank - ) - break - # Check if the optimization is converged on the predicted surface - if dyn.converged(): - self.message_system( - "Relaxation on surrogate surface converged!", rank=rank - ) - break - # Check the number of steps - if dyn.get_number_of_steps() >= local_steps: - break - return dyn.converged(), candidate - - def get_predictions(self, candidate): - "Calculate the energies and uncertainties with the ML calculator" - unc = candidate.calc.get_uncertainty(candidate) - energy = candidate.get_potential_energy() - return energy, unc - - def get_training_set_size(self): - "Get the size of the training set" - return self.mlcalc.get_training_set_size() - - def extra_initial_data(self, initial_points): - """ - If only initial and final state is given then a third data point - is calculated. - """ - candidate = None - while self.get_training_set_size() < initial_points: - candidate = self.add_random_ads() - self.evaluate(candidate) - return self.get_training_set_size() - - def append_candidates( - self, - candidates, - candidate, - energy, - unc, - x, - **kwargs, - ): - "Update the candidates by appending the newest one." - candidates["candidates"].append(candidate) - candidates["energies"].append(energy) - candidates["uncertainties"].append(unc) - candidates["x"].append(x) - return candidates - - def broadcast_candidates(self, candidates, **kwargs): - "Broadcast candidates with energies, uncertainties, and positions." - candidates_broad = { - "candidates": [], - "energies": [], - "uncertainties": [], - "x": [], - } - for r in range(self.size): - cand_r = broadcast(candidates, root=r) - for n in range(len(cand_r["candidates"])): - candidates_broad = self.append_candidates( - candidates_broad, - cand_r["candidates"][n], - cand_r["energies"][n], - cand_r["uncertainties"][n], - cand_r["x"][n], - ) - return candidates_broad - - def get_energy_deviation(self, **kwargs): - """ - Get the absolute energy difference between - the predicted and true energy. - """ - return np.abs(self.energy_true - self.energy) - - def message_system(self, message, obj=None, end="\n", rank=0): - "Print output once." - if self.full_output is True: - if self.rank == rank: - if obj is None: - print(message, end=end) - else: - print(message, obj, end=end) - else: - if self.rank == 0: - if obj is None: - print(message, end=end) - else: - print(message, obj, end=end) - return - - def converged(self): - "Whether MLGO is converged." - return self.converging - - def set_mlcalc(self, mlcalc, save_memory=None, **kwargs): - """ - Setup the ML calculator. - - Parameters: - mlcalc : ML-calculator instance. - The ML-calculator instance used as surrogate surface. - A default ML-model is used if mlcalc is None. - save_memory : bool - Whether to only train the ML calculator and store - all objects on one CPU. - If save_memory==True then parallel optimization of - the hyperparameters can not be achived. - If save_memory==False no MPI object is used. - - Returns: - self: The object itself. - """ - if mlcalc is None: - from ..regression.gp.calculator import ( - get_default_mlmodel, - MLCalculator, - ) - from ..regression.gp.fingerprint import ( - SortedDistances, - ) - - # Check if the save_memory is given - if save_memory is None: - try: - save_memory = self.save_memory - except Exception: - raise Exception("The save_memory is not given.") - - fp = SortedDistances( - reduce_dimensions=True, - use_derivatives=True, - periodic_softmax=True, - wrap=True, - ) - baseline = RepulsionCalculator( - reduce_dimensions=True, - power=10, - periodic_softmax=True, - wrap=True, - ) - mlmodel = get_default_mlmodel( - model="gp", - fp=fp, - baseline=baseline, - use_derivatives=True, - parallel=(not save_memory), - database_reduction=False, - ) - self.mlcalc = MLCalculator(mlmodel=mlmodel) - else: - self.mlcalc = mlcalc - return self - - def set_acq(self, acq=None, **kwargs): - """ - Set the acquisition function. - - Parameters: - acq : Acquisition class instance. - The Acquisition instance used for calculating - the acq. function and choose a candidate to calculate next. - If None is given then LCB is used. - - Returns: - self: The object itself. - """ - if acq is None: - from .acquisition import AcqLCB - - self.acq = AcqLCB(objective="min", kappa=3.0) - else: - self.acq = acq.copy() - return self - - def set_local_opt(self, local_opt=None, local_opt_kwargs={}, **kwargs): - """ - Save local optimizer. - - Parameters: - local_opt : ASE local optimizer Object. - A local optimizer object from ASE. - If None is given then FIRE is used. - local_opt_kwargs : dict - Arguments used for the ASE local optimizer. - - Returns: - self: The object itself. - """ - local_opt_kwargs_default = dict() - if not self.full_output: - local_opt_kwargs_default["logfile"] = None - if local_opt is None: - from ase.optimize import FIRE - - local_opt = FIRE - local_opt_kwargs_default.update( - dict( - dt=0.05, - maxstep=0.2, - a=1.0, - astart=1.0, - fa=0.999, - downhill_check=True, - ) - ) - self.local_opt = local_opt - local_opt_kwargs_default.update(local_opt_kwargs) - self.local_opt_kwargs = local_opt_kwargs_default.copy() - return self - - def save_mlcalc(self, filename="mlcalc.pkl", **kwargs): - """ - Save the ML calculator object to a file. - - Parameters: - filename : str - The name of the file where the object is saved. - - Returns: - self: The object itself. - """ - self.mlcalc.save_mlcalc(filename, **kwargs) - return self - - def make_summary_table(self, step, **kwargs): - "Make the summary of the Global optimization process as table." - now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") - try: - len(self.print_list) - except Exception: - self.print_list = [ - "| Step | Time | True energy | " - "Uncertainty | True error | fmax |" - ] - msg = "|{0:6d}| ".format(step) - msg += "{} |".format(now) - msg += "{0:23f}|".format(self.energy_true) - msg += "{0:13f}|".format(self.unc) - msg += "{0:14f}|".format(self.get_energy_deviation()) - msg += "{0:10f}|".format(self.max_abs_forces) - self.print_list.append(msg) - msg = "\n".join(self.print_list) - return msg - - def save_summary_table(self, **kwargs): - "Save the summary table in the .txt file." - if self.tabletxt is not None: - with open(self.tabletxt, "w") as thefile: - msg = "\n".join(self.print_list) - thefile.writelines(msg) - return - - def print_statement(self, step, **kwargs): - "Print the Global optimization process as a table" - msg = "" - if self.rank == 0: - msg = self.make_summary_table(step, **kwargs) - self.save_summary_table() - self.message_system(msg) - return msg diff --git a/catlearn/optimize/mlneb.py b/catlearn/optimize/mlneb.py deleted file mode 100644 index b262bdcf..00000000 --- a/catlearn/optimize/mlneb.py +++ /dev/null @@ -1,1071 +0,0 @@ -import numpy as np -import ase -from ase.io import read -from ase.io.trajectory import TrajectoryWriter -from ase.parallel import world, broadcast -import datetime -from .neb.improvedneb import ImprovedTangentNEB -from .neb.nebimage import NEBImage -from .neb.interpolate_band import make_interpolation -from ..regression.gp.calculator.copy_atoms import copy_atoms - - -class MLNEB: - def __init__( - self, - start, - end, - ase_calc, - mlcalc=None, - acq=None, - interpolation="idpp", - interpolation_kwargs=dict(), - climb=True, - neb_method=ImprovedTangentNEB, - neb_kwargs=dict(), - n_images=15, - prev_calculations=None, - use_database_check=True, - use_restart_path=True, - check_path_unc=True, - check_path_fmax=True, - use_low_unc_ci=True, - reuse_ci_path=False, - save_memory=False, - apply_constraint=True, - force_consistent=None, - scale_fmax=0.8, - local_opt=None, - local_opt_kwargs=dict(), - trainingset="evaluated_structures.traj", - trajectory="MLNEB.traj", - last_path=None, - final_path="final_path.traj", - tabletxt="mlneb_summary.txt", - restart=False, - full_output=False, - **kwargs, - ): - """ - Nudged elastic band (NEB) with Machine Learning as active learning. - - Parameters: - start : Atoms object with calculated energy or ASE Trajectory file. - Initial end-point of the NEB path. - end : Atoms object with calculated energy or ASE Trajectory file. - Final end-point of the NEB path. - ase_calc : ASE calculator instance. - ASE calculator as implemented in ASE. - See: - https://wiki.fysik.dtu.dk/ase/ase/calculators/calculators.html - mlcalc : ML-calculator instance. - The ML-calculator instance used as surrogate surface. - A default ML-model is used if mlcalc is None. - acq : Acquisition class instance. - The Acquisition instance used for calculating - the acq. function and choose a candidate to calculate next. - A default Acquisition instance is used if acq is None. - interpolation : string or list of ASE Atoms or ASE Trajectory file. - Automatic interpolation can be done ('idpp' and 'linear' as - implemented in ASE). - See https://wiki.fysik.dtu.dk/ase/ase/neb.html. - Manual: Trajectory file (in ASE format) or list of Atoms. - interpolation_kwargs : dict. - A dictionary with the arguments used in the interpolation. - See https://wiki.fysik.dtu.dk/ase/ase/neb.html. - climb : bool - Whether to use climbing image in the ML-NEB. - It is strongly recommended to have climb=True. - It is only activated when the uncertainty is low and - a NEB without climbing image can converge. - neb_method : class object or str - The NEB implemented class object used for the ML-NEB. - A string can be used to select: - - 'improvedtangentneb' (default) - - 'ewneb' - neb_kwargs : dict. - A dictionary with the arguments used in the NEB object - to create the instance. - Climb must not be included. - See https://wiki.fysik.dtu.dk/ase/ase/neb.html. - n_images : int. - Number of images of the path (if not included a path before). - The number of images include the 2 end-points of the NEB path. - prev_calculations : Atoms list or ASE Trajectory file. - (optional) The user can feed previously calculated data for the - same hypersurface. The previous calculations must be fed as an - Atoms list or Trajectory file. - use_database_check : bool - Whether to check if the new structure is within the database. - If it is in the database, the structure is rattled. - use_restart_path : bool - Use the path from last robust iteration (low uncertainty). - check_path_unc : bool - Check if the uncertainty is large for the restarted path and - if it is then use the initial interpolation. - check_path_fmax : bool - Check if the maximum perpendicular force is larger for - the restarted path than the initial interpolation and - if so then replace it. - use_low_unc_ci : bool - Whether to only activative climbing image NEB - when the uncertainties of all images are below unc_convergence. - If use_low_unc_ci=False, the climbing image is activated - without checking the uncertainties. - reuse_ci_path : bool - Whether to reuse the path from the climbing image NEB. - It is only recommended to be used if use_low_unc_ci=True. - save_memory : bool - Whether to only train the ML calculator and store - all objects on one CPU. - If save_memory==True then parallel optimization of - the hyperparameters can not be achived. - If save_memory==False no MPI object is used. - apply_constraint : boolean - Whether to apply the constrains of the ASE Atoms instance - to the calculated forces. - By default (apply_constraint=True) forces are 0 for - constrained atoms and directions. - force_consistent : boolean or None. - Use force-consistent energy calls (as opposed to the energy - extrapolated to 0 K). By default (force_consistent=None) uses - force-consistent energies if available in the calculator, but - falls back to force_consistent=False if not. - scale_fmax : float - The scaling of the fmax for the ML-NEB runs. - It makes the path converge tighter on surrogate surface. - local_opt : ASE local optimizer Object. - A local optimizer object from ASE. - If None is given then FIRE is used. - local_opt_kwargs : dict - Arguments used for the ASE local optimizer. - trainingset : string. - Trajectory filename to store the evaluated training data. - trajectory : string - Trajectory filename to store the predicted NEB path. - last_path : string - Trajectory filename to store the last MLNEB path. - If last_path=None, the last path is not saved. - final_path : string - Trajectory filename to store the final MLNEB path. - If final_path=None, the final path is not saved. - tabletxt : string - Name of the .txt file where the summary table is printed. - It is not saved to the file if tabletxt=None. - restart : bool - Whether to restart the MLNEB from a previous run. - It is only possible to restart the MLNEB - if the previous run was performed in same directory. - The previous and current run must have the same parameters. - The trainingset and trajectory file is used - to restart the MLNEB. - Therefore, prev_calculations has to be None. - full_output : boolean - Whether to print on screen the full output (True). - """ - # Setup parallelization - self.parallel_setup(save_memory) - # NEB parameters - self.interpolation = interpolation - self.interpolation_kwargs = dict( - mic=True, - remove_rotation_and_translation=False, - ) - self.interpolation_kwargs.update(interpolation_kwargs) - self.n_images = n_images - self.climb = climb - self.set_neb_method(neb_method) - self.neb_kwargs = dict(k=3.0, remove_rotation_and_translation=False) - self.neb_kwargs.update(neb_kwargs) - # General parameter settings - self.use_database_check = use_database_check - self.use_restart_path = use_restart_path - self.check_path_unc = check_path_unc - self.check_path_fmax = check_path_fmax - self.reuse_ci_path = reuse_ci_path - self.use_low_unc_ci = use_low_unc_ci - # Set initial parameters - self.step = 0 - self.converging = False - # Setup the ML calculator - self.set_mlcalc(mlcalc, start=start, save_memory=save_memory) - # Whether to have the full output - self.full_output = full_output - self.set_verbose(verbose=full_output) - # Set an acquisition function - self.set_acq(acq) - # Save initial and final state - self.set_up_endpoints(start, end) - # Set candidate instance with ASE calculator - self.candidate = self.start.copy() - self.candidate.calc = ase_calc - self.apply_constraint = apply_constraint - self.force_consistent = force_consistent - # Scale the fmax on the surrogate surface - self.scale_fmax = scale_fmax - # Set local optimizer - self.set_local_opt( - local_opt=local_opt, - local_opt_kwargs=local_opt_kwargs, - ) - # Trajectories - self.trainingset = trainingset - self.trajectory = trajectory - self.last_path = last_path - self.final_path = final_path - # Summary table file name - self.tabletxt = tabletxt - # Restart the MLNEB - if restart: - if prev_calculations is not None: - self.message_system( - "Warning: Given previous calculations does " - "not work with restarting MLNEB!" - ) - try: - self.interpolation = read( - self.trajectory, - "-{}:".format(self.n_images), - ) - prev_calculations = read(self.trainingset, "2:") - except Exception: - self.message_system( - "Warning: Restarting MLNEB is not possible! " - "Reinitalizing MLNEB." - ) - # Load previous calculations to the ML model - self.use_prev_calculations(prev_calculations) - # Define the last images that can be used to restart the interpolation - self.last_images = self.make_interpolation( - interpolation=self.interpolation - ) - # CI restart path activation - self.climb_active = False - - def run( - self, - fmax=0.05, - unc_convergence=0.05, - steps=200, - ml_steps=1500, - max_unc=0.25, - **kwargs, - ): - """ - Run the active learning NEB process. - - Parameters: - fmax : float - Convergence criteria (in eV/Angs). - unc_convergence : float - Maximum uncertainty for convergence (in eV). - steps : int - Maximum number of evaluations. - ml_steps : int - Maximum number of steps for the NEB optimization on the - predicted landscape. - max_unc : float (optional) - Early stopping criteria. - Maximum uncertainty before stopping the optimization - on the surrogate surface. - If it is None or False, it will run to convergence. - """ - # Active learning parameters - candidate = None - self.acq.update_arguments(unc_convergence=unc_convergence) - # Define the images - self.images = [copy_atoms(image) for image in self.last_images] - # Define the temporary last images that can be used - # to restart the interpolation - self.last_images_tmp = None - # Calculate a extra data point if only start and end is given - self.extra_initial_data() - # Save MLNEB path trajectory - with TrajectoryWriter( - self.trajectory, - mode="w", - properties=["energy", "forces", "uncertainty"], - ) as self.trajectory_neb: - # Save the initial interpolation - self.save_last_path(self.last_path, self.images, properties=None) - # Run the active learning - for step in range(1, steps + 1): - # Train and optimize ML model - self.train_mlmodel() - # Perform NEB on ML surrogate surface - candidate, neb_converged = self.run_mlneb( - fmax=fmax * self.scale_fmax, - ml_steps=ml_steps, - max_unc=max_unc, - unc_convergence=unc_convergence, - ) - # Evaluate candidate - self.evaluate(candidate) - # Share the images between all CPUs - self.share_images() - # Print the results for this iteration - self.print_statement(step) - # Check convergence - self.converging = self.check_convergence( - fmax, unc_convergence, neb_converged - ) - if self.converging: - self.save_last_path(self.final_path, self.images) - self.message_system("MLNEB is converged.") - self.print_cite() - break - if not self.converging: - self.message_system("MLNEB did not converge!") - return self - - def get_images(self): - "Get the images." - return self.images - - def set_up_endpoints(self, start, end, **kwargs): - "Load and calculate the intial and final states" - # Load initial and final states - if isinstance(start, str): - start = read(start) - if isinstance(end, str): - end = read(end) - # Add initial and final states to ML model - self.add_training([start, end]) - # Store the initial and final energy - start.get_forces() - self.start_energy = start.get_potential_energy() - self.start = copy_atoms(start) - end.get_forces() - self.end_energy = end.get_potential_energy() - self.end = copy_atoms(end) - return - - def use_prev_calculations(self, prev_calculations, **kwargs): - "Use previous calculations to restart ML calculator." - if prev_calculations is not None: - # Use a trajectory file - if isinstance(prev_calculations, str): - prev_calculations = read(prev_calculations, ":") - # Add calculations to the ML model - self.add_training(prev_calculations) - return - - def make_interpolation(self, interpolation="idpp", **kwargs): - "Make the NEB interpolation path" - # Make the interpolation path - images = make_interpolation( - self.start.copy(), - self.end.copy(), - n_images=self.n_images, - method=interpolation, - **self.interpolation_kwargs, - ) - # Check interpolation has the right number of images - if len(images) != self.n_images: - raise Exception( - "The interpolated path has the wrong number of images!" - ) - # Attach the ML calculator to all images - images = self.attach_mlcalc(images) - return images - - def make_reused_interpolation( - self, - unc_convergence, - climb=False, - **kwargs, - ): - """ - Make the NEB interpolation path or use the previous path - if it has low uncertainty. - """ - # Whether to reuse the previous path - reuse_path = True - # Make the interpolation from the initial points - if not self.use_restart_path or self.last_images_tmp is None: - if not self.use_restart_path or self.step == 0: - self.message_system( - "The initial interpolation is used as the initial path." - ) - else: - self.message_system( - "The previous initial path is used as the initial path." - ) - reuse_path = False - elif self.check_path_unc or self.check_path_fmax: - # Get uncertainty and max perpendicular force - uncmax_tmp, fmax_tmp = self.get_path_unc_fmax( - interpolation=self.last_images_tmp, - climb=climb, - ) - # Check uncertainty - if self.check_path_unc: - # Check if the uncertainty is too large - if uncmax_tmp > unc_convergence: - reuse_path = False - self.last_images_tmp = None - self.message_system( - "The previous initial path is used as " - "the initial path due to uncertainty." - ) - # Check if the perpendicular force are less for the new path - if self.check_path_fmax and reuse_path: - fmax_last = self.get_path_unc_fmax( - interpolation=self.last_images, - climb=climb, - )[1] - if fmax_tmp > fmax_last: - reuse_path = False - self.last_images_tmp = None - self.message_system( - "The previous initial path is used as " - "the initial path due to fmax." - ) - # Reuse the last path - if reuse_path: - self.message_system("The last path is used as the initial path.") - self.last_images = [image.copy() for image in self.last_images_tmp] - return self.make_interpolation(interpolation=self.last_images) - - def attach_mlcalc(self, imgs, **kwargs): - "Attach the ML calculator to the given images." - images = [copy_atoms(self.start)] - for img in imgs[1:-1]: - image = img.copy() - image.calc = self.mlcalc - images.append(NEBImage(image)) - images.append(copy_atoms(self.end)) - return images - - def parallel_setup(self, save_memory=False, **kwargs): - "Setup the parallelization." - self.save_memory = save_memory - self.rank = world.rank - self.size = world.size - return self - - def evaluate(self, candidate, **kwargs): - "Evaluate the ASE atoms with the ASE calculator." - # Ensure that the candidate is not already in the database - if self.use_database_check: - candidate = self.ensure_not_in_database(candidate) - # Broadcast the system to all cpus - if self.rank == 0: - candidate = candidate.copy() - candidate = broadcast(candidate, root=0) - # Calculate the energies and forces - self.message_system("Performing evaluation.", end="\r") - self.candidate.set_positions(candidate.get_positions()) - forces = self.candidate.get_forces( - apply_constraint=self.apply_constraint - ) - self.energy_true = self.candidate.get_potential_energy( - force_consistent=self.force_consistent - ) - self.step += 1 - self.message_system("Single-point calculation finished.") - # Store the data - self.max_abs_forces = np.nanmax(np.linalg.norm(forces, axis=1)) - self.add_training([self.candidate]) - self.save_data() - return - - def add_training(self, atoms_list, **kwargs): - "Add atoms_list data to ML model on rank=0." - self.mlcalc.add_training(atoms_list) - return self.mlcalc - - def train_mlmodel(self, **kwargs): - "Train the ML model" - if self.save_memory and self.rank != 0: - return self.mlcalc - # Update database with the points of interest - self.update_database_arguments(point_interest=self.last_images[1:-1]) - # Train the ML model - self.mlcalc.train_model() - return self.mlcalc - - def set_verbose(self, verbose, **kwargs): - "Set verbose of MLModel." - self.mlcalc.mlmodel.update_arguments(verbose=verbose) - return - - def is_in_database(self, atoms, **kwargs): - "Check if the ASE Atoms is in the database." - return self.mlcalc.is_in_database(atoms, **kwargs) - - def update_database_arguments(self, point_interest=None, **kwargs): - "Update the arguments in the database." - self.mlcalc.update_database_arguments( - point_interest=point_interest, - **kwargs, - ) - return self - - def ensure_not_in_database(self, atoms, perturb=0.01, **kwargs): - """ - Ensure the ASE Atoms object is not in database by perturb it if it is. - """ - # Return atoms if it does not exist - if atoms is None: - return atoms - # Check if atoms object is in the database - if self.is_in_database(atoms, **kwargs): - # Get positions - pos = atoms.get_positions() - # Rattle the positions - pos = pos + np.random.uniform( - low=-perturb, - high=perturb, - size=pos.shape, - ) - atoms.set_positions(pos) - self.message_system( - "The system is rattled, since it is already in the database." - ) - return atoms - - def extra_initial_data(self, **kwargs): - """ - If only initial and final state is given then a third data point - is calculated. - """ - candidate = None - if self.get_training_set_size() <= 2: - middle = ( - int((self.n_images - 2) / 3.0) - if self.start_energy >= self.end_energy - else int((self.n_images - 2) * 2.0 / 3.0) - ) - candidate = self.last_images[1 + middle].copy() - if candidate is not None: - self.evaluate(candidate) - return candidate - - def run_mlneb( - self, - fmax=0.05, - ml_steps=750, - max_unc=0.25, - unc_convergence=0.05, - **kwargs, - ): - "Run the NEB on the ML surrogate surface" - # Convergence of the NEB - neb_converged = False - # If memeory is saved NEB is only performed on one CPU - if self.rank != 0: - return None, neb_converged - # Make the interpolation from initial path or the previous path - images = self.make_reused_interpolation( - unc_convergence, climb=self.climb_active - ) - # Run the NEB on the surrogate surface - if self.climb_active: - self.message_system( - "Starting NEB with climbing image on surrogate surface." - ) - else: - self.message_system( - "Starting NEB without climbing image on surrogate surface." - ) - images, neb_converged = self.mlneb_opt( - images, - fmax=fmax, - ml_steps=ml_steps, - max_unc=max_unc, - unc_convergence=unc_convergence, - climb=self.climb_active, - ) - self.save_mlneb(images) - self.save_last_path(self.last_path, self.images) - # Get the candidate - candidate = self.choose_candidate(images) - return candidate, neb_converged - - def get_training_set_size(self): - "Get the size of the training set" - return self.mlcalc.get_training_set_size() - - def get_predictions(self, images, **kwargs): - "Calculate the energies and uncertainties with the ML calculator" - energies = [] - uncertainties = [] - for image in images[1:-1]: - uncertainties.append(image.get_property("uncertainty")) - energies.append(image.get_potential_energy()) - return np.array(energies), np.array(uncertainties) - - def get_path_unc_fmax(self, interpolation, climb=False, **kwargs): - """ - Get the maximum uncertainty and fmax prediction from - the NEB interpolation. - """ - uncmax = None - fmax = None - images = self.make_interpolation(interpolation=interpolation) - if self.check_path_unc: - uncmax = np.nanmax(self.get_predictions(images)[1]) - if self.check_path_fmax: - fmax = self.get_fmax_predictions(images, climb=climb) - return uncmax, fmax - - def get_fmax_predictions(self, images, climb=False, **kwargs): - "Calculate the maximum perpendicular force with the ML calculator" - neb = self.neb_method(images, climb=climb, **self.neb_kwargs) - forces = neb.get_forces() - return np.nanmax(np.linalg.norm(forces, axis=1)) - - def choose_candidate(self, images, **kwargs): - "Use acquisition functions to chose the next training point" - # Get the energies and uncertainties - energy_path, unc_path = self.get_predictions(images) - # Store the maximum predictions - self.emax_ml = np.nanmax(energy_path) - self.umax_ml = np.nanmax(unc_path) - self.umean_ml = np.mean(unc_path) - # Calculate the acquisition function for each image - acq_values = self.acq.calculate(energy_path, unc_path) - # Chose the maximum value given by the Acq. class - i_min = int(self.acq.choose(acq_values)[0]) - # The next training point - image = images[1 + i_min].copy() - self.energy_pred = energy_path[i_min] - return image - - def mlneb_opt( - self, - images, - fmax=0.05, - ml_steps=750, - max_unc=0.25, - unc_convergence=0.05, - climb=False, - **kwargs, - ): - "Run the ML NEB with checking uncertainties if selected." - # Run the MLNEB fully without consider the uncertainty - if max_unc is False or max_unc is None: - images, converged = self.mlneb_opt_no_max_unc( - images, - fmax=fmax, - ml_steps=ml_steps, - climb=climb, - **kwargs, - ) - else: - # Stop the MLNEB if the uncertainty becomes too large - images, converged = self.mlneb_opt_max_unc( - images, - fmax=fmax, - ml_steps=ml_steps, - max_unc=max_unc, - unc_convergence=unc_convergence, - climb=climb, - **kwargs, - ) - # Activate climbing when the NEB is converged - if converged: - self.message_system("NEB on surrogate surface converged.") - if not climb and self.climb: - # Check the uncertainty is low enough to do CI-NEB if requested - if not self.use_low_unc_ci or ( - np.max(self.get_predictions(images)[1]) <= unc_convergence - ): - # Use CI from here if reuse_ci_path=True - if self.reuse_ci_path: - self.message_system( - "The restart of the climbing image path" - "is actived." - ) - self.climb_active = True - self.message_system( - "Starting NEB with climbing image on" - "surrogate surface." - ) - return self.mlneb_opt( - images, - fmax=fmax, - ml_steps=ml_steps, - max_unc=max_unc, - unc_convergence=unc_convergence, - climb=True, - ) - return images, converged - - def mlneb_opt_no_max_unc( - self, - images, - fmax=0.05, - ml_steps=750, - climb=False, - **kwargs, - ): - "Run the MLNEB fully without consider the uncertainty." - # Construct the NEB - neb = self.neb_method(images, climb=climb, **self.neb_kwargs) - with self.local_opt(neb, **self.local_opt_kwargs) as neb_opt: - neb_opt.run(fmax=fmax, steps=ml_steps) - if self.reuse_ci_path or not climb: - self.last_images_tmp = [image.copy() for image in images] - # Check if the MLNEB is converged - converged = neb_opt.converged() - return images, converged - - def mlneb_opt_max_unc( - self, - images, - fmax=0.05, - ml_steps=750, - max_unc=0.25, - unc_convergence=0.05, - climb=False, - **kwargs, - ): - "Run the MLNEB, but stop it if the uncertainty becomes too large." - # Construct the NEB - neb = self.neb_method(images, climb=climb, **self.neb_kwargs) - with self.local_opt(neb, **self.local_opt_kwargs) as neb_opt: - for i in range(1, ml_steps + 1): - # Run the NEB on the surrogate surface - if ase.__version__ >= "3.23": - neb_opt.run(fmax=fmax, steps=1) - else: - neb_opt.run(fmax=fmax, steps=i) - # Calculate energy and uncertainty - energy_path, unc_path = self.get_predictions(images) - # Get the maximum uncertainty of the path - max_unc_path = np.max(unc_path) - # Check if the uncertainty is too large - if max_unc_path >= max_unc: - self.message_system( - "NEB on surrogate surface stopped due " - "to high uncertainty." - ) - break - # Check if there is a problem with prediction - if np.isnan(energy_path).any(): - images = self.make_interpolation( - interpolation=self.last_images_tmp - ) - for image in images: - image.get_forces() - self.message_system( - "Warning: Stopped due to NaN value in prediction!" - ) - break - # Make backup of images before the next NEB step, - # which can be used as a restart interpolation - if self.reuse_ci_path or not climb: - if not self.check_path_unc or ( - max_unc_path <= unc_convergence - ): - self.last_images_tmp = [ - image.copy() for image in images - ] - - # Check if the NEB is converged on the predicted surface - if neb_opt.converged(): - break - # Check the number of steps - if neb_opt.get_number_of_steps() >= ml_steps: - break - # Check if the MLNEB is converged - converged = neb_opt.converged() - return images, converged - - def save_mlneb(self, images, **kwargs): - "Save the MLNEB result in the trajectory." - self.images = [] - for image in images: - image = copy_atoms(image) - self.images.append(image) - self.trajectory_neb.write(image) - return self.images - - def share_images(self, **kwargs): - "Share the images between all CPUs." - self.images = broadcast(self.images, root=0) - return - - def save_data(self, **kwargs): - "Save the training data to trajectory file." - self.mlcalc.save_data(trajectory=self.trainingset) - return - - def save_last_path( - self, - trajname, - images, - properties=["energy", "forces", "uncertainty"], - **kwargs, - ): - "Save the final MLNEB path in the trajectory file." - if self.rank == 0 and isinstance(trajname, str) and len(trajname): - with TrajectoryWriter( - trajname, mode="w", properties=properties - ) as trajectory_last: - for image in images: - trajectory_last.write(copy_atoms(image)) - return - - def get_barrier(self, forward=True, **kwargs): - "Get the forward or backward predicted potential energy barrier." - if forward: - return self.emax_ml - self.start_energy - return self.emax_ml - self.end_energy - - def message_system(self, message, obj=None, end="\n"): - "Print output once." - if self.full_output is True: - if self.rank == 0: - if obj is None: - print(message, end=end) - else: - print(message, obj, end=end) - return - - def check_convergence( - self, - fmax, - unc_convergence, - neb_converged, - **kwargs, - ): - """ - Check if the ML-NEB is converged to the final path with - low uncertainty. - """ - converged = False - if self.rank == 0: - # Check if NEB on the predicted energy is converged - if neb_converged: - # Check the force criterion is met if climbing image is used - if not self.climb or self.max_abs_forces <= fmax: - # Check the uncertainty criterion is met - if self.umax_ml <= unc_convergence: - # Check the true energy deviation match - # the uncertainty prediction - e_dif = np.abs(self.energy_pred - self.energy_true) - if e_dif <= 2.0 * unc_convergence: - converged = True - # Broadcast convergence statement - converged = broadcast(converged, root=0) - return converged - - def converged(self): - "Whether MLNEB is converged." - return self.converging - - def set_neb_method(self, neb_method=None, **kwargs): - """ - Set the NEB method. - - Parameters: - neb_method : class object or str - The NEB implemented class object used for the ML-NEB. - A string can be used to select: - - 'improvedtangentneb' (default) - - 'ewneb' - """ - if neb_method is None: - neb_method = ImprovedTangentNEB - elif isinstance(neb_method, str): - if neb_method.lower() == "improvedtangentneb": - neb_method = ImprovedTangentNEB - elif neb_method.lower() == "ewneb": - from .neb.ewneb import EWNEB - - neb_method = EWNEB - else: - raise Exception( - "The NEB method {} is not implemented.".format(neb_method) - ) - self.neb_method = neb_method - return self - - def set_mlcalc(self, mlcalc, start=None, save_memory=None, **kwargs): - """ - Setup the ML calculator. - - Parameters: - mlcalc : ML-calculator instance. - The ML-calculator instance used as surrogate surface. - A default ML-model is used if mlcalc is None. - start : Atoms object - Initial end-point of the NEB path. - save_memory : bool - Whether to only train the ML calculator and store - all objects on one CPU. - If save_memory==True then parallel optimization of - the hyperparameters can not be achived. - If save_memory==False no MPI object is used. - - Returns: - self: The object itself. - """ - if mlcalc is None: - from ..regression.gp.calculator.mlmodel import get_default_mlmodel - from ..regression.gp.calculator.mlcalc import MLCalculator - from ..regression.gp.means.max import Prior_max - from ..regression.gp.fingerprint.invdistances import InvDistances - - # Check if the start Atoms object is given - if start is None: - try: - start = self.start.copy() - except Exception: - raise Exception("The start Atoms object is not given.") - # Check if the save_memory is given - if save_memory is None: - try: - save_memory = self.save_memory - except Exception: - raise Exception("The save_memory is not given.") - - if len(start) > 1: - if start.pbc.any(): - fp = InvDistances( - reduce_dimensions=True, - use_derivatives=True, - periodic_softmax=True, - wrap=True, - ) - else: - fp = InvDistances( - reduce_dimensions=True, - use_derivatives=True, - periodic_softmax=False, - wrap=False, - ) - else: - fp = None - prior = Prior_max(add=1.0) - mlmodel = get_default_mlmodel( - model="tp", - prior=prior, - fp=fp, - baseline=None, - use_derivatives=True, - parallel=(not save_memory), - database_reduction=False, - ) - self.mlcalc = MLCalculator(mlmodel=mlmodel) - else: - self.mlcalc = mlcalc - return self - - def set_acq(self, acq=None, **kwargs): - """ - Select an acquisition function. - - Parameters: - acq : Acquisition class instance - The acquisition function object. - If None is given then UME is used. - - Returns: - self: The object itself. - """ - if acq is None: - from .acquisition import AcqUME - - self.acq = AcqUME(objective="max", unc_convergence=0.05) - else: - self.acq = acq.copy() - return self - - def set_local_opt(self, local_opt=None, local_opt_kwargs={}, **kwargs): - """ - Save local optimizer. - - Parameters: - local_opt : ASE local optimizer Object. - A local optimizer object from ASE. - If None is given then FIRE is used. - local_opt_kwargs : dict - Arguments used for the ASE local optimizer. - - Returns: - self: The object itself. - """ - local_opt_kwargs_default = dict() - if not self.full_output: - local_opt_kwargs_default["logfile"] = None - if local_opt is None: - from ase.optimize import FIRE - - local_opt = FIRE - local_opt_kwargs_default.update( - dict(dt=0.05, maxstep=0.2, a=1.0, astart=1.0, fa=0.999) - ) - self.local_opt = local_opt - local_opt_kwargs_default.update(local_opt_kwargs) - self.local_opt_kwargs = local_opt_kwargs_default.copy() - return self - - def save_mlcalc(self, filename="mlcalc.pkl", **kwargs): - """ - Save the ML calculator object to a file. - - Parameters: - filename : str - The name of the file where the object is saved. - - Returns: - self: The object itself. - """ - self.mlcalc.save_mlcalc(filename, **kwargs) - return self - - def print_cite(self): - msg = "\n" + "-" * 79 + "\n" - msg += "You are using MLNEB. Please cite: \n" - msg += "[1] J. A. Garrido Torres, M. H. Hansen, P. C. Jennings, " - msg += "J. R. Boes and T. Bligaard. Phys. Rev. Lett. 122, 156001. " - msg += "https://doi.org/10.1103/PhysRevLett.122.156001 \n" - msg += "[2] O. Koistinen, F. B. Dagbjartsdottir, V. Asgeirsson, " - msg += "A. Vehtari and H. Jonsson. J. Chem. Phys. 147, 152720. " - msg += "https://doi.org/10.1063/1.4986787 \n" - msg += "-" * 79 + "\n" - self.message_system(msg) - return - - def make_summary_table(self, step, **kwargs): - "Make the summary of the NEB process as table." - now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") - try: - len(self.print_neb_list) - except Exception: - self.print_neb_list = [ - "| Step | Time | Pred. barrier (-->) | " - "Pred. barrier (<--) | Max. uncert. | " - "Avg. uncert. | fmax |" - ] - msg = "|{0:6d}| ".format(step) - msg += "{} |".format(now) - msg += "{0:21f}|".format(self.get_barrier(forward=True)) - msg += "{0:21f}|".format(self.get_barrier(forward=False)) - msg += "{0:14f}|".format(self.umax_ml) - msg += "{0:14f}|".format(np.mean(self.umean_ml)) - msg += "{0:10f}|".format(self.max_abs_forces) - self.print_neb_list.append(msg) - msg = "\n".join(self.print_neb_list) - return msg - - def save_summary_table(self, **kwargs): - "Save the summary table in the .txt file." - if isinstance(self.tabletxt, str) and len(self.tabletxt): - with open(self.tabletxt, "w") as thefile: - msg = "\n".join(self.print_neb_list) - thefile.writelines(msg) - return - - def print_statement(self, step, **kwargs): - "Print the NEB process as a table" - msg = "" - if self.rank == 0: - msg = self.make_summary_table(step, **kwargs) - self.save_summary_table() - self.message_system(msg) - return msg From efa8050f570f379022cfa92dd84441814cdb230d Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Sun, 27 Oct 2024 19:56:07 +0100 Subject: [PATCH 011/194] Make an argument for the minimum needed data points --- catlearn/activelearning/activelearning.py | 15 +++++++++++++++ catlearn/activelearning/adsorption.py | 6 ++++++ catlearn/activelearning/local.py | 6 ++++++ catlearn/activelearning/mlgo.py | 6 ++++++ catlearn/activelearning/mlneb.py | 6 ++++++ 5 files changed, 39 insertions(+) diff --git a/catlearn/activelearning/activelearning.py b/catlearn/activelearning/activelearning.py index 4e3feee3..335d9ec0 100644 --- a/catlearn/activelearning/activelearning.py +++ b/catlearn/activelearning/activelearning.py @@ -30,6 +30,7 @@ def __init__( check_energy=True, check_fmax=True, n_evaluations_each=1, + min_data=2, trajectory="predicted.traj", trainingset="evaluated.traj", converged_trajectory="converged.traj", @@ -109,6 +110,9 @@ def __init__( than the initial interpolation and if so then replace it. n_evaluations_each: int Number of evaluations for each iteration. + min_data: int + The minimum number of data points in the training set before + the active learning can converge. trajectory: str or TrajectoryWriter instance Trajectory filename to store the predicted data. Or the TrajectoryWriter instance to store the predicted data. @@ -169,6 +173,7 @@ def __init__( check_energy=check_energy, check_fmax=check_fmax, n_evaluations_each=n_evaluations_each, + min_data=min_data, trajectory=trajectory, trainingset=trainingset, converged_trajectory=converged_trajectory, @@ -593,6 +598,7 @@ def update_arguments( check_energy=None, check_fmax=None, n_evaluations_each=None, + min_data=None, trajectory=None, trainingset=None, converged_trajectory=None, @@ -670,6 +676,9 @@ def update_arguments( than the initial interpolation and if so then replace it. n_evaluations_each: int Number of evaluations for each iteration. + min_data: int + The minimum number of data points in the training set before + the active learning can converge. trajectory: str or TrajectoryWriter instance Trajectory filename to store the predicted data. Or the TrajectoryWriter instance to store the predicted data. @@ -739,6 +748,8 @@ def update_arguments( self.n_evaluations_each = int(abs(n_evaluations_each)) if self.n_evaluations_each < 1: self.n_evaluations_each = 1 + if min_data is not None: + self.min_data = int(abs(min_data)) if trajectory is not None: self.trajectory = trajectory if trainingset is not None: @@ -1142,6 +1153,9 @@ def check_convergence(self, fmax, method_converged, **kwargs): # Check if the method converged if not method_converged: converged = False + # Check if the minimum number of data points is reached + if self.get_training_set_size() < self.min_data: + converged = False # Check the force criterion is met if it is requested if self.use_fmax_convergence and self.true_fmax > fmax: converged = False @@ -1346,6 +1360,7 @@ def get_arguments(self): check_energy=self.check_energy, check_fmax=self.check_fmax, n_evaluations_each=self.n_evaluations_each, + min_data=self.min_data, trajectory=self.trajectory, trainingset=self.trainingset, converged_trajectory=self.converged_trajectory, diff --git a/catlearn/activelearning/adsorption.py b/catlearn/activelearning/adsorption.py index b836dc75..9c2e5862 100644 --- a/catlearn/activelearning/adsorption.py +++ b/catlearn/activelearning/adsorption.py @@ -32,6 +32,7 @@ def __init__( check_energy=True, check_fmax=True, n_evaluations_each=1, + min_data=2, trajectory="predicted.traj", trainingset="evaluated.traj", converged_trajectory="converged.traj", @@ -125,6 +126,9 @@ def __init__( than the initial interpolation and if so then replace it. n_evaluations_each: int Number of evaluations for each candidate. + min_data: int + The minimum number of data points in the training set before + the active learning can converge. trajectory: str or TrajectoryWriter instance Trajectory filename to store the predicted data. Or the TrajectoryWriter instance to store the predicted data. @@ -184,6 +188,7 @@ def __init__( check_energy=check_energy, check_fmax=check_fmax, n_evaluations_each=n_evaluations_each, + min_data=min_data, trajectory=trajectory, trainingset=trainingset, converged_trajectory=converged_trajectory, @@ -336,6 +341,7 @@ def get_arguments(self): check_energy=self.check_energy, check_fmax=self.check_fmax, n_evaluations_each=self.n_evaluations_each, + min_data=self.min_data, trajectory=self.trajectory, trainingset=self.trainingset, converged_trajectory=self.converged_trajectory, diff --git a/catlearn/activelearning/local.py b/catlearn/activelearning/local.py index 1fec98d6..80c8570e 100644 --- a/catlearn/activelearning/local.py +++ b/catlearn/activelearning/local.py @@ -30,6 +30,7 @@ def __init__( check_energy=True, check_fmax=True, n_evaluations_each=1, + min_data=2, trajectory="predicted.traj", trainingset="evaluated.traj", converged_trajectory="converged.traj", @@ -113,6 +114,9 @@ def __init__( than the initial interpolation and if so then replace it. n_evaluations_each: int Number of evaluations for each structure. + min_data: int + The minimum number of data points in the training set before + the active learning can converge. trajectory: str or TrajectoryWriter instance Trajectory filename to store the predicted data. Or the TrajectoryWriter instance to store the predicted data. @@ -169,6 +173,7 @@ def __init__( check_energy=check_energy, check_fmax=check_fmax, n_evaluations_each=n_evaluations_each, + min_data=min_data, trajectory=trajectory, trainingset=trainingset, converged_trajectory=converged_trajectory, @@ -248,6 +253,7 @@ def get_arguments(self): check_energy=self.check_energy, check_fmax=self.check_fmax, n_evaluations_each=self.n_evaluations_each, + min_data=self.min_data, trajectory=self.trajectory, trainingset=self.trainingset, converged_trajectory=self.converged_trajectory, diff --git a/catlearn/activelearning/mlgo.py b/catlearn/activelearning/mlgo.py index 6e96d317..ac6cf7fd 100644 --- a/catlearn/activelearning/mlgo.py +++ b/catlearn/activelearning/mlgo.py @@ -36,6 +36,7 @@ def __init__( check_energy=True, check_fmax=True, n_evaluations_each=1, + min_data=2, trajectory="predicted.traj", trainingset="evaluated.traj", converged_trajectory="converged.traj", @@ -146,6 +147,9 @@ def __init__( than the initial interpolation and if so then replace it. n_evaluations_each: int The number of evaluations for each structure. + min_data: int + The minimum number of data points in the training set before + the active learning can converge. trajectory: str or TrajectoryWriter instance Trajectory filename to store the predicted data. Or the TrajectoryWriter instance to store the predicted data. @@ -196,6 +200,7 @@ def __init__( check_energy=check_energy, check_fmax=check_fmax, n_evaluations_each=n_evaluations_each, + min_data=min_data, trajectory=trajectory, trainingset=trainingset, converged_trajectory=converged_trajectory, @@ -365,6 +370,7 @@ def get_arguments(self): check_energy=self.check_energy, check_fmax=self.check_fmax, n_evaluations_each=self.n_evaluations_each, + min_data=self.min_data, trajectory=self.trajectory, trainingset=self.trainingset, converged_trajectory=self.converged_trajectory, diff --git a/catlearn/activelearning/mlneb.py b/catlearn/activelearning/mlneb.py index 9276a440..760db875 100644 --- a/catlearn/activelearning/mlneb.py +++ b/catlearn/activelearning/mlneb.py @@ -39,6 +39,7 @@ def __init__( check_energy=False, check_fmax=True, n_evaluations_each=1, + min_data=2, trajectory="predicted.traj", trainingset="evaluated.traj", converged_trajectory="converged.traj", @@ -144,6 +145,9 @@ def __init__( than the initial interpolation and if so then replace it. n_evaluations_each: int Number of evaluations for each iteration. + min_data: int + The minimum number of data points in the training set before + the active learning can converge. trajectory: str or TrajectoryWriter instance Trajectory filename to store the predicted data. Or the TrajectoryWriter instance to store the predicted data. @@ -209,6 +213,7 @@ def __init__( check_energy=check_energy, check_fmax=check_fmax, n_evaluations_each=n_evaluations_each, + min_data=min_data, trajectory=trajectory, trainingset=trainingset, converged_trajectory=converged_trajectory, @@ -355,6 +360,7 @@ def get_arguments(self): check_energy=self.check_energy, check_fmax=self.check_fmax, n_evaluations_each=self.n_evaluations_each, + min_data=self.min_data, trajectory=self.trajectory, trainingset=self.trainingset, converged_trajectory=self.converged_trajectory, From d081021b95b2f152fa47382a790ad39e7d277a9f Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Mon, 28 Oct 2024 21:05:33 +0100 Subject: [PATCH 012/194] Change README --- README.md | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index f532f96c..4c745884 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ $ pip install git+https://github.com/avishart/CatLearn.git@v.x.x.x The following code shows how to use MLNEB: ```python -from catlearn.optimize.mlneb import MLNEB +from catlearn.activelearning.mlneb import MLNEB from ase.io import read # Load endpoints @@ -40,13 +40,13 @@ mlneb = MLNEB( start=initial, end=final, ase_calc=calc, - interpolation="linear", + neb_interpolation="linear", n_images=15, - full_output=True, + unc_convergence=0.05, + verbose=True, ) mlneb.run( fmax=0.05, - unc_convergence=0.05, max_unc=0.30, steps=100, ml_steps=1000, @@ -56,7 +56,7 @@ mlneb.run( The following code shows how to use MLGO: ```python -from catlearn.optimize.mlgo import MLGO +from catlearn.activelearning.mlgo import MLGO from ase.io import read # Load the slab and the adsorbate @@ -79,16 +79,20 @@ bounds = np.array( ) # Initialize MLGO -mlgo = MLGO(slab, ads, ase_calc=calc, bounds=bounds, full_output=True) +mlgo = MLGO( + slab, + ads, + ase_calc=calc, + unc_convergence=0.02, + bounds=bounds, + chains=4, + verbose=True +) mlgo.run( fmax=0.05, - unc_convergence=0.02, max_unc=0.30, steps=100, ml_steps=1000, - ml_chains=8, - relax=True, - local_steps=500, ) ``` From 2a57c7492e2a6561edc354cb634391646c3ae665 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Mon, 28 Oct 2024 21:15:26 +0100 Subject: [PATCH 013/194] Save calculation results in info --- catlearn/activelearning/activelearning.py | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/catlearn/activelearning/activelearning.py b/catlearn/activelearning/activelearning.py index 335d9ec0..fcb059c9 100644 --- a/catlearn/activelearning/activelearning.py +++ b/catlearn/activelearning/activelearning.py @@ -950,17 +950,19 @@ def save_trajectory(self, trajectory, structures, mode="w", **kwargs): return self if isinstance(trajectory, str): with TrajectoryWriter(trajectory, mode=mode) as traj: - if isinstance(structures, list): - for struc in structures: - traj.write(struc) - else: - traj.write(structures) - elif isinstance(trajectory, TrajectoryWriter): - if isinstance(structures, list): + if not isinstance(structures, list): + structures = [structures] for struc in structures: - trajectory.write(struc) - else: - trajectory.write(structures) + if hasattr(struc.calc, "results"): + struc.info["results"] = struc.calc.results + traj.write(struc) + elif isinstance(trajectory, TrajectoryWriter): + if not isinstance(structures, list): + structures = [structures] + for struc in structures: + if hasattr(struc.calc, "results"): + struc.info["results"] = struc.calc.results + trajectory.write(struc) else: self.message_system( "The trajectory type is not supported. " From 0a77ff721c12c551e50d5084fc549d04fdf91f6e Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Mon, 28 Oct 2024 21:48:52 +0100 Subject: [PATCH 014/194] Update docstring and make None trajectory possible --- catlearn/activelearning/activelearning.py | 16 ++++++++++++---- catlearn/activelearning/adsorption.py | 4 ++-- catlearn/activelearning/local.py | 4 ++-- catlearn/activelearning/mlgo.py | 6 +++--- catlearn/activelearning/mlneb.py | 4 ++-- 5 files changed, 21 insertions(+), 13 deletions(-) diff --git a/catlearn/activelearning/activelearning.py b/catlearn/activelearning/activelearning.py index fcb059c9..1fb62800 100644 --- a/catlearn/activelearning/activelearning.py +++ b/catlearn/activelearning/activelearning.py @@ -77,12 +77,12 @@ def __init__( verbose: bool Whether to print on screen the full output (True) or not (False). - apply_constraint: boolean + apply_constraint: bool Whether to apply the constrains of the ASE Atoms instance to the calculated forces. By default (apply_constraint=True) forces are 0 for constrained atoms and directions. - force_consistent: boolean or None. + force_consistent: bool or None. Use force-consistent energy calls (as opposed to the energy extrapolated to 0 K). By default force_consistent=False. @@ -643,12 +643,12 @@ def update_arguments( verbose: bool Whether to print on screen the full output (True) or not (False). - apply_constraint: boolean + apply_constraint: bool Whether to apply the constrains of the ASE Atoms instance to the calculated forces. By default (apply_constraint=True) forces are 0 for constrained atoms and directions. - force_consistent: boolean or None. + force_consistent: bool or None. Use force-consistent energy calls (as opposed to the energy extrapolated to 0 K). By default force_consistent=False. @@ -752,12 +752,20 @@ def update_arguments( self.min_data = int(abs(min_data)) if trajectory is not None: self.trajectory = trajectory + elif not hasattr(self, "trajectory"): + self.trajectory = None if trainingset is not None: self.trainingset = trainingset + elif not hasattr(self, "trainingset"): + self.trainingset = None if converged_trajectory is not None: self.converged_trajectory = converged_trajectory + elif not hasattr(self, "converged_trajectory"): + self.converged_trajectory = None if tabletxt is not None: self.tabletxt = str(tabletxt) + elif not hasattr(self, "tabletxt"): + self.tabletxt = None # Set ASE calculator if ase_calc is not None: self.ase_calc = ase_calc diff --git a/catlearn/activelearning/adsorption.py b/catlearn/activelearning/adsorption.py index 9c2e5862..15d395c7 100644 --- a/catlearn/activelearning/adsorption.py +++ b/catlearn/activelearning/adsorption.py @@ -95,12 +95,12 @@ def __init__( verbose: bool Whether to print on screen the full output (True) or not (False). - apply_constraint: boolean + apply_constraint: bool Whether to apply the constrains of the ASE Atoms instance to the calculated forces. By default (apply_constraint=True) forces are 0 for constrained atoms and directions. - force_consistent: boolean or None. + force_consistent: bool or None. Use force-consistent energy calls (as opposed to the energy extrapolated to 0 K). By default force_consistent=False. diff --git a/catlearn/activelearning/local.py b/catlearn/activelearning/local.py index 80c8570e..d5b96176 100644 --- a/catlearn/activelearning/local.py +++ b/catlearn/activelearning/local.py @@ -81,12 +81,12 @@ def __init__( verbose: bool Whether to print on screen the full output (True) or not (False). - apply_constraint: boolean + apply_constraint: bool Whether to apply the constrains of the ASE Atoms instance to the calculated forces. By default (apply_constraint=True) forces are 0 for constrained atoms and directions. - force_consistent: boolean or None. + force_consistent: bool or None. Use force-consistent energy calls (as opposed to the energy extrapolated to 0 K). By default force_consistent=False. diff --git a/catlearn/activelearning/mlgo.py b/catlearn/activelearning/mlgo.py index ac6cf7fd..917f2e87 100644 --- a/catlearn/activelearning/mlgo.py +++ b/catlearn/activelearning/mlgo.py @@ -113,12 +113,12 @@ def __init__( verbose: bool Whether to print on screen the full output (True) or not (False). - apply_constraint: boolean + apply_constraint: bool Whether to apply the constrains of the ASE Atoms instance to the calculated forces. By default (apply_constraint=True) forces are 0 for constrained atoms and directions. - force_consistent: boolean or None. + force_consistent: bool or None. Use force-consistent energy calls (as opposed to the energy extrapolated to 0 K). By default force_consistent=False. @@ -238,7 +238,7 @@ def build_local_method( self.atoms = self.copy_atoms(atoms) self.local_opt = local_opt self.local_opt_kwargs = local_opt_kwargs - # Save boolean for reusing data in the mlcalc_local + # Save bool for reusing data in the mlcalc_local self.reuse_data_local = reuse_data_local # Build the local optimizer method self.local_method = LocalOptimizer( diff --git a/catlearn/activelearning/mlneb.py b/catlearn/activelearning/mlneb.py index 760db875..e445f12b 100644 --- a/catlearn/activelearning/mlneb.py +++ b/catlearn/activelearning/mlneb.py @@ -114,12 +114,12 @@ def __init__( verbose: bool Whether to print on screen the full output (True) or not (False). - apply_constraint: boolean + apply_constraint: bool Whether to apply the constrains of the ASE Atoms instance to the calculated forces. By default (apply_constraint=True) forces are 0 for constrained atoms and directions. - force_consistent: boolean or None. + force_consistent: bool or None. Use force-consistent energy calls (as opposed to the energy extrapolated to 0 K). By default force_consistent=False. From fb0c242c83783e021708a4b3a9ea7371b3dbd5db Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Mon, 28 Oct 2024 22:09:11 +0100 Subject: [PATCH 015/194] Change Bayesian optimzation to active learning --- catlearn/activelearning/activelearning.py | 42 +++++++++++------------ catlearn/activelearning/adsorption.py | 6 ++-- catlearn/activelearning/local.py | 6 ++-- catlearn/activelearning/mlgo.py | 14 ++++---- catlearn/activelearning/mlneb.py | 6 ++-- catlearn/optimizer/adsorption.py | 2 +- catlearn/optimizer/local.py | 2 +- catlearn/optimizer/localcineb.py | 2 +- catlearn/optimizer/localneb.py | 2 +- catlearn/optimizer/method.py | 13 ++++--- catlearn/optimizer/parallelopt.py | 2 +- catlearn/optimizer/sequential.py | 2 +- 12 files changed, 49 insertions(+), 50 deletions(-) diff --git a/catlearn/activelearning/activelearning.py b/catlearn/activelearning/activelearning.py index 1fb62800..ba8df371 100644 --- a/catlearn/activelearning/activelearning.py +++ b/catlearn/activelearning/activelearning.py @@ -41,7 +41,7 @@ def __init__( **kwargs, ): """ - A Bayesian optimizer that is used for accelerating quantum mechanincal + A active learner that is used for accelerating quantum mechanincal simulation methods with an active learning approach. Parameters: @@ -93,7 +93,7 @@ def __init__( Whether to use the maximum force as an convergence criterion. unc_convergence: float Maximum uncertainty for convergence in - the Bayesian optimization (in eV). + the active learning (in eV). use_method_unc_conv: bool Whether to use the unc_convergence as a convergence criterion in the optimization method. @@ -133,7 +133,7 @@ def __init__( The previous calculations must be fed as an Atoms list or Trajectory filename. restart: bool - Whether to restart the Bayesian optimization. + Whether to restart the active learning. comm: MPI communicator. The MPI communicator. """ @@ -183,7 +183,7 @@ def __init__( ) # Use previous calculations to train ML calculator self.use_prev_calculations(prev_calculations) - # Restart the bayesian optimization + # Restart the active learning self.restart_optimization(restart, prev_calculations) def run( @@ -216,14 +216,14 @@ def run( Returns: converged: bool - Whether the Bayesian optimization is converged. + Whether the active learning is converged. """ # Set the random seed if seed is not None: np.random.RandomState(seed) # Check if the method is converged if self.converged(): - self.message_system("Bayesian optimization is converged.") + self.message_system("active learning is converged.") return self.best_structures # Check if there are any training data self.extra_initial_data() @@ -231,7 +231,7 @@ def run( for step in range(1, steps + 1): # Check if the method is converged if self.converged(): - self.message_system("Bayesian optimization is converged.") + self.message_system("active learning is converged.") self.save_trajectory( self.converged_trajectory, self.best_structures, @@ -257,15 +257,15 @@ def run( fmax, method_converged, ) - # State if the Bayesian optimization did not converge + # State if the active learning did not converge if not self.converged(): - self.message_system("Bayesian optimization did not converge!") + self.message_system("active learning did not converge!") # Return and broadcast the best atoms self.broadcast_best_structures() return self.converged() def converged(self): - "Whether the Bayesian optimization is converged." + "Whether the active learning is converged." return self._converged def get_number_of_steps(self): @@ -276,7 +276,7 @@ def get_number_of_steps(self): def reset(self, **kwargs): """ - Reset the initial parameters for the Bayesian optimizer. + Reset the initial parameters for the active learner. """ # Set initial parameters self.steps = 0 @@ -365,7 +365,7 @@ def setup_mlcalc( calc_forces: bool Whether to calculate the forces for all energy predictions. bayesian: bool - Whether to use the Bayesian optimization calculator. + Whether to use the active learning calculator. kappa: float The scaling of the uncertainty relative to the energy. Default is 2.0. @@ -484,7 +484,7 @@ def setup_acq( if acq.objective != objective: raise Exception( "The objective of the acquisition function " - "does not match the Bayesian optimizer." + "does not match the active learner." ) return self @@ -659,7 +659,7 @@ def update_arguments( Whether to use the maximum force as an convergence criterion. unc_convergence: float Maximum uncertainty for convergence in - the Bayesian optimization (in eV). + the active learning (in eV). use_method_unc_conv: bool Whether to use the unc_convergence as a convergence criterion in the optimization method. @@ -699,7 +699,7 @@ def update_arguments( The previous calculations must be fed as an Atoms list or Trajectory filename. restart: bool - Whether to restart the Bayesian optimization. + Whether to restart the active learning. comm: MPI communicator. The MPI communicator. @@ -1069,7 +1069,7 @@ def update_candidate(self, candidate, dtol=1e-8, **kwargs): def extra_initial_data(self, **kwargs): """ - Get an initial structure for the Bayesian optimization + Get an initial structure for the active learning if the ML calculator does not have any training points. """ # Check if the training set is empty @@ -1213,7 +1213,7 @@ def copy_atoms(self, atoms): return copy_atoms(atoms) def get_objective_str(self, **kwargs): - "Get what the objective is for the Bayesian optimization." + "Get what the objective is for the active learning." if not self.is_minimization: return "max" return "min" @@ -1243,12 +1243,12 @@ def save_mlcalc(self, filename="mlcalc.pkl", **kwargs): def check_attributes(self, **kwargs): """ - Check that the Bayesian optimization and the method + Check that the active learning and the method agree upon the attributes. """ if self.parallel_run != self.method.parallel_run: raise Exception( - "Bayesian optimizer and Optimization method does " + "Active learner and Optimization method does " "not agree whether to run in parallel!" ) return self @@ -1319,7 +1319,7 @@ def restart_optimization( prev_calculations=None, **kwargs, ): - "Restart the Bayesian optimization." + "Restart the active learning." # Check if the optimization should be restarted if not restart: return self @@ -1339,7 +1339,7 @@ def restart_optimization( except Exception: self.message_system( "Warning: Restart is not possible! " - "Reinitalizing Bayesian optimization." + "Reinitalizing active learning." ) # Set the writing mode self.mode = "a" diff --git a/catlearn/activelearning/adsorption.py b/catlearn/activelearning/adsorption.py index 15d395c7..0d65a6c2 100644 --- a/catlearn/activelearning/adsorption.py +++ b/catlearn/activelearning/adsorption.py @@ -43,7 +43,7 @@ def __init__( **kwargs, ): """ - A Bayesian optimizer that is used for accelerating local optimization + A active learner that is used for accelerating local optimization of an atomic structure with an active learning approach. Parameters: @@ -111,7 +111,7 @@ def __init__( Whether to use the maximum force as an convergence criterion. unc_convergence: float Maximum uncertainty for convergence in - the Bayesian optimization (in eV). + the active learning (in eV). use_method_unc_conv: bool Whether to use the unc_convergence as a convergence criterion in the optimization method. @@ -149,7 +149,7 @@ def __init__( The previous calculations must be fed as an Atoms list or Trajectory filename. restart: bool - Whether to restart the Bayesian optimization. + Whether to restart the active learning. comm: MPI communicator. The MPI communicator. """ diff --git a/catlearn/activelearning/local.py b/catlearn/activelearning/local.py index d5b96176..e496682e 100644 --- a/catlearn/activelearning/local.py +++ b/catlearn/activelearning/local.py @@ -41,7 +41,7 @@ def __init__( **kwargs, ): """ - A Bayesian optimizer that is used for accelerating local optimization + A active learner that is used for accelerating local optimization of an atomic structure with an active learning approach. Parameters: @@ -97,7 +97,7 @@ def __init__( Whether to use the maximum force as an convergence criterion. unc_convergence: float Maximum uncertainty for convergence in - the Bayesian optimization (in eV). + the active learning (in eV). use_method_unc_conv: bool Whether to use the unc_convergence as a convergence criterion in the optimization method. @@ -137,7 +137,7 @@ def __init__( The previous calculations must be fed as an Atoms list or Trajectory filename. restart: bool - Whether to restart the Bayesian optimization. + Whether to restart the active learning. comm: MPI communicator. The MPI communicator. """ diff --git a/catlearn/activelearning/mlgo.py b/catlearn/activelearning/mlgo.py index 917f2e87..2ae5c4d7 100644 --- a/catlearn/activelearning/mlgo.py +++ b/catlearn/activelearning/mlgo.py @@ -47,7 +47,7 @@ def __init__( **kwargs, ): """ - A Bayesian optimizer that is used for accelerating local optimization + A active learner that is used for accelerating local optimization of an atomic structure with an active learning approach. Parameters: @@ -129,7 +129,7 @@ def __init__( Whether to use the maximum force as an convergence criterion. unc_convergence: float Maximum uncertainty for convergence in - the Bayesian optimization (in eV). + the active learning (in eV). use_method_unc_conv: bool Whether to use the unc_convergence as a convergence criterion in the optimization method. @@ -170,7 +170,7 @@ def __init__( The previous calculations must be fed as an Atoms list or Trajectory filename. restart: bool - Whether to restart the Bayesian optimization. + Whether to restart the active learning. comm: MPI communicator. The MPI communicator. """ @@ -268,7 +268,7 @@ def run( seed=None, **kwargs, ): - # Run the Bayesian optimization + # Run the active learning super().run( fmax=fmax, steps=steps, @@ -278,7 +278,7 @@ def run( seed=seed, **kwargs, ) - # Check if the Bayesian optimization is converged + # Check if the active learning is converged if not self.converged(): return self.converged() # Switch to the local optimization @@ -287,7 +287,7 @@ def run( steps = steps - self.get_number_of_steps() if steps <= 0: return self.converged() - # Run the local Bayesian optimization + # Run the local active learning super().run( fmax=fmax, steps=steps, @@ -304,7 +304,7 @@ def switch_mlcalcs(self, **kwargs): Switch the ML calculator used for the local optimization. The data is reused, but without the constraints from Adsorption. """ - # Get the data from the Bayesian optimization + # Get the data from the active learning data = self.get_data_atoms() if not self.reuse_data_local: data = data[-1:] diff --git a/catlearn/activelearning/mlneb.py b/catlearn/activelearning/mlneb.py index e445f12b..3527ad0f 100644 --- a/catlearn/activelearning/mlneb.py +++ b/catlearn/activelearning/mlneb.py @@ -50,7 +50,7 @@ def __init__( **kwargs, ): """ - A Bayesian optimizer that is used for accelerating nudged elastic band + A active learner that is used for accelerating nudged elastic band (NEB) optimization with an active learning approach. Parameters: @@ -128,7 +128,7 @@ def __init__( It makes the path converge tighter on surrogate surface. unc_convergence: float Maximum uncertainty for convergence in - the Bayesian optimization (in eV). + the active learning (in eV). use_method_unc_conv: bool Whether to use the unc_convergence as a convergence criterion in the optimization method. @@ -168,7 +168,7 @@ def __init__( The previous calculations must be fed as an Atoms list or Trajectory filename. restart: bool - Whether to restart the Bayesian optimization. + Whether to restart the active learning. comm: MPI communicator. The MPI communicator. """ diff --git a/catlearn/optimizer/adsorption.py b/catlearn/optimizer/adsorption.py index 8e89e53e..85f50e45 100644 --- a/catlearn/optimizer/adsorption.py +++ b/catlearn/optimizer/adsorption.py @@ -25,7 +25,7 @@ def __init__( A single structure will be created and optimized. Simulated annealing will be used to global optimize the structure. The AdsorptionOptimizer is applicable to be used with - Bayesian optimization. + active learning. Parameters: slab: Atoms instance diff --git a/catlearn/optimizer/local.py b/catlearn/optimizer/local.py index caed089e..b920184e 100644 --- a/catlearn/optimizer/local.py +++ b/catlearn/optimizer/local.py @@ -19,7 +19,7 @@ def __init__( """ The LocalOptimizer is used to run a local optimization on a given structure. - The LocalOptimizer is applicable to be used with Bayesian optimization. + The LocalOptimizer is applicable to be used with active learning. Parameters: atoms: Atoms instance diff --git a/catlearn/optimizer/localcineb.py b/catlearn/optimizer/localcineb.py index a8a51cfb..8e341a5e 100644 --- a/catlearn/optimizer/localcineb.py +++ b/catlearn/optimizer/localcineb.py @@ -27,7 +27,7 @@ def __init__( ): """ The LocalNEB is used to run a local optimization of NEB. - The LocalNEB is applicable to be used with Bayesian optimization. + The LocalNEB is applicable to be used with active learning. Parameters: start : Atoms instance or ASE Trajectory file. diff --git a/catlearn/optimizer/localneb.py b/catlearn/optimizer/localneb.py index 05a53867..e64c7004 100644 --- a/catlearn/optimizer/localneb.py +++ b/catlearn/optimizer/localneb.py @@ -17,7 +17,7 @@ def __init__( ): """ The LocalNEB is used to run a local optimization of NEB. - The LocalNEB is applicable to be used with Bayesian optimization. + The LocalNEB is applicable to be used with active learning. Parameters: neb: NEB instance diff --git a/catlearn/optimizer/method.py b/catlearn/optimizer/method.py index 06be1691..25565e37 100644 --- a/catlearn/optimizer/method.py +++ b/catlearn/optimizer/method.py @@ -17,8 +17,7 @@ def __init__( The OptimizerMethod class is a base class for all optimization methods. The OptimizerMethod is used to run an optimization on a given optimizable. - The OptimizerMethod is applicable to be used with Bayesian - optimization. + The OptimizerMethod is applicable to be used with active learning. Parameters: optimizable: optimizable instance @@ -85,7 +84,7 @@ def get_structures(self): def get_candidates(self): """ Get the candidate structure instances. - It is used for Bayesian optimization. + It is used for active learning. """ return [self.optimizable] @@ -206,7 +205,7 @@ def get_fmax(self, per_candidate=False, **kwargs): def get_uncertainty(self, per_candidate=False, **kwargs): """ Get the uncertainty of the optimizable. - It is used for Bayesian optimization. + It is used for active learning. Parameters: per_candidate: bool @@ -358,7 +357,7 @@ def get_properties( def is_within_dtrust(self, per_candidate=False, dtrust=2.0, **kwargs): """ Get whether the structures are within a trust distance to the database. - It is used for Bayesian optimization. + It is used for active learning. Parameters: per_candidate: bool @@ -454,7 +453,7 @@ def run_max_unc(self, **kwargs): Run the optimization with a maximum uncertainty. The uncertainty is checked at each optimization step if requested. The trust distance is checked at each optimization step if requested. - It is used for Bayesian optimization. + It is used for active learning. """ raise NotImplementedError("The run_max_unc method is not implemented") @@ -468,7 +467,7 @@ def check_convergence( ): """ Check if the optimization is converged also in terms of uncertainty. - The uncertainty is used for Bayesian optimization. + The uncertainty is used for active learning. Parameters: converged: bool diff --git a/catlearn/optimizer/parallelopt.py b/catlearn/optimizer/parallelopt.py index 6b5da03f..42a6bff9 100644 --- a/catlearn/optimizer/parallelopt.py +++ b/catlearn/optimizer/parallelopt.py @@ -16,7 +16,7 @@ def __init__( """ The ParallelOptimizer is used to run an optimization in parallel. The ParallelOptimizer is applicable to be used with - Bayesian optimization. + active learning. Parameters: method: OptimizerMethod instance diff --git a/catlearn/optimizer/sequential.py b/catlearn/optimizer/sequential.py index 51510225..5ae87c85 100644 --- a/catlearn/optimizer/sequential.py +++ b/catlearn/optimizer/sequential.py @@ -16,7 +16,7 @@ def __init__( The SequentialOptimizer is used to run multiple optimizations in sequence for a given structure. The SequentialOptimizer is applicable to be used with - Bayesian optimization. + active learning. Parameters: methods: List of OptimizerMethod objects From ce9064bf47af9fadf4a4034639b4f909420eac37 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Wed, 30 Oct 2024 14:44:54 +0100 Subject: [PATCH 016/194] Make None as default possible --- catlearn/activelearning/acquisition.py | 2 ++ catlearn/regression/gp/calculator/mlmodel.py | 14 ++++++-------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/catlearn/activelearning/acquisition.py b/catlearn/activelearning/acquisition.py index a06f729c..7b741187 100644 --- a/catlearn/activelearning/acquisition.py +++ b/catlearn/activelearning/acquisition.py @@ -377,6 +377,8 @@ def update_arguments(self, objective=None, ebest=None, **kwargs): self.objective = objective.lower() if ebest is not None: self.ebest = ebest + elif not hasattr(self, "ebest"): + self.ebest = None return self def get_arguments(self): diff --git a/catlearn/regression/gp/calculator/mlmodel.py b/catlearn/regression/gp/calculator/mlmodel.py index 8e5b7c61..c075ff82 100644 --- a/catlearn/regression/gp/calculator/mlmodel.py +++ b/catlearn/regression/gp/calculator/mlmodel.py @@ -42,14 +42,6 @@ def __init__( # Make default database if it is not given if database is None: database = get_default_database() - # Use default baseline if it is not given - if baseline is None: - self.baseline = None - # Use default pdis if it is not given - if pdis is None: - self.pdis = None - # Make default hyperparameters if it is not given - self.hp = None # Set the arguments self.update_arguments( model=model, @@ -268,12 +260,18 @@ def update_arguments( self.database = database.copy() if baseline is not None: self.baseline = baseline.copy() + elif not hasattr(self, "baseline"): + self.baseline = None if optimize is not None: self.optimize = optimize if hp is not None: self.hp = hp.copy() + elif not hasattr(self, "hp"): + self.hp = None if pdis is not None: self.pdis = pdis.copy() + elif not hasattr(self, "pdis"): + self.pdis = None if verbose is not None: self.verbose = verbose # Check if the baseline is used From a210b241d38170af34ffbeae4398a564bcf9eabe Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Fri, 1 Nov 2024 10:31:44 +0100 Subject: [PATCH 017/194] Ensure properties are saved in structures --- catlearn/optimizer/adsorption.py | 2 +- catlearn/optimizer/localneb.py | 13 ++++++++++--- catlearn/optimizer/method.py | 18 ++++++++++++++---- catlearn/optimizer/parallelopt.py | 10 +++++----- catlearn/optimizer/sequential.py | 6 ++++-- 5 files changed, 34 insertions(+), 15 deletions(-) diff --git a/catlearn/optimizer/adsorption.py b/catlearn/optimizer/adsorption.py index 85f50e45..3b5cf64d 100644 --- a/catlearn/optimizer/adsorption.py +++ b/catlearn/optimizer/adsorption.py @@ -64,7 +64,7 @@ def __init__( **kwargs, ) - def get_structures(self): + def get_structures(self, get_all=True, **kwargs): structures = self.copy_atoms(self.optimizable) structures.set_constraint(self.constraints_org) return structures diff --git a/catlearn/optimizer/localneb.py b/catlearn/optimizer/localneb.py index e64c7004..d3183163 100644 --- a/catlearn/optimizer/localneb.py +++ b/catlearn/optimizer/localneb.py @@ -55,10 +55,17 @@ def update_optimizable(self, structures, **kwargs): self.reset_optimization() return self - def get_structures(self): - return [self.copy_atoms(image) for image in self.optimizable.images] + def get_structures(self, get_all=True, **kwargs): + # Get only the first image + if not get_all: + return self.copy_atoms(self.optimizable.images[0]) + # Get all the images + structures = [ + self.copy_atoms(image) for image in self.optimizable.images + ] + return structures - def get_candidates(self): + def get_candidates(self, **kwargs): return self.optimizable.images[1:-1] def set_calculator(self, calculator, copy_calc=False, **kwargs): diff --git a/catlearn/optimizer/method.py b/catlearn/optimizer/method.py index 25565e37..68eae1e9 100644 --- a/catlearn/optimizer/method.py +++ b/catlearn/optimizer/method.py @@ -61,7 +61,7 @@ def update_optimizable(self, structures, **kwargs): self.reset_optimization() return self - def get_optimizable(self): + def get_optimizable(self, **kwargs): """ Get the optimizable that are considered for the optimizer. @@ -71,17 +71,22 @@ def get_optimizable(self): """ return self.optimizable - def get_structures(self): + def get_structures(self, get_all=True, **kwargs): """ Get the structures that optimizable instance is dependent on. + Parameters: + get_all: bool + If True, all structures are returned. + Else, only the first structure is returned + Returns: structures: Atoms instance or list of Atoms instances The structures that the optimizable instance is dependent on. """ return self.copy_atoms(self.optimizable) - def get_candidates(self): + def get_candidates(self, **kwargs): """ Get the candidate structure instances. It is used for active learning. @@ -319,7 +324,6 @@ def get_properties( Returns: dict: The requested properties. """ - if per_candidate: results = {name: [] for name in properties} for atoms in self.get_candidates(): @@ -542,6 +546,12 @@ def update_arguments( def copy_atoms(self, atoms): "Copy an atoms instance." + # Enforce the correct results in the calculator + if atoms.calc is not None: + if hasattr(atoms.calc, "results"): + if len(atoms.calc.results): + atoms.get_forces() + # Save the structure with saved properties return copy_atoms(atoms) def message(self, message): diff --git a/catlearn/optimizer/parallelopt.py b/catlearn/optimizer/parallelopt.py index 42a6bff9..573ba17a 100644 --- a/catlearn/optimizer/parallelopt.py +++ b/catlearn/optimizer/parallelopt.py @@ -53,13 +53,13 @@ def update_optimizable(self, structures, **kwargs): self.reset_optimization() return self - def get_optimizable(self): - return self.method.get_optimizable() + def get_optimizable(self, **kwargs): + return self.method.get_optimizable(**kwargs) - def get_structures(self): - return self.method.get_structures() + def get_structures(self, get_all=True, **kwargs): + return self.method.get_structures(get_all=get_all, **kwargs) - def get_candidates(self): + def get_candidates(self, **kwargs): return self.candidates def run( diff --git a/catlearn/optimizer/sequential.py b/catlearn/optimizer/sequential.py index 5ae87c85..5d77252a 100644 --- a/catlearn/optimizer/sequential.py +++ b/catlearn/optimizer/sequential.py @@ -52,12 +52,14 @@ def update_optimizable(self, structures, **kwargs): def get_optimizable(self): return self.optimizable - def get_structures(self): + def get_structures(self, get_all=True, **kwargs): if isinstance(self.structures, list): + if not get_all: + return self.copy_atoms(self.structures[0]) return [self.copy_atoms(struc) for struc in self.structures] return self.copy_atoms(self.structures) - def get_candidates(self): + def get_candidates(self, **kwargs): return self.candidates def run( From 667fb50b7d567edc6017cca7a3cbc609c34f3903 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Fri, 1 Nov 2024 10:33:39 +0100 Subject: [PATCH 018/194] Save results in info and use get_all in method --- catlearn/activelearning/activelearning.py | 28 +++++++++++++++-------- catlearn/activelearning/adsorption.py | 5 ++++ catlearn/activelearning/local.py | 13 +++++++++-- catlearn/activelearning/mlgo.py | 5 ++++ catlearn/activelearning/mlneb.py | 5 ++++ 5 files changed, 44 insertions(+), 12 deletions(-) diff --git a/catlearn/activelearning/activelearning.py b/catlearn/activelearning/activelearning.py index ba8df371..cea8a103 100644 --- a/catlearn/activelearning/activelearning.py +++ b/catlearn/activelearning/activelearning.py @@ -31,6 +31,7 @@ def __init__( check_fmax=True, n_evaluations_each=1, min_data=2, + save_properties_traj=True, trajectory="predicted.traj", trainingset="evaluated.traj", converged_trajectory="converged.traj", @@ -113,6 +114,8 @@ def __init__( min_data: int The minimum number of data points in the training set before the active learning can converge. + save_properties_traj: bool + Whether to save the calculated properties to the trajectory. trajectory: str or TrajectoryWriter instance Trajectory filename to store the predicted data. Or the TrajectoryWriter instance to store the predicted data. @@ -174,6 +177,7 @@ def __init__( check_fmax=check_fmax, n_evaluations_each=n_evaluations_each, min_data=min_data, + save_properties_traj=save_properties_traj, trajectory=trajectory, trainingset=trainingset, converged_trajectory=converged_trajectory, @@ -491,6 +495,7 @@ def setup_acq( def get_structures( self, get_all=True, + **kwargs, ): """ Get the list of ASE Atoms object from the method. @@ -502,12 +507,7 @@ def get_structures( Returns: Atoms object or list of Atoms objects. """ - structures = self.method.get_structures() - if isinstance(structures, list): - if get_all: - return structures - return structures[0] - return structures + return self.method.get_structures(get_all=get_all, **kwargs) def get_candidates(self): """ @@ -599,6 +599,7 @@ def update_arguments( check_fmax=None, n_evaluations_each=None, min_data=None, + save_properties_traj=None, trajectory=None, trainingset=None, converged_trajectory=None, @@ -679,6 +680,8 @@ def update_arguments( min_data: int The minimum number of data points in the training set before the active learning can converge. + save_properties_traj: bool + Whether to save the calculated properties to the trajectory. trajectory: str or TrajectoryWriter instance Trajectory filename to store the predicted data. Or the TrajectoryWriter instance to store the predicted data. @@ -750,6 +753,8 @@ def update_arguments( self.n_evaluations_each = 1 if min_data is not None: self.min_data = int(abs(min_data)) + if save_properties_traj is not None: + self.save_properties_traj = save_properties_traj if trajectory is not None: self.trajectory = trajectory elif not hasattr(self, "trajectory"): @@ -961,15 +966,17 @@ def save_trajectory(self, trajectory, structures, mode="w", **kwargs): if not isinstance(structures, list): structures = [structures] for struc in structures: - if hasattr(struc.calc, "results"): - struc.info["results"] = struc.calc.results + if self.save_properties_traj: + if hasattr(struc.calc, "results"): + struc.info["results"] = struc.calc.results traj.write(struc) elif isinstance(trajectory, TrajectoryWriter): if not isinstance(structures, list): structures = [structures] for struc in structures: - if hasattr(struc.calc, "results"): - struc.info["results"] = struc.calc.results + if self.save_properties_traj: + if hasattr(struc.calc, "results"): + struc.info["results"] = struc.calc.results trajectory.write(struc) else: self.message_system( @@ -1371,6 +1378,7 @@ def get_arguments(self): check_fmax=self.check_fmax, n_evaluations_each=self.n_evaluations_each, min_data=self.min_data, + save_properties_traj=self.save_properties_traj, trajectory=self.trajectory, trainingset=self.trainingset, converged_trajectory=self.converged_trajectory, diff --git a/catlearn/activelearning/adsorption.py b/catlearn/activelearning/adsorption.py index 0d65a6c2..10ecd57f 100644 --- a/catlearn/activelearning/adsorption.py +++ b/catlearn/activelearning/adsorption.py @@ -33,6 +33,7 @@ def __init__( check_fmax=True, n_evaluations_each=1, min_data=2, + save_properties_traj=True, trajectory="predicted.traj", trainingset="evaluated.traj", converged_trajectory="converged.traj", @@ -129,6 +130,8 @@ def __init__( min_data: int The minimum number of data points in the training set before the active learning can converge. + save_properties_traj: bool + Whether to save the calculated properties to the trajectory. trajectory: str or TrajectoryWriter instance Trajectory filename to store the predicted data. Or the TrajectoryWriter instance to store the predicted data. @@ -189,6 +192,7 @@ def __init__( check_fmax=check_fmax, n_evaluations_each=n_evaluations_each, min_data=min_data, + save_properties_traj=save_properties_traj, trajectory=trajectory, trainingset=trainingset, converged_trajectory=converged_trajectory, @@ -342,6 +346,7 @@ def get_arguments(self): check_fmax=self.check_fmax, n_evaluations_each=self.n_evaluations_each, min_data=self.min_data, + save_properties_traj=self.save_properties_traj, trajectory=self.trajectory, trainingset=self.trainingset, converged_trajectory=self.converged_trajectory, diff --git a/catlearn/activelearning/local.py b/catlearn/activelearning/local.py index e496682e..be696c76 100644 --- a/catlearn/activelearning/local.py +++ b/catlearn/activelearning/local.py @@ -1,5 +1,6 @@ from ase.optimize import FIRE from ase.parallel import world +import numpy as np from .activelearning import ActiveLearning from ..optimizer import LocalOptimizer @@ -31,6 +32,7 @@ def __init__( check_fmax=True, n_evaluations_each=1, min_data=2, + save_properties_traj=True, trajectory="predicted.traj", trainingset="evaluated.traj", converged_trajectory="converged.traj", @@ -117,6 +119,8 @@ def __init__( min_data: int The minimum number of data points in the training set before the active learning can converge. + save_properties_traj: bool + Whether to save the calculated properties to the trajectory. trajectory: str or TrajectoryWriter instance Trajectory filename to store the predicted data. Or the TrajectoryWriter instance to store the predicted data. @@ -174,6 +178,7 @@ def __init__( check_fmax=check_fmax, n_evaluations_each=n_evaluations_each, min_data=min_data, + save_properties_traj=save_properties_traj, trajectory=trajectory, trainingset=trainingset, converged_trajectory=converged_trajectory, @@ -218,8 +223,11 @@ def extra_initial_data(self, **kwargs): if self.atoms.calc is not None: results = self.atoms.calc.results if "energy" in results and "forces" in results: - self.use_prev_calculations([self.atoms]) - return self + pos0 = self.atoms.get_positions() + pos1 = self.atoms.calc.atoms.get_positions() + if np.linalg.norm(pos0 - pos1) < 1e-8: + self.use_prev_calculations([self.atoms]) + return self # Calculate the initial structure self.evaluate(self.get_structures(get_all=False)) # Print summary table @@ -254,6 +262,7 @@ def get_arguments(self): check_fmax=self.check_fmax, n_evaluations_each=self.n_evaluations_each, min_data=self.min_data, + save_properties_traj=self.save_properties_traj, trajectory=self.trajectory, trainingset=self.trainingset, converged_trajectory=self.converged_trajectory, diff --git a/catlearn/activelearning/mlgo.py b/catlearn/activelearning/mlgo.py index 2ae5c4d7..545609f8 100644 --- a/catlearn/activelearning/mlgo.py +++ b/catlearn/activelearning/mlgo.py @@ -37,6 +37,7 @@ def __init__( check_fmax=True, n_evaluations_each=1, min_data=2, + save_properties_traj=True, trajectory="predicted.traj", trainingset="evaluated.traj", converged_trajectory="converged.traj", @@ -150,6 +151,8 @@ def __init__( min_data: int The minimum number of data points in the training set before the active learning can converge. + save_properties_traj: bool + Whether to save the calculated properties to the trajectory. trajectory: str or TrajectoryWriter instance Trajectory filename to store the predicted data. Or the TrajectoryWriter instance to store the predicted data. @@ -201,6 +204,7 @@ def __init__( check_fmax=check_fmax, n_evaluations_each=n_evaluations_each, min_data=min_data, + save_properties_traj=save_properties_traj, trajectory=trajectory, trainingset=trainingset, converged_trajectory=converged_trajectory, @@ -371,6 +375,7 @@ def get_arguments(self): check_fmax=self.check_fmax, n_evaluations_each=self.n_evaluations_each, min_data=self.min_data, + save_properties_traj=self.save_properties_traj, trajectory=self.trajectory, trainingset=self.trainingset, converged_trajectory=self.converged_trajectory, diff --git a/catlearn/activelearning/mlneb.py b/catlearn/activelearning/mlneb.py index 3527ad0f..e403ff76 100644 --- a/catlearn/activelearning/mlneb.py +++ b/catlearn/activelearning/mlneb.py @@ -40,6 +40,7 @@ def __init__( check_fmax=True, n_evaluations_each=1, min_data=2, + save_properties_traj=True, trajectory="predicted.traj", trainingset="evaluated.traj", converged_trajectory="converged.traj", @@ -148,6 +149,8 @@ def __init__( min_data: int The minimum number of data points in the training set before the active learning can converge. + save_properties_traj: bool + Whether to save the calculated properties to the trajectory. trajectory: str or TrajectoryWriter instance Trajectory filename to store the predicted data. Or the TrajectoryWriter instance to store the predicted data. @@ -214,6 +217,7 @@ def __init__( check_fmax=check_fmax, n_evaluations_each=n_evaluations_each, min_data=min_data, + save_properties_traj=save_properties_traj, trajectory=trajectory, trainingset=trainingset, converged_trajectory=converged_trajectory, @@ -361,6 +365,7 @@ def get_arguments(self): check_fmax=self.check_fmax, n_evaluations_each=self.n_evaluations_each, min_data=self.min_data, + save_properties_traj=self.save_properties_traj, trajectory=self.trajectory, trainingset=self.trainingset, converged_trajectory=self.converged_trajectory, From 0fdf2b4636f5f2fdebe43b2dadfdec8dc35f575b Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Fri, 1 Nov 2024 10:33:53 +0100 Subject: [PATCH 019/194] Have matplotlib as optional package --- setup.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/setup.py b/setup.py index b33c7c7a..0591e9de 100644 --- a/setup.py +++ b/setup.py @@ -16,7 +16,9 @@ packages=find_packages(), python_requires=">=3.8", install_requires=["numpy>=1.20.3", "scipy>=1.8.0", "ase>=3.22.1"], - extras_require={"optional": ["mpi4py>=3.0.3", "dscribe>=2.1"]}, + extras_require={ + "optional": ["mpi4py>=3.0.3", "dscribe>=2.1", "matplotlib>=3.8"] + }, test_suite="tests", tests_require=["unittest"], keywords=["python", "gaussian process", "machine learning", "regression"], From 64a900cf3534f2bfbd941523e1774e60a386bc05 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Fri, 1 Nov 2024 10:34:18 +0100 Subject: [PATCH 020/194] Make useful plots --- catlearn/tools/__init__.py | 3 + catlearn/tools/plot.py | 298 +++++++++++++++++++++++++++++++++++++ 2 files changed, 301 insertions(+) create mode 100644 catlearn/tools/__init__.py create mode 100644 catlearn/tools/plot.py diff --git a/catlearn/tools/__init__.py b/catlearn/tools/__init__.py new file mode 100644 index 00000000..2e6d7b47 --- /dev/null +++ b/catlearn/tools/__init__.py @@ -0,0 +1,3 @@ +from .plot import plot_minimize, plot_neb, plot_all_neb + +__all__ = ["plot_minimize", "plot_neb", "plot_all_neb"] diff --git a/catlearn/tools/plot.py b/catlearn/tools/plot.py new file mode 100644 index 00000000..00805586 --- /dev/null +++ b/catlearn/tools/plot.py @@ -0,0 +1,298 @@ +import numpy as np +import matplotlib.pyplot as plt +import matplotlib.cm as cm +from ase.io import read +from ..structures.neb import ImprovedTangentNEB + + +def plot_minimize( + pred_atoms, + eval_atoms, + use_uncertainty=True, + ax=None, + loc=0, + **kwargs, +): + """ + Plot the predicted and evaluated atoms in a 2D plot. + + Parameters: + pred_atoms: ASE atoms instance + The predicted atoms. + eval_atoms: ASE atoms instance + The evaluated atoms. + use_uncertainty: bool + If True, use the uncertainty of the atoms. + ax: matplotlib axis instance + The axis to plot the NEB images. + loc: int + The location of the legend. + + Returns: + ax: matplotlib axis instance + """ + # Make figure if it is not given + if ax is None: + _, ax = plt.subplots() + # Get the energies of the predicted atoms + if isinstance(pred_atoms, str): + pred_atoms = read(pred_atoms, ":") + pred_energies = [atoms.get_potential_energy() for atoms in pred_atoms] + # Get the energies of the evaluated atoms + if isinstance(eval_atoms, str): + eval_atoms = read(eval_atoms, ":") + eval_energies = [atoms.get_potential_energy() for atoms in eval_atoms] + # Get the reference energy + e_ref = eval_energies[0] + # Truncate the evaluated energies + eval_energies = np.array(eval_energies)[-len(pred_energies) :] + # Get the uncertainties of the atoms if requested + uncertainties = None + if use_uncertainty: + if "results" in pred_atoms[0].info: + if "uncertainty" in pred_atoms[0].info["results"]: + uncertainties = [ + atoms.info["results"]["uncertainty"] + for atoms in pred_atoms + ] + uncertainties = np.array(uncertainties) + # Make the energies relative to the first energy + pred_energies = np.array(pred_energies) - e_ref + eval_energies = np.array(eval_energies) - e_ref + # Make x values + x_values = np.arange(1, len(pred_energies) + 1) + # Plot the energies of the atoms + ax.plot(x_values, pred_energies, "o-", color="red", label="Predicted") + ax.plot(x_values, eval_energies, "o-", color="black", label="Evaluated") + # Plot the uncertainties of the atoms if requested + if uncertainties is not None: + ax.fill_between( + x_values, + pred_energies - uncertainties, + pred_energies + uncertainties, + color="red", + alpha=0.3, + ) + ax.fill_between( + x_values, + pred_energies - 2.0 * uncertainties, + pred_energies + 2.0 * uncertainties, + color="red", + alpha=0.2, + ) + # Make labels + ax.set_xlabel("Iteration") + ax.set_ylabel("Potential energy / [eV]") + ax.legend(loc=loc) + return ax + + +def plot_neb( + images, + neb_method=ImprovedTangentNEB, + neb_kwargs={}, + climb=True, + use_uncertainty=True, + use_projection=True, + ax=None, + **kwargs, +): + """ + Plot the NEB images in a 2D plot. + + Parameters: + images: list of ASE atoms instances + The images of the NEB calculation. + neb_method: class + The NEB method to use. + neb_kwargs: dict + The keyword arguments for the NEB method. + climb: bool + If True, use the climbing image method. + use_uncertainty: bool + If True, use the uncertainty of the images. + use_projection: bool + If True, use the projection of the derivatives on the tangent. + ax: matplotlib axis instance + The axis to plot the NEB images. + + Returns: + ax: matplotlib axis instance + """ + # Default values for the neb method + used_neb_kwargs = dict( + k=3.0, + remove_rotation_and_translation=False, + mic=True, + ) + used_neb_kwargs.update(neb_kwargs) + # Initialize the NEB method + neb = neb_method(images, climb=climb, **used_neb_kwargs) + # Get the energies of the images + energies = [image.get_potential_energy() for image in images] + if "results" in images[1].info: + if "predicted energy" in images[1].info["results"]: + for i, image in enumerate(images[1:-1]): + energies[i + 1] = image.info["results"]["predicted energy"] + energies = np.array(energies) - energies[0] + # Get the forces + forces = [image.get_forces() for image in images] + forces = np.array(forces) + if "results" in images[1].info: + if "predicted forces" in images[1].info["results"]: + for i, image in enumerate(images[1:-1]): + forces[i + 1] = image.info["results"][ + "predicted forces" + ].copy() + # Get the uncertainties of the images if requested + uncertainties = None + if use_uncertainty: + if "results" in images[1].info: + if "uncertainty" in images[1].info["results"]: + uncertainties = [ + image.info["results"]["uncertainty"] + for image in images[1:-1] + ] + uncertainties = np.concatenate([[0.0], uncertainties, [0.0]]) + # Get the distances between the images + pos_p, pos_m = neb.get_position_diff() + distances = np.linalg.norm(pos_p, axis=(1, 2)) + distances = np.concatenate([[0.0], [np.linalg.norm(pos_m[0])], distances]) + distances = np.cumsum(distances) + # Use projection of the derivatives on the tangent + if use_projection: + # Get the tangent + tangent = neb.get_tangent(pos_p, pos_m) + tangent = np.concatenate([[pos_m[0]], tangent, [pos_p[0]]], axis=0) + tangent = tangent / np.linalg.norm(tangent, axis=(1, 2)).reshape( + -1, 1, 1 + ) + # Get the projection of the derivatives on the tangent + deriv_proj = -np.sum(forces * tangent, axis=(1, 2)) + # Get length of projection + proj_len = distances[-1] / len(images) + proj_len *= 0.4 + # Make figure if it is not given + if ax is None: + _, ax = plt.subplots() + # Plot the NEB images + ax.plot(distances, energies, "o-", color="black") + if uncertainties is not None: + ax.errorbar( + distances, energies, yerr=uncertainties, color="black", capsize=3 + ) + ax.errorbar( + distances, + energies, + yerr=2.0 * uncertainties, + color="black", + capsize=1.5, + ) + # Plot the projection of the derivatives + if use_projection: + for i, deriv in enumerate(deriv_proj): + dist = distances[i] + energy = energies[i] + x_range = [dist - proj_len, dist + proj_len] + y_range = [energy - deriv * proj_len, energy + deriv * proj_len] + ax.plot( + x_range, + y_range, + color="red", + ) + # Make labels + ax.set_xlabel("Distance / [Å]") + ax.set_ylabel("Potential energy / [eV]") + title = "Reaction energy = {:.3f} eV \n".format(energies[-1]) + title += "Activation energy = {:.3f} eV".format(energies.max()) + ax.set_title(title) + return ax + + +def plot_all_neb( + neb_traj, + n_images, + neb_method=ImprovedTangentNEB, + neb_kwargs={}, + ax=None, + cmap=cm.jet, + **kwargs, +): + """ + Plot all the NEB images in a 2D plot. + + Parameters: + neb_traj: list of ASE atoms instances or str + The NEB trajectories of the NEB calculation. + It can be a list of all ASE atoms instances for all NEB bands. + It can also be a string to the file containing the NEB + trajectories. + n_images: int + The number of images in each NEB band. + neb_method: class + The NEB method to use. + neb_kwargs: dict + The keyword arguments for the NEB method. + ax: matplotlib axis instance + The axis to plot the NEB images. + cmap: matplotlib colormap + The colormap to use for the NEB bands. + + Returns: + ax: matplotlib axis instance + """ + # Default values for the neb method + used_neb_kwargs = dict( + k=3.0, + remove_rotation_and_translation=False, + mic=True, + ) + used_neb_kwargs.update(neb_kwargs) + # Calculate the number of NEB bands + if isinstance(neb_traj, str): + neb_traj = read(neb_traj, ":") + n_neb = len(neb_traj) // n_images + # Make figure if it is not given + if ax is None: + _, ax = plt.subplots() + # Make colors for NEB bands + colors = cm.ScalarMappable( + cmap=cmap, + norm=plt.Normalize(1, n_neb), + ) + # Plot all NEB bands + for i in range(n_neb): + # Get the images of the NEB band + images = neb_traj[i * n_images : (i + 1) * n_images] + neb = neb_method(images, **used_neb_kwargs) + # Get the distances between the images + pos_p, pos_m = neb.get_position_diff() + distances = np.linalg.norm(pos_p, axis=(1, 2)) + distances = np.concatenate( + [[0.0], [np.linalg.norm(pos_m[0])], distances], + ) + distances = np.cumsum(distances) + # Get the energies of the images + energies = [image.get_potential_energy() for image in images] + if "results" in images[1].info: + if "predicted energy" in images[1].info["results"]: + for j, image in enumerate(images[1:-1]): + energies[j + 1] = image.info["results"]["predicted energy"] + energies = np.array(energies) - energies[0] + # Plot the NEB images + if n_neb == 1: + color = cmap(1) + else: + color = cmap(i / (n_neb - 1)) + ax.plot(distances, energies, "o-", color=color) + # Add colorbar + if n_neb == 1: + colors = cm.ScalarMappable(cmap=cmap, norm=plt.Normalize(0, 1)) + else: + colors = cm.ScalarMappable(cmap=cmap, norm=plt.Normalize(0, n_neb - 1)) + cbar = plt.colorbar(colors, ax=ax) + cbar.set_label("NEB band index") + # Make labels + ax.set_xlabel("Distance / [Å]") + ax.set_ylabel("Potential energy / [eV]") + return ax From c21db9599c3be9946dfc46eadffe7d0461e62680 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Fri, 1 Nov 2024 10:36:14 +0100 Subject: [PATCH 021/194] Update and make tests --- tests/test_adsorption.py | 88 +++++++++++++++++++++++++ tests/test_local.py | 74 +++++++++++++++++++++ tests/test_mlgo.py | 35 +++++----- tests/test_mlneb.py | 136 +++++++++++++++++++++++++-------------- 4 files changed, 266 insertions(+), 67 deletions(-) create mode 100644 tests/test_adsorption.py create mode 100644 tests/test_local.py diff --git a/tests/test_adsorption.py b/tests/test_adsorption.py new file mode 100644 index 00000000..113f2607 --- /dev/null +++ b/tests/test_adsorption.py @@ -0,0 +1,88 @@ +import unittest +from .functions import get_slab_ads, check_fmax + + +class TestAdsorption(unittest.TestCase): + """ + Test if the Adsorption works and give the right output. + """ + + def test_adsorption_init(self): + "Test if the Adsorption can be initialized." + import numpy as np + from catlearn.activelearning.adsorption import AdsorptionAL + from ase.calculators.emt import EMT + + # Get the initial and final states + slab, ads = get_slab_ads() + # Make the boundary conditions for the global search + bounds = np.array( + [ + [0.0, 1.0], + [0.0, 1.0], + [0.5, 0.95], + [0.0, 2 * np.pi], + [0.0, 2 * np.pi], + [0.0, 2 * np.pi], + ] + ) + # Initialize Adsorption AL + AdsorptionAL( + slab=slab, + adsorbate=ads, + ase_calc=EMT(), + unc_convergence=0.025, + bounds=bounds, + min_data=4, + verbose=False, + tabletxt=None, + ) + + def test_adsorption_run(self): + "Test if the Adsorption can run and converge." + import numpy as np + from catlearn.activelearning.adsorption import AdsorptionAL + from ase.calculators.emt import EMT + + # Get the initial and final states + slab, ads = get_slab_ads() + # Make the boundary conditions for the global search + bounds = np.array( + [ + [0.0, 1.0], + [0.0, 1.0], + [0.5, 0.95], + [0.0, 2 * np.pi], + [0.0, 2 * np.pi], + [0.0, 2 * np.pi], + ] + ) + # Set random seed + np.random.seed(1) + # Initialize Adsorption AL + ads_al = AdsorptionAL( + slab=slab, + adsorbate=ads, + ase_calc=EMT(), + unc_convergence=0.025, + bounds=bounds, + min_data=4, + verbose=False, + tabletxt=None, + ) + # Test if the Adsorption AL can be run + ads_al.run( + fmax=0.05, + steps=50, + max_unc=0.050, + ml_steps=500, + ) + # Check that Adsorption AL converged + self.assertTrue(ads_al.converged() is True) + # Check that Adsorption AL give a minimum + atoms = ads_al.get_best_structures() + self.assertTrue(check_fmax(atoms, EMT(), fmax=0.05)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_local.py b/tests/test_local.py new file mode 100644 index 00000000..c5d0818c --- /dev/null +++ b/tests/test_local.py @@ -0,0 +1,74 @@ +import unittest +import numpy as np +from .functions import get_endstructures, check_fmax + + +class TestLocal(unittest.TestCase): + """ + Test if the local active learning (AL) optimization works and + give the right output. + """ + + def test_local_init(self): + "Test if the local AL can be initialized." + from catlearn.activelearning.local import LocalAL + from ase.calculators.emt import EMT + + # Get the atoms from initial and final states + atoms, _ = get_endstructures() + # Move the gold atom up to prepare optimization + pos = atoms.get_positions() + pos[-1, 2] += 0.5 + atoms.set_positions(pos) + atoms.get_forces() + # Set random seed + np.random.seed(1) + # Initialize Local AL optimization + LocalAL( + atoms=atoms, + ase_calc=EMT(), + unc_convergence=0.02, + use_restart=True, + check_unc=True, + verbose=False, + ) + + def test_local_run(self): + "Test if the local AL can run and converge with restart of path." + from catlearn.activelearning.local import LocalAL + from ase.calculators.emt import EMT + + # Get the atoms from initial and final states + atoms, _ = get_endstructures() + # Move the gold atom up to prepare optimization + pos = atoms.get_positions() + pos[-1, 2] += 0.5 + atoms.set_positions(pos) + atoms.get_forces() + # Set random seed + np.random.seed(1) + # Initialize Local AL optimization + local_al = LocalAL( + atoms=atoms, + ase_calc=EMT(), + unc_convergence=0.02, + use_restart=True, + check_unc=True, + verbose=False, + ) + # Test if the Local AL optimization can be run + local_al.run( + fmax=0.05, + steps=50, + ml_steps=250, + max_unc=0.05, + ) + # Check that Local AL optimization converged + self.assertTrue(local_al.converged() is True) + # Check that Local AL optimization gives a saddle point + atoms = local_al.get_best_structures() + self.assertTrue(check_fmax(atoms, EMT(), fmax=0.05)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_mlgo.py b/tests/test_mlgo.py index 7115a5c3..cc99220e 100644 --- a/tests/test_mlgo.py +++ b/tests/test_mlgo.py @@ -3,12 +3,14 @@ class TestMLGO(unittest.TestCase): - """Test if the MLGO works and give the right output.""" + """ + Test if the MLGO works and give the right output. + """ def test_mlgo_init(self): "Test if the MLGO can be initialized." import numpy as np - from catlearn.optimize.mlgo import MLGO + from catlearn.activelearning.mlgo import MLGO from ase.calculators.emt import EMT # Get the initial and final states @@ -27,19 +29,20 @@ def test_mlgo_init(self): # Initialize MLGO MLGO( slab=slab, - ads=ads, + adsorbate=ads, ase_calc=EMT(), + unc_convergence=0.025, bounds=bounds, - initial_points=2, - norelax_points=10, - min_steps=6, - full_output=False, + min_data=4, + verbose=False, + local_opt_kwargs=dict(logfile=None), + tabletxt=None, ) def test_mlgo_run(self): "Test if the MLGO can run and converge." import numpy as np - from catlearn.optimize.mlgo import MLGO + from catlearn.activelearning.mlgo import MLGO from ase.calculators.emt import EMT # Get the initial and final states @@ -60,32 +63,26 @@ def test_mlgo_run(self): # Initialize MLGO mlgo = MLGO( slab=slab, - ads=ads, + adsorbate=ads, ase_calc=EMT(), + unc_convergence=0.025, bounds=bounds, - initial_points=2, - norelax_points=10, - min_steps=6, - full_output=False, + min_data=4, + verbose=False, local_opt_kwargs=dict(logfile=None), tabletxt=None, ) # Test if the MLGO can be run mlgo.run( fmax=0.05, - unc_convergence=0.025, steps=50, max_unc=0.050, ml_steps=500, - ml_chains=2, - relax=True, - local_steps=100, - seed=0, ) # Check that MLGO converged self.assertTrue(mlgo.converged() is True) # Check that MLGO give a minimum - atoms = mlgo.get_atoms() + atoms = mlgo.get_best_structures() self.assertTrue(check_fmax(atoms, EMT(), fmax=0.05)) diff --git a/tests/test_mlneb.py b/tests/test_mlneb.py index 4fd35767..c47aeaa3 100644 --- a/tests/test_mlneb.py +++ b/tests/test_mlneb.py @@ -4,11 +4,13 @@ class TestMLNEB(unittest.TestCase): - """Test if the MLNEB works and give the right output.""" + """ + Test if the MLNEB works and give the right output. + """ def test_mlneb_init(self): "Test if the MLNEB can be initialized." - from catlearn.optimize.mlneb import MLNEB + from catlearn.activelearning.mlneb import MLNEB from ase.calculators.emt import EMT # Get the initial and final states @@ -20,16 +22,17 @@ def test_mlneb_init(self): start=initial, end=final, ase_calc=EMT(), - interpolation="linear", + neb_interpolation="linear", n_images=11, - use_restart_path=True, - check_path_unc=True, - full_output=False, + unc_convergence=0.05, + use_restart=True, + check_unc=True, + verbose=False, ) def test_mlneb_run(self): "Test if the MLNEB can run and converge with restart of path." - from catlearn.optimize.mlneb import MLNEB + from catlearn.activelearning.mlneb import MLNEB from ase.calculators.emt import EMT # Get the initial and final states @@ -41,18 +44,18 @@ def test_mlneb_run(self): start=initial, end=final, ase_calc=EMT(), - interpolation="linear", + neb_interpolation="linear", n_images=11, - use_restart_path=True, - check_path_unc=True, - full_output=False, + unc_convergence=0.05, + use_restart=True, + check_unc=True, + verbose=False, local_opt_kwargs=dict(logfile=None), tabletxt=None, ) # Test if the MLNEB can be run mlneb.run( fmax=0.05, - unc_convergence=0.05, steps=50, ml_steps=250, max_unc=0.05, @@ -60,7 +63,7 @@ def test_mlneb_run(self): # Check that MLNEB converged self.assertTrue(mlneb.converged() is True) # Check that MLNEB gives a saddle point - images = mlneb.get_images() + images = mlneb.get_best_structures() self.assertTrue(check_image_fmax(images, EMT(), fmax=0.05)) def test_mlneb_run_idpp(self): @@ -68,7 +71,7 @@ def test_mlneb_run_idpp(self): Test if the MLNEB can run and converge with restart of path from IDPP. """ - from catlearn.optimize.mlneb import MLNEB + from catlearn.activelearning.mlneb import MLNEB from ase.calculators.emt import EMT # Get the initial and final states @@ -80,18 +83,18 @@ def test_mlneb_run_idpp(self): start=initial, end=final, ase_calc=EMT(), - interpolation="idpp", + neb_interpolation="idpp", n_images=11, - use_restart_path=True, - check_path_unc=True, - full_output=False, + unc_convergence=0.05, + use_restart=True, + check_unc=True, + verbose=False, local_opt_kwargs=dict(logfile=None), tabletxt=None, ) # Test if the MLNEB can be run mlneb.run( fmax=0.05, - unc_convergence=0.05, steps=50, ml_steps=250, max_unc=0.05, @@ -99,7 +102,7 @@ def test_mlneb_run_idpp(self): # Check that MLNEB converged self.assertTrue(mlneb.converged() is True) # Check that MLNEB gives a saddle point - images = mlneb.get_images() + images = mlneb.get_best_structures() self.assertTrue(check_image_fmax(images, EMT(), fmax=0.05)) def test_mlneb_run_path(self): @@ -107,7 +110,7 @@ def test_mlneb_run_path(self): Test if the MLNEB can run and converge with restart of path from different initial paths. """ - from catlearn.optimize.mlneb import MLNEB + from catlearn.activelearning.mlneb import MLNEB from ase.calculators.emt import EMT # Get the initial and final states @@ -122,18 +125,18 @@ def test_mlneb_run_path(self): start=initial, end=final, ase_calc=EMT(), - interpolation=interpolation, + neb_interpolation=interpolation, n_images=11, - use_restart_path=True, - check_path_unc=True, - full_output=False, + unc_convergence=0.05, + use_restart=True, + check_unc=True, + verbose=False, local_opt_kwargs=dict(logfile=None), tabletxt=None, ) # Test if the MLNEB can be run mlneb.run( fmax=0.05, - unc_convergence=0.05, steps=50, ml_steps=250, max_unc=0.05, @@ -141,12 +144,12 @@ def test_mlneb_run_path(self): # Check that MLNEB converged self.assertTrue(mlneb.converged() is True) # Check that MLNEB gives a saddle point - images = mlneb.get_images() + images = mlneb.get_best_structures() self.assertTrue(check_image_fmax(images, EMT(), fmax=0.05)) def test_mlneb_run_norestart(self): "Test if the MLNEB can run and converge with no restart of path." - from catlearn.optimize.mlneb import MLNEB + from catlearn.activelearning.mlneb import MLNEB from ase.calculators.emt import EMT # Get the initial and final states @@ -158,17 +161,17 @@ def test_mlneb_run_norestart(self): start=initial, end=final, ase_calc=EMT(), - interpolation="linear", + neb_interpolation="linear", n_images=11, - use_restart_path=False, - full_output=False, + unc_convergence=0.05, + use_restart=False, + verbose=False, local_opt_kwargs=dict(logfile=None), tabletxt=None, ) # Test if the MLNEB can be run mlneb.run( fmax=0.05, - unc_convergence=0.05, steps=50, ml_steps=250, max_unc=0.05, @@ -176,12 +179,12 @@ def test_mlneb_run_norestart(self): # Check that MLNEB converged self.assertTrue(mlneb.converged() is True) # Check that MLNEB gives a saddle point - images = mlneb.get_images() + images = mlneb.get_best_structures() self.assertTrue(check_image_fmax(images, EMT(), fmax=0.05)) def test_mlneb_run_savememory(self): "Test if the MLNEB can run and converge when it saves memory." - from catlearn.optimize.mlneb import MLNEB + from catlearn.activelearning.mlneb import MLNEB from ase.calculators.emt import EMT # Get the initial and final states @@ -193,19 +196,19 @@ def test_mlneb_run_savememory(self): start=initial, end=final, ase_calc=EMT(), - interpolation="linear", + neb_interpolation="linear", n_images=11, - use_restart_path=True, - check_path_unc=True, + unc_convergence=0.05, + use_restart=True, + check_unc=True, save_memory=True, - full_output=False, + verbose=False, local_opt_kwargs=dict(logfile=None), tabletxt=None, ) # Test if the MLNEB can be run mlneb.run( fmax=0.05, - unc_convergence=0.05, steps=50, ml_steps=250, max_unc=0.05, @@ -213,12 +216,12 @@ def test_mlneb_run_savememory(self): # Check that MLNEB converged self.assertTrue(mlneb.converged() is True) # Check that MLNEB gives a saddle point - images = mlneb.get_images() + images = mlneb.get_best_structures() self.assertTrue(check_image_fmax(images, EMT(), fmax=0.05)) def test_mlneb_run_no_maxunc(self): "Test if the MLNEB can run and converge when it does not use max_unc." - from catlearn.optimize.mlneb import MLNEB + from catlearn.activelearning.mlneb import MLNEB from ase.calculators.emt import EMT # Get the initial and final states @@ -230,28 +233,65 @@ def test_mlneb_run_no_maxunc(self): start=initial, end=final, ase_calc=EMT(), - interpolation="linear", + neb_interpolation="linear", n_images=11, - use_restart_path=True, - check_path_unc=True, - full_output=False, + unc_convergence=0.05, + use_restart=True, + check_unc=True, + verbose=False, local_opt_kwargs=dict(logfile=None), tabletxt=None, ) # Test if the MLNEB can be run mlneb.run( fmax=0.05, - unc_convergence=0.05, steps=50, ml_steps=250, - max_unc=False, + max_unc=None, ) # Check that MLNEB converged self.assertTrue(mlneb.converged() is True) # Check that MLNEB gives a saddle point - images = mlneb.get_images() + images = mlneb.get_best_structures() self.assertTrue(check_image_fmax(images, EMT(), fmax=0.05)) +def test_mlneb_run_dtrust(self): + "Test if the MLNEB can run and converge when it use a trust distance." + from catlearn.activelearning.mlneb import MLNEB + from ase.calculators.emt import EMT + + # Get the initial and final states + initial, final = get_endstructures() + # Set random seed + np.random.seed(1) + # Initialize MLNEB + mlneb = MLNEB( + start=initial, + end=final, + ase_calc=EMT(), + neb_interpolation="linear", + n_images=11, + unc_convergence=0.05, + use_restart=True, + check_unc=True, + verbose=False, + local_opt_kwargs=dict(logfile=None), + tabletxt=None, + ) + # Test if the MLNEB can be run + mlneb.run( + fmax=0.05, + steps=50, + ml_steps=250, + dtrust=0.5, + ) + # Check that MLNEB converged + self.assertTrue(mlneb.converged() is True) + # Check that MLNEB gives a saddle point + images = mlneb.get_best_structures() + self.assertTrue(check_image_fmax(images, EMT(), fmax=0.05)) + + if __name__ == "__main__": unittest.main() From f6d770ce3f22138ec047e92f9b0d531b2bb39160 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Fri, 1 Nov 2024 10:40:25 +0100 Subject: [PATCH 022/194] Update README --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 4c745884..01a155f3 100644 --- a/README.md +++ b/README.md @@ -7,13 +7,13 @@ CalLearn uses ASE for handling the atomic systems and the calculator interface f You can simply install CatLearn by dowloading it from github as: ```shell -$ git clone https://github.com/avishart/CatLearn +$ git clone https://github.com/avishart/CatLearn/tree/activelearning $ pip install -e CatLearn/. ``` You can also install CatLearn directly from github: ```shell -$ pip install git@github.com:avishart/CatLearn.git +$ pip install git@github.com:avishart/CatLearn.git@activelearning ``` However, it is recommended to install a specific tag to ensure it is a stable version: @@ -74,7 +74,7 @@ bounds = np.array( [0.5, 1.0], [0.0, 2 * np.pi], [0.0, 2 * np.pi], - [0.0, 2 * np.pi], + [0.5, 2 * np.pi], ] ) From 2429c555fcb3d1d4c8b50d70b22ee533817ee527 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Fri, 1 Nov 2024 11:01:43 +0100 Subject: [PATCH 023/194] Debug FixBondLengths --- catlearn/optimizer/adsorption.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/catlearn/optimizer/adsorption.py b/catlearn/optimizer/adsorption.py index 3b5cf64d..f0fbe8fb 100644 --- a/catlearn/optimizer/adsorption.py +++ b/catlearn/optimizer/adsorption.py @@ -133,7 +133,7 @@ def create_slab_ads(self, slab, adsorbate, adsorbate2=None): 2, ) ) - constraints.append(FixBondLengths(indices=pairs)) + constraints.append(FixBondLengths(pairs=pairs)) if self.n_ads2 > 1: pairs = list( itertools.combinations( @@ -141,7 +141,7 @@ def create_slab_ads(self, slab, adsorbate, adsorbate2=None): 2, ) ) - constraints.append(FixBondLengths(indices=pairs)) + constraints.append(FixBondLengths(pairs=pairs)) optimizable.set_constraint(constraints) # Setup the optimizable structure self.setup_optimizable(optimizable) From e3eaf1e63d4032a3e1421a43d84704c5a1a3b91e Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Fri, 1 Nov 2024 11:32:29 +0100 Subject: [PATCH 024/194] Do not use constraint in annealing --- catlearn/optimizer/adsorption.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/catlearn/optimizer/adsorption.py b/catlearn/optimizer/adsorption.py index f0fbe8fb..9d662897 100644 --- a/catlearn/optimizer/adsorption.py +++ b/catlearn/optimizer/adsorption.py @@ -125,7 +125,7 @@ def create_slab_ads(self, slab, adsorbate, adsorbate2=None): # Store the constraints self.constraints_org = [c.copy() for c in optimizable.constraints] # Make constraints - constraints = [FixAtoms(indices=list(range(self.n_slab)))] + self.constraints_new = [FixAtoms(indices=list(range(self.n_slab)))] if self.n_ads > 1: pairs = list( itertools.combinations( @@ -133,7 +133,7 @@ def create_slab_ads(self, slab, adsorbate, adsorbate2=None): 2, ) ) - constraints.append(FixBondLengths(pairs=pairs)) + self.constraints_new.append(FixBondLengths(pairs=pairs)) if self.n_ads2 > 1: pairs = list( itertools.combinations( @@ -141,8 +141,8 @@ def create_slab_ads(self, slab, adsorbate, adsorbate2=None): 2, ) ) - constraints.append(FixBondLengths(pairs=pairs)) - optimizable.set_constraint(constraints) + self.constraints_new.append(FixBondLengths(pairs=pairs)) + optimizable.set_constraint(self.constraints_new) # Setup the optimizable structure self.setup_optimizable(optimizable) return self @@ -195,6 +195,8 @@ def run( unc_convergence=None, **kwargs, ): + # Use original constraints + self.optimizable.set_constraint(self.constraints_org) # Perform the simulated annealing sol = dual_annealing( self.evaluate_value, @@ -202,6 +204,8 @@ def run( maxfun=steps, **self.opt_kwargs, ) + # Set the new constraints + self.optimizable.set_constraint(self.constraints_new) # Set the positions self.evaluate_value(sol["x"]) # Calculate the maximum force to check convergence From 6225648c268c02ff1d0cf13ecf64ccb96cc1a4bf Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Fri, 1 Nov 2024 13:20:32 +0100 Subject: [PATCH 025/194] Make a proper copy of constraints and optimize without FixBondLengths --- catlearn/activelearning/mlgo.py | 2 +- catlearn/optimizer/adsorption.py | 12 +++++++----- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/catlearn/activelearning/mlgo.py b/catlearn/activelearning/mlgo.py index 545609f8..6599536d 100644 --- a/catlearn/activelearning/mlgo.py +++ b/catlearn/activelearning/mlgo.py @@ -321,7 +321,7 @@ def switch_mlcalcs(self, **kwargs): atoms=structures, ) # Remove adsorption constraints - constraints = structures.constraints + constraints = [c.copy() for c in structures.constraints] for atoms in data: atoms.set_constraint(constraints) self.use_prev_calculations(data) diff --git a/catlearn/optimizer/adsorption.py b/catlearn/optimizer/adsorption.py index 9d662897..eb9815fa 100644 --- a/catlearn/optimizer/adsorption.py +++ b/catlearn/optimizer/adsorption.py @@ -122,9 +122,10 @@ def create_slab_ads(self, slab, adsorbate, adsorbate2=None): # Store the positions and cell self.positions0 = optimizable.get_positions().copy() self.cell = np.array(optimizable.get_cell()) - # Store the constraints + # Store the original constraints self.constraints_org = [c.copy() for c in optimizable.constraints] - # Make constraints + # Make constraints for optimization + self.constraints_used = [FixAtoms(indices=list(range(self.n_slab)))] self.constraints_new = [FixAtoms(indices=list(range(self.n_slab)))] if self.n_ads > 1: pairs = list( @@ -196,7 +197,7 @@ def run( **kwargs, ): # Use original constraints - self.optimizable.set_constraint(self.constraints_org) + self.optimizable.set_constraint(self.constraints_used) # Perform the simulated annealing sol = dual_annealing( self.evaluate_value, @@ -204,10 +205,11 @@ def run( maxfun=steps, **self.opt_kwargs, ) - # Set the new constraints - self.optimizable.set_constraint(self.constraints_new) + # Set the positions self.evaluate_value(sol["x"]) + # Set the new constraints + self.optimizable.set_constraint(self.constraints_new) # Calculate the maximum force to check convergence if fmax > self.get_fmax(): # Check if the optimization is converged From 8d52fbac3bb4d81af5f3fc7c601afb413db5b43c Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Fri, 1 Nov 2024 13:36:14 +0100 Subject: [PATCH 026/194] Use global random seed --- catlearn/activelearning/activelearning.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/catlearn/activelearning/activelearning.py b/catlearn/activelearning/activelearning.py index cea8a103..c345791c 100644 --- a/catlearn/activelearning/activelearning.py +++ b/catlearn/activelearning/activelearning.py @@ -224,7 +224,7 @@ def run( """ # Set the random seed if seed is not None: - np.random.RandomState(seed) + np.random.seed(seed) # Check if the method is converged if self.converged(): self.message_system("active learning is converged.") From ff91c1ae300a916d070de3d36ace48770fd50293 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Fri, 1 Nov 2024 13:49:09 +0100 Subject: [PATCH 027/194] Debug to README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 01a155f3..e21c9324 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ CalLearn uses ASE for handling the atomic systems and the calculator interface f You can simply install CatLearn by dowloading it from github as: ```shell -$ git clone https://github.com/avishart/CatLearn/tree/activelearning +$ git clone --single-branch --branch activelearning https://github.com/avishart/CatLearn $ pip install -e CatLearn/. ``` From cc215bef7e0ec91ebe532af8df806772389861ba Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Fri, 1 Nov 2024 15:33:40 +0100 Subject: [PATCH 028/194] Broadcast the prediction --- catlearn/activelearning/activelearning.py | 32 +++++++++++++++++++---- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/catlearn/activelearning/activelearning.py b/catlearn/activelearning/activelearning.py index c345791c..1037a7b4 100644 --- a/catlearn/activelearning/activelearning.py +++ b/catlearn/activelearning/activelearning.py @@ -287,6 +287,8 @@ def reset(self, **kwargs): self._converged = False self.unc = np.nan self.energy_pred = np.nan + self.pred_energies = [] + self.uncertainties = [] # Set the header for the summary table self.make_hdr_table() # Set the writing mode @@ -992,11 +994,8 @@ def evaluate_candidates(self, candidates, **kwargs): candidates = [candidates] # Evaluate the candidates for candidate in candidates: - # Get energy and uncertainty and remove it from the list - self.energy_pred = self.pred_energies[0] - self.pred_energies = self.pred_energies[1:] - self.unc = self.uncertainties[0] - self.uncertainties = self.uncertainties[1:] + # Broadcast the predictions + self.broadcast_predictions() # Evaluate the candidate self.evaluate(candidate) return self @@ -1074,6 +1073,29 @@ def update_candidate(self, candidate, dtol=1e-8, **kwargs): self.candidate.set_velocities(velocities_new) return candidate + def broadcast_predictions(self, **kwargs): + "Broadcast the predictions." + # Get energy and uncertainty and remove it from the list + if self.rank == 0: + self.energy_pred = self.pred_energies[0] + self.pred_energies = self.pred_energies[1:] + self.unc = self.uncertainties[0] + self.uncertainties = self.uncertainties[1:] + # Broadcast the predictions + self.energy_pred = broadcast(self.energy_pred, root=0, comm=self.comm) + self.unc = broadcast(self.unc, root=0, comm=self.comm) + self.pred_energies = broadcast( + self.pred_energies, + root=0, + comm=self.comm, + ) + self.uncertainties = broadcast( + self.uncertainties, + root=0, + comm=self.comm, + ) + return self + def extra_initial_data(self, **kwargs): """ Get an initial structure for the active learning From 98a263b0556d4b5f4376fae6a637c82ad9b6298d Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Fri, 1 Nov 2024 15:34:12 +0100 Subject: [PATCH 029/194] Broadcast the method by using structures --- catlearn/optimizer/parallelopt.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/catlearn/optimizer/parallelopt.py b/catlearn/optimizer/parallelopt.py index 573ba17a..8f868541 100644 --- a/catlearn/optimizer/parallelopt.py +++ b/catlearn/optimizer/parallelopt.py @@ -74,6 +74,7 @@ def run( # Set the rank rank = 0 # Make list of properties + structures = [None] * self.chains candidates = [None] * self.chains converged = [False] * self.chains used_steps = [self.steps] * self.chains @@ -93,6 +94,8 @@ def run( ) # Update the number of steps used_steps[chain] += method.get_number_of_steps() + # Get the structures + structures[chain] = method.get_structures() # Get the candidates candidates[chain] = method.get_candidates() # Check if the optimization is converged @@ -109,9 +112,9 @@ def run( rank = 0 # Broadcast the saved instances rank = 0 - for chain, method in enumerate(self.methods): - self.methods[chain] = broadcast( - self.methods[chain], + for chain in range(self.chains): + structures[chain] = broadcast( + structures[chain], root=rank, comm=self.comm, ) @@ -146,7 +149,7 @@ def run( self.candidates.append(candidate) # Check the minimum value i_min = np.argmin(values) - self.method = self.methods[i_min] + self.method = self.method.update_optimizable(structures[i_min]) self.steps = np.max(used_steps) # Check if the optimization is converged self._converged = self.check_convergence( @@ -164,7 +167,7 @@ def set_calculator(self, calculator, copy_calc=False, **kwargs): return self def is_energy_minimized(self): - return self.methods[-1].is_energy_minimized() + return self.method.is_energy_minimized() def is_parallel_allowed(self): return True From de22cfe47b678412fa0e4a4d12947b0bb08243c7 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Fri, 1 Nov 2024 15:49:47 +0100 Subject: [PATCH 030/194] Minor change in plot --- catlearn/tools/plot.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/catlearn/tools/plot.py b/catlearn/tools/plot.py index 00805586..993363c0 100644 --- a/catlearn/tools/plot.py +++ b/catlearn/tools/plot.py @@ -44,8 +44,6 @@ def plot_minimize( eval_energies = [atoms.get_potential_energy() for atoms in eval_atoms] # Get the reference energy e_ref = eval_energies[0] - # Truncate the evaluated energies - eval_energies = np.array(eval_energies)[-len(pred_energies) :] # Get the uncertainties of the atoms if requested uncertainties = None if use_uncertainty: @@ -60,21 +58,22 @@ def plot_minimize( pred_energies = np.array(pred_energies) - e_ref eval_energies = np.array(eval_energies) - e_ref # Make x values - x_values = np.arange(1, len(pred_energies) + 1) + x_values = np.arange(1, len(eval_energies) + 1) + x_pred = x_values[-len(pred_energies) :] # Plot the energies of the atoms - ax.plot(x_values, pred_energies, "o-", color="red", label="Predicted") + ax.plot(x_pred, pred_energies, "o-", color="red", label="Predicted") ax.plot(x_values, eval_energies, "o-", color="black", label="Evaluated") # Plot the uncertainties of the atoms if requested if uncertainties is not None: ax.fill_between( - x_values, + x_pred, pred_energies - uncertainties, pred_energies + uncertainties, color="red", alpha=0.3, ) ax.fill_between( - x_values, + x_pred, pred_energies - 2.0 * uncertainties, pred_energies + 2.0 * uncertainties, color="red", From 35dceaa7f9491fda8d012417bafaf41305756445 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Fri, 1 Nov 2024 16:11:05 +0100 Subject: [PATCH 031/194] Use print instead of parprint --- catlearn/structures/neb/orgneb.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/catlearn/structures/neb/orgneb.py b/catlearn/structures/neb/orgneb.py index 7c613053..30ef5abe 100644 --- a/catlearn/structures/neb/orgneb.py +++ b/catlearn/structures/neb/orgneb.py @@ -65,14 +65,16 @@ def __init__( self.parallel = parallel if parallel: if world is None: - from ase.parallel import world, parprint + from ase.parallel import world self.world = world if self.nimages % self.world.size != 0: - parprint( - "Warning: The number of images are not chosen optimal for " - "the number of processors when running in parallel!" - ) + if self.world.rank == 0: + print( + "Warning: The number of images are not chosen optimal " + "for the number of processors when running in " + "parallel!" + ) else: self.world = None # Set the properties From 12100bd3eb03c7ee21bbe6ed0945499170dab53a Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Mon, 4 Nov 2024 10:08:34 +0100 Subject: [PATCH 032/194] Change default ml_steps --- catlearn/activelearning/activelearning.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/catlearn/activelearning/activelearning.py b/catlearn/activelearning/activelearning.py index 1037a7b4..7ca48b59 100644 --- a/catlearn/activelearning/activelearning.py +++ b/catlearn/activelearning/activelearning.py @@ -194,7 +194,7 @@ def run( self, fmax=0.05, steps=200, - ml_steps=200, + ml_steps=1000, max_unc=None, dtrust=None, seed=None, From d2a7e268ed1fb5e95517be5e31b925ac795da3e7 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Mon, 4 Nov 2024 10:08:49 +0100 Subject: [PATCH 033/194] Debug broadcast in NEB --- catlearn/structures/neb/orgneb.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/catlearn/structures/neb/orgneb.py b/catlearn/structures/neb/orgneb.py index 30ef5abe..67e76df4 100644 --- a/catlearn/structures/neb/orgneb.py +++ b/catlearn/structures/neb/orgneb.py @@ -1,6 +1,7 @@ import numpy as np from ase.calculators.singlepoint import SinglePointCalculator from ase.build import minimize_rotation_and_translation +from ase.parallel import broadcast from ..structure import Structure from ...regression.gp.fingerprint.geometry import mic_distance @@ -237,8 +238,16 @@ def calculate_properties_parallel(self, **kwargs): # Broadcast the results for i in range(1, self.nimages - 1): root = (i - 1) % self.world.size - self.world.broadcast(self.energies[i : i + 1], root=root) - self.world.broadcast(self.real_forces[i : i + 1], root=root) + self.energies[i : i + 1] = broadcast( + self.energies[i : i + 1], + root=root, + comm=self.world, + ) + self.real_forces[i : i + 1] = broadcast( + self.real_forces[i : i + 1], + root=root, + comm=self.world, + ) return self.energies, self.real_forces def emax(self, **kwargs): From 60ed1c21832b571346e6231f20340ad154ca502a Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Mon, 4 Nov 2024 10:12:00 +0100 Subject: [PATCH 034/194] Change initial calculator for adsorption --- catlearn/activelearning/adsorption.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/catlearn/activelearning/adsorption.py b/catlearn/activelearning/adsorption.py index 10ecd57f..027c0469 100644 --- a/catlearn/activelearning/adsorption.py +++ b/catlearn/activelearning/adsorption.py @@ -3,6 +3,7 @@ from ..optimizer import AdsorptionOptimizer from ..optimizer import ParallelOptimizer from ..regression.gp.baseline.repulsive import RepulsionCalculator +from ..regression.gp.baseline.mie import MiePotential class AdsorptionAL(ActiveLearning): @@ -253,7 +254,9 @@ def extra_initial_data(self, **kwargs): if self.get_training_set_size(): return # Get the initial structures from repulsion potential - self.method.set_calculator(RepulsionCalculator(r_scale=0.7)) + self.method.set_calculator( + MiePotential(denergy=1.0, power_r=10, power_a=6) + ) self.method.run(fmax=0.05, steps=1000) atoms = self.method.get_candidates()[0] # Calculate the initial structure From 4376cf60b94549c59ef12c1297dac7ad6d75597d Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Mon, 4 Nov 2024 10:14:11 +0100 Subject: [PATCH 035/194] Debug in Mie potential name --- catlearn/activelearning/adsorption.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/catlearn/activelearning/adsorption.py b/catlearn/activelearning/adsorption.py index 027c0469..5809e5a6 100644 --- a/catlearn/activelearning/adsorption.py +++ b/catlearn/activelearning/adsorption.py @@ -3,7 +3,7 @@ from ..optimizer import AdsorptionOptimizer from ..optimizer import ParallelOptimizer from ..regression.gp.baseline.repulsive import RepulsionCalculator -from ..regression.gp.baseline.mie import MiePotential +from ..regression.gp.baseline.mie import MieCalculator class AdsorptionAL(ActiveLearning): @@ -255,7 +255,7 @@ def extra_initial_data(self, **kwargs): return # Get the initial structures from repulsion potential self.method.set_calculator( - MiePotential(denergy=1.0, power_r=10, power_a=6) + MieCalculator(denergy=1.0, power_r=10, power_a=6) ) self.method.run(fmax=0.05, steps=1000) atoms = self.method.get_candidates()[0] From 3cf53ed7a382e13bc0406a82f509a2f8aac16170 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Mon, 4 Nov 2024 11:11:02 +0100 Subject: [PATCH 036/194] Use extra initial data for better uncertainty in active learning --- catlearn/activelearning/activelearning.py | 2 +- catlearn/activelearning/adsorption.py | 27 ++++++++++++++--------- catlearn/activelearning/local.py | 2 +- catlearn/activelearning/mlneb.py | 21 ++++++++++++++++++ 4 files changed, 40 insertions(+), 12 deletions(-) diff --git a/catlearn/activelearning/activelearning.py b/catlearn/activelearning/activelearning.py index 7ca48b59..083dd147 100644 --- a/catlearn/activelearning/activelearning.py +++ b/catlearn/activelearning/activelearning.py @@ -1102,7 +1102,7 @@ def extra_initial_data(self, **kwargs): if the ML calculator does not have any training points. """ # Check if the training set is empty - if self.get_training_set_size(): + if self.get_training_set_size() >= 1: return self # Calculate the initial structure self.evaluate(self.get_structures(get_all=False)) diff --git a/catlearn/activelearning/adsorption.py b/catlearn/activelearning/adsorption.py index 5809e5a6..b38fc430 100644 --- a/catlearn/activelearning/adsorption.py +++ b/catlearn/activelearning/adsorption.py @@ -2,8 +2,7 @@ from .activelearning import ActiveLearning from ..optimizer import AdsorptionOptimizer from ..optimizer import ParallelOptimizer -from ..regression.gp.baseline.repulsive import RepulsionCalculator -from ..regression.gp.baseline.mie import MieCalculator +from ..regression.gp.baseline import RepulsionCalculator, MieCalculator class AdsorptionAL(ActiveLearning): @@ -250,20 +249,28 @@ def build_method( return method def extra_initial_data(self, **kwargs): + # Get the number of training data + n_data = self.get_training_set_size() # Check if the training set is empty - if self.get_training_set_size(): - return - # Get the initial structures from repulsion potential - self.method.set_calculator( - MieCalculator(denergy=1.0, power_r=10, power_a=6) - ) + if n_data >= 2: + return self + # Get the initial structures from baseline potentials + if n_data == 0: + self.method.set_calculator(RepulsionCalculator(r_scale=0.7)) + else: + self.method.set_calculator( + MieCalculator(r_scale=1.1, denergy=1.0, power_r=10, power_a=6) + ) self.method.run(fmax=0.05, steps=1000) atoms = self.method.get_candidates()[0] # Calculate the initial structure self.evaluate(atoms) # Print summary table - self.print_statement() - return atoms + if n_data == 1: + self.print_statement() + else: + self.extra_initial_data(**kwargs) + return self def setup_mlcalc( self, diff --git a/catlearn/activelearning/local.py b/catlearn/activelearning/local.py index be696c76..47504162 100644 --- a/catlearn/activelearning/local.py +++ b/catlearn/activelearning/local.py @@ -217,7 +217,7 @@ def build_method( def extra_initial_data(self, **kwargs): # Check if the training set is empty - if self.get_training_set_size(): + if self.get_training_set_size() >= 1: return self # Get the initial structure if it is calculated if self.atoms.calc is not None: diff --git a/catlearn/activelearning/mlneb.py b/catlearn/activelearning/mlneb.py index e403ff76..2a59d367 100644 --- a/catlearn/activelearning/mlneb.py +++ b/catlearn/activelearning/mlneb.py @@ -330,6 +330,27 @@ def build_method( ) return method + def extra_initial_data(self, **kwargs): + # Check if the training set is empty + if self.get_training_set_size() >= 3: + return self + # Get the images + images = self.get_structures(get_all=True) + # Calculate energies of end points + e_start = self.start.get_potential_energy() + e_end = self.end.get_potential_energy() + # Get the image with the potential highest energy + if e_start >= e_end: + i_middle = int((len(images) - 2) / 3.0) + else: + i_middle = int(2.0 * (len(images) - 2) / 3.0) + candidate = images[1 + i_middle].copy() + # Calculate the structure + self.evaluate(candidate) + # Print summary table + self.print_statement() + return self + def get_arguments(self): "Get the arguments of the class itself." # Get the arguments given to the class in the initialization From 48aa027c590b1916fcee2152e278747317bb7a4b Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Mon, 4 Nov 2024 13:12:53 +0100 Subject: [PATCH 037/194] Changes to min_data. Use minimum 3 trained points --- catlearn/activelearning/activelearning.py | 6 +++--- catlearn/activelearning/adsorption.py | 2 +- catlearn/activelearning/local.py | 2 +- catlearn/activelearning/mlgo.py | 2 +- catlearn/activelearning/mlneb.py | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/catlearn/activelearning/activelearning.py b/catlearn/activelearning/activelearning.py index 083dd147..7d387da4 100644 --- a/catlearn/activelearning/activelearning.py +++ b/catlearn/activelearning/activelearning.py @@ -30,7 +30,7 @@ def __init__( check_energy=True, check_fmax=True, n_evaluations_each=1, - min_data=2, + min_data=3, save_properties_traj=True, trajectory="predicted.traj", trainingset="evaluated.traj", @@ -1192,8 +1192,8 @@ def check_convergence(self, fmax, method_converged, **kwargs): # Check if the method converged if not method_converged: converged = False - # Check if the minimum number of data points is reached - if self.get_training_set_size() < self.min_data: + # Check if the minimum number of trained data points is reached + if self.get_training_set_size() - 1 < self.min_data: converged = False # Check the force criterion is met if it is requested if self.use_fmax_convergence and self.true_fmax > fmax: diff --git a/catlearn/activelearning/adsorption.py b/catlearn/activelearning/adsorption.py index b38fc430..2ef201fc 100644 --- a/catlearn/activelearning/adsorption.py +++ b/catlearn/activelearning/adsorption.py @@ -32,7 +32,7 @@ def __init__( check_energy=True, check_fmax=True, n_evaluations_each=1, - min_data=2, + min_data=3, save_properties_traj=True, trajectory="predicted.traj", trainingset="evaluated.traj", diff --git a/catlearn/activelearning/local.py b/catlearn/activelearning/local.py index 47504162..da06b88a 100644 --- a/catlearn/activelearning/local.py +++ b/catlearn/activelearning/local.py @@ -31,7 +31,7 @@ def __init__( check_energy=True, check_fmax=True, n_evaluations_each=1, - min_data=2, + min_data=3, save_properties_traj=True, trajectory="predicted.traj", trainingset="evaluated.traj", diff --git a/catlearn/activelearning/mlgo.py b/catlearn/activelearning/mlgo.py index 6599536d..17d4bb90 100644 --- a/catlearn/activelearning/mlgo.py +++ b/catlearn/activelearning/mlgo.py @@ -36,7 +36,7 @@ def __init__( check_energy=True, check_fmax=True, n_evaluations_each=1, - min_data=2, + min_data=3, save_properties_traj=True, trajectory="predicted.traj", trainingset="evaluated.traj", diff --git a/catlearn/activelearning/mlneb.py b/catlearn/activelearning/mlneb.py index 2a59d367..af001924 100644 --- a/catlearn/activelearning/mlneb.py +++ b/catlearn/activelearning/mlneb.py @@ -39,7 +39,7 @@ def __init__( check_energy=False, check_fmax=True, n_evaluations_each=1, - min_data=2, + min_data=3, save_properties_traj=True, trajectory="predicted.traj", trainingset="evaluated.traj", From 40cd712a7dacb5ac0524c17fd4b99f368605eb50 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Tue, 5 Nov 2024 11:51:42 +0100 Subject: [PATCH 038/194] More detailts about usage of code --- README.md | 60 ++++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 55 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index e21c9324..9e8a06d3 100644 --- a/README.md +++ b/README.md @@ -23,10 +23,44 @@ $ pip install git+https://github.com/avishart/CatLearn.git@v.x.x.x ## Usage +The following code shows how to use LocalAL: +```python +from catlearn.activelearning.local import LocalAL +from ase.io import read +from ase.optimize import FIRE + +# Load initial structure +atoms = read("initial.traj") + +# Make the ASE calculator +calc = ... + +# Initialize local optimization +dyn = LocalAL( + atoms=atoms, + ase_calc=calc, + unc_convergence=0.05, + local_opt=FIRE, + local_opt_kwargs={}, + save_memory=False, + use_restart=True, + min_data=3, + verbose=True, +) +dyn.run( + fmax=0.05, + max_unc=0.30, + steps=100, + ml_steps=1000, +) + +``` + The following code shows how to use MLNEB: ```python from catlearn.activelearning.mlneb import MLNEB from ase.io import read +from ase.optimize import FIRE # Load endpoints initial = read("initial.traj") @@ -40,9 +74,18 @@ mlneb = MLNEB( start=initial, end=final, ase_calc=calc, - neb_interpolation="linear", - n_images=15, unc_convergence=0.05, + n_images=15, + neb_method="improvedtangentneb", + neb_kwargs={}, + neb_interpolation="linear", + reuse_ci_path=True, + save_memory=False, + parallel_run=False, + local_opt=FIRE, + local_opt_kwargs={}, + use_restart=True, + min_data=3, verbose=True, ) mlneb.run( @@ -58,6 +101,7 @@ The following code shows how to use MLGO: ```python from catlearn.activelearning.mlgo import MLGO from ase.io import read +from ase.optimize import FIRE # Load the slab and the adsorbate slab = read("slab.traj") @@ -80,12 +124,18 @@ bounds = np.array( # Initialize MLGO mlgo = MLGO( - slab, - ads, + slab=slab, + adsorbate=ads, + adsorbate2=None, ase_calc=calc, unc_convergence=0.02, bounds=bounds, - chains=4, + opt_kwargs={}, + local_opt=FIRE, + local_opt_kwargs={}, + reuse_data_local=True, + parallel_run=False, + min_data=3, verbose=True ) mlgo.run( From 99da4c64368ffb21e2ca661dfeea89d144745f56 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Tue, 5 Nov 2024 11:52:04 +0100 Subject: [PATCH 039/194] Change default reuse_ci_path to True --- catlearn/activelearning/mlneb.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/catlearn/activelearning/mlneb.py b/catlearn/activelearning/mlneb.py index af001924..12a00334 100644 --- a/catlearn/activelearning/mlneb.py +++ b/catlearn/activelearning/mlneb.py @@ -20,7 +20,7 @@ def __init__( climb=True, neb_interpolation="linear", neb_interpolation_kwargs={}, - reuse_ci_path=False, + reuse_ci_path=True, local_opt=FIRE, local_opt_kwargs={}, acq=None, From 3ef9b954093bc03e686eab302a809054958e6648 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Tue, 5 Nov 2024 15:00:35 +0100 Subject: [PATCH 040/194] Debug verbose to mlmodel --- catlearn/activelearning/activelearning.py | 6 ++++++ catlearn/activelearning/adsorption.py | 2 ++ catlearn/activelearning/mlgo.py | 1 + 3 files changed, 9 insertions(+) diff --git a/catlearn/activelearning/activelearning.py b/catlearn/activelearning/activelearning.py index 7d387da4..e356f493 100644 --- a/catlearn/activelearning/activelearning.py +++ b/catlearn/activelearning/activelearning.py @@ -150,6 +150,7 @@ def __init__( self.setup_mlcalc( mlcalc, save_memory=save_memory, + verbose=verbose, ) # Setup the acquisition function self.setup_acq( @@ -339,6 +340,7 @@ def setup_mlcalc( calc_forces=True, bayesian=True, kappa=2.0, + verbose=True, **kwargs, ): """ @@ -375,6 +377,9 @@ def setup_mlcalc( kappa: float The scaling of the uncertainty relative to the energy. Default is 2.0. + verbose: bool + Whether to print on screen the full output (True) or + not (False). Returns: self: The object itself. @@ -426,6 +431,7 @@ def setup_mlcalc( use_derivatives=True, parallel=(not save_memory), database_reduction=database_reduction, + verbose=verbose, ) # Setup the ML calculator if bayesian: diff --git a/catlearn/activelearning/adsorption.py b/catlearn/activelearning/adsorption.py index 2ef201fc..dd9fd1a3 100644 --- a/catlearn/activelearning/adsorption.py +++ b/catlearn/activelearning/adsorption.py @@ -284,6 +284,7 @@ def setup_mlcalc( calc_forces=True, bayesian=True, kappa=2.0, + verbose=True, **kwargs, ): if mlcalc is None: @@ -324,6 +325,7 @@ def setup_mlcalc( calc_forces=calc_forces, bayesian=bayesian, kappa=kappa, + verbose=verbose, **kwargs, ) diff --git a/catlearn/activelearning/mlgo.py b/catlearn/activelearning/mlgo.py index 17d4bb90..bc679075 100644 --- a/catlearn/activelearning/mlgo.py +++ b/catlearn/activelearning/mlgo.py @@ -319,6 +319,7 @@ def switch_mlcalcs(self, **kwargs): mlcalc_local=self.mlcalc_local, save_memory=self.save_memory, atoms=structures, + verbose=self.verbose, ) # Remove adsorption constraints constraints = [c.copy() for c in structures.constraints] From b6d6400c8fa9e70d837d6d965e188d8658b978e8 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Wed, 6 Nov 2024 10:18:33 +0100 Subject: [PATCH 041/194] Change default ml_steps in MLGO and make ml_steps for local --- catlearn/activelearning/mlgo.py | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/catlearn/activelearning/mlgo.py b/catlearn/activelearning/mlgo.py index bc679075..bc3f9273 100644 --- a/catlearn/activelearning/mlgo.py +++ b/catlearn/activelearning/mlgo.py @@ -266,12 +266,37 @@ def run( self, fmax=0.05, steps=200, - ml_steps=2000, + ml_steps=4000, + ml_steps_local=1000, max_unc=None, dtrust=None, seed=None, **kwargs, ): + """ + Run the active learning optimization. + + Parameters: + fmax: float + Convergence criteria (in eV/Angs). + steps: int + Maximum number of evaluations. + ml_steps: int + Maximum number of steps for the optimization method + on the predicted landscape. + ml_steps_local: int + Maximum number of steps for the local optimization method. + max_unc: float (optional) + Maximum uncertainty for continuation of the optimization. + dtrust: float (optional) + The trust distance for the optimization method. + seed: int (optional) + The random seed. + + Returns: + converged: bool + Whether the active learning is converged. + """ # Run the active learning super().run( fmax=fmax, @@ -295,7 +320,7 @@ def run( super().run( fmax=fmax, steps=steps, - ml_steps=ml_steps, + ml_steps=ml_steps_local, max_unc=max_unc, dtrust=dtrust, seed=seed, From fa9a419d95936e67271e648be13ed90e7061ce52 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Wed, 6 Nov 2024 14:35:37 +0100 Subject: [PATCH 042/194] Change kappa in BO for MLGO --- catlearn/activelearning/activelearning.py | 1 + catlearn/activelearning/adsorption.py | 2 +- catlearn/activelearning/mlgo.py | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/catlearn/activelearning/activelearning.py b/catlearn/activelearning/activelearning.py index e356f493..48914f0d 100644 --- a/catlearn/activelearning/activelearning.py +++ b/catlearn/activelearning/activelearning.py @@ -376,6 +376,7 @@ def setup_mlcalc( Whether to use the active learning calculator. kappa: float The scaling of the uncertainty relative to the energy. + The uncertainty is added to the predicted energy. Default is 2.0. verbose: bool Whether to print on screen the full output (True) or diff --git a/catlearn/activelearning/adsorption.py b/catlearn/activelearning/adsorption.py index dd9fd1a3..e5adea8c 100644 --- a/catlearn/activelearning/adsorption.py +++ b/catlearn/activelearning/adsorption.py @@ -283,7 +283,7 @@ def setup_mlcalc( database_reduction=False, calc_forces=True, bayesian=True, - kappa=2.0, + kappa=-2.0, verbose=True, **kwargs, ): diff --git a/catlearn/activelearning/mlgo.py b/catlearn/activelearning/mlgo.py index bc3f9273..70f34026 100644 --- a/catlearn/activelearning/mlgo.py +++ b/catlearn/activelearning/mlgo.py @@ -345,6 +345,7 @@ def switch_mlcalcs(self, **kwargs): save_memory=self.save_memory, atoms=structures, verbose=self.verbose, + **kwargs, ) # Remove adsorption constraints constraints = [c.copy() for c in structures.constraints] From ccc636a037b7a780a7cdf8b2413bf12cbb4e5148 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Wed, 6 Nov 2024 14:35:47 +0100 Subject: [PATCH 043/194] Correct docstrings --- catlearn/activelearning/activelearning.py | 10 +++++----- catlearn/activelearning/adsorption.py | 2 +- catlearn/activelearning/local.py | 2 +- catlearn/activelearning/mlgo.py | 2 +- catlearn/activelearning/mlneb.py | 2 +- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/catlearn/activelearning/activelearning.py b/catlearn/activelearning/activelearning.py index 48914f0d..08bf0d30 100644 --- a/catlearn/activelearning/activelearning.py +++ b/catlearn/activelearning/activelearning.py @@ -42,7 +42,7 @@ def __init__( **kwargs, ): """ - A active learner that is used for accelerating quantum mechanincal + An active learner that is used for accelerating quantum mechanincal simulation methods with an active learning approach. Parameters: @@ -228,7 +228,7 @@ def run( np.random.seed(seed) # Check if the method is converged if self.converged(): - self.message_system("active learning is converged.") + self.message_system("Active learning is converged.") return self.best_structures # Check if there are any training data self.extra_initial_data() @@ -236,7 +236,7 @@ def run( for step in range(1, steps + 1): # Check if the method is converged if self.converged(): - self.message_system("active learning is converged.") + self.message_system("Active learning is converged.") self.save_trajectory( self.converged_trajectory, self.best_structures, @@ -264,7 +264,7 @@ def run( ) # State if the active learning did not converge if not self.converged(): - self.message_system("active learning did not converge!") + self.message_system("Active learning did not converge!") # Return and broadcast the best atoms self.broadcast_best_structures() return self.converged() @@ -373,7 +373,7 @@ def setup_mlcalc( calc_forces: bool Whether to calculate the forces for all energy predictions. bayesian: bool - Whether to use the active learning calculator. + Whether to use the Bayesian optimization calculator. kappa: float The scaling of the uncertainty relative to the energy. The uncertainty is added to the predicted energy. diff --git a/catlearn/activelearning/adsorption.py b/catlearn/activelearning/adsorption.py index e5adea8c..ed185de6 100644 --- a/catlearn/activelearning/adsorption.py +++ b/catlearn/activelearning/adsorption.py @@ -44,7 +44,7 @@ def __init__( **kwargs, ): """ - A active learner that is used for accelerating local optimization + An active learner that is used for accelerating local optimization of an atomic structure with an active learning approach. Parameters: diff --git a/catlearn/activelearning/local.py b/catlearn/activelearning/local.py index da06b88a..f73d2821 100644 --- a/catlearn/activelearning/local.py +++ b/catlearn/activelearning/local.py @@ -43,7 +43,7 @@ def __init__( **kwargs, ): """ - A active learner that is used for accelerating local optimization + An active learner that is used for accelerating local optimization of an atomic structure with an active learning approach. Parameters: diff --git a/catlearn/activelearning/mlgo.py b/catlearn/activelearning/mlgo.py index 70f34026..ddc7b4b7 100644 --- a/catlearn/activelearning/mlgo.py +++ b/catlearn/activelearning/mlgo.py @@ -48,7 +48,7 @@ def __init__( **kwargs, ): """ - A active learner that is used for accelerating local optimization + An active learner that is used for accelerating local optimization of an atomic structure with an active learning approach. Parameters: diff --git a/catlearn/activelearning/mlneb.py b/catlearn/activelearning/mlneb.py index 12a00334..81c21705 100644 --- a/catlearn/activelearning/mlneb.py +++ b/catlearn/activelearning/mlneb.py @@ -51,7 +51,7 @@ def __init__( **kwargs, ): """ - A active learner that is used for accelerating nudged elastic band + An active learner that is used for accelerating nudged elastic band (NEB) optimization with an active learning approach. Parameters: From 28c76c6722e1a629dfd20cf9369485fb0cc57559 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Wed, 6 Nov 2024 14:42:44 +0100 Subject: [PATCH 044/194] Minor change to functions --- catlearn/regression/gp/calculator/mlmodel.py | 39 +++++++++++++++----- 1 file changed, 29 insertions(+), 10 deletions(-) diff --git a/catlearn/regression/gp/calculator/mlmodel.py b/catlearn/regression/gp/calculator/mlmodel.py index c075ff82..4d3b3c58 100644 --- a/catlearn/regression/gp/calculator/mlmodel.py +++ b/catlearn/regression/gp/calculator/mlmodel.py @@ -331,7 +331,9 @@ def model_prediction( ) # Correct the predicted targets with the baseline if it is used y = self.add_baseline_correction( - y, atoms=atoms, use_derivatives=get_forces + y, + atoms=atoms, + use_derivatives=get_forces, ) # Extract the energy energy = y[0][0] @@ -387,17 +389,23 @@ def store_results( # Make the full matrix of forces and save it if forces is not None: results["forces"] = self.not_masked_reshape( - forces, not_masked, natoms + forces, + not_masked, + natoms, ) # Make the full matrix of force uncertainties and save it if unc_forces is not None: results["force uncertainties"] = self.not_masked_reshape( - unc_forces, not_masked, natoms + unc_forces, + not_masked, + natoms, ) # Make the full matrix of derivatives of uncertainty and save it if unc_deriv is not None: results["uncertainty derivatives"] = self.not_masked_reshape( - unc_deriv, not_masked, natoms + unc_deriv, + not_masked, + natoms, ) return results @@ -408,7 +416,9 @@ def add_baseline_correction( if self.use_baseline: # Calculate the baseline for the ASE atoms object y_base = self.calculate_baseline( - [atoms], use_derivatives=use_derivatives, **kwargs + [atoms], + use_derivatives=use_derivatives, + **kwargs, ) # Add baseline correction to the targets return targets + np.array(y_base)[0] @@ -484,7 +494,9 @@ def reset_database(self, **kwargs): def make_targets(self, atoms, use_derivatives=True, **kwargs): "Make the target in the data base." return self.database.make_target( - atoms, use_derivatives=use_derivatives, use_negative_forces=True + atoms, + use_derivatives=use_derivatives, + use_negative_forces=True, ) def get_constraints(self, atoms, **kwargs): @@ -623,7 +635,8 @@ def get_default_model( from ..kernel.se import SE kernel = SE( - use_fingerprint=use_fingerprint, use_derivatives=use_derivatives + use_fingerprint=use_fingerprint, + use_derivatives=use_derivatives, ) # Set the hyperparameter optimization method if global_optimization: @@ -644,7 +657,9 @@ def get_default_model( from ..optimizers.linesearcher import GoldenSearch line_optimizer = GoldenSearch( - optimize=True, multiple_min=False, parallel=False + optimize=True, + multiple_min=False, + parallel=False, ) optimizer = FactorizedOptimizer( line_optimizer=line_optimizer, @@ -698,7 +713,9 @@ def get_default_model( from ..models.gp import GaussianProcess model = GaussianProcess( - prior=prior, kernel=kernel, use_derivatives=use_derivatives + prior=prior, + kernel=kernel, + use_derivatives=use_derivatives, ) # Set objective function if global_optimization: @@ -720,7 +737,9 @@ def get_default_model( from ..hpfitter.redhpfitter import ReducedHyperparameterFitter hpfitter = ReducedHyperparameterFitter( - func=func, optimizer=optimizer, opt_tr_size=n_reduced + func=func, + optimizer=optimizer, + opt_tr_size=n_reduced, ) model.update_arguments(hpfitter=hpfitter) return model From b4cfe1cc10de11384a07d601a89aaccf580a5402 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Wed, 6 Nov 2024 14:51:30 +0100 Subject: [PATCH 045/194] Enable use_derivatives in setup_mlcalc but give a warning if positive kappa is used without derivatives --- catlearn/activelearning/activelearning.py | 12 +++++++++++- catlearn/activelearning/adsorption.py | 2 ++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/catlearn/activelearning/activelearning.py b/catlearn/activelearning/activelearning.py index 08bf0d30..74f6b97b 100644 --- a/catlearn/activelearning/activelearning.py +++ b/catlearn/activelearning/activelearning.py @@ -336,6 +336,7 @@ def setup_mlcalc( atoms=None, prior=None, baseline=None, + use_derivatives=True, database_reduction=False, calc_forces=True, bayesian=True, @@ -368,6 +369,8 @@ def setup_mlcalc( baseline: Baseline class instance (optional) The baseline instance used for the ML model. The default is None. + use_derivatives : bool + Whether to use derivatives of the targets in the ML model. database_reduction: bool Whether to reduce the database. calc_forces: bool @@ -429,7 +432,7 @@ def setup_mlcalc( prior=prior, fp=fp, baseline=baseline, - use_derivatives=True, + use_derivatives=use_derivatives, parallel=(not save_memory), database_reduction=database_reduction, verbose=verbose, @@ -441,6 +444,13 @@ def setup_mlcalc( calc_forces=calc_forces, kappa=kappa, ) + if not use_derivatives and kappa > 0.0: + if world.rank == 0: + print( + "Warning: The Bayesian optimization calculator " + "with a positive kappa value and no derivatives " + "is not recommended!" + ) else: self.mlcalc = MLCalculator( mlmodel=mlmodel, diff --git a/catlearn/activelearning/adsorption.py b/catlearn/activelearning/adsorption.py index ed185de6..f545edf0 100644 --- a/catlearn/activelearning/adsorption.py +++ b/catlearn/activelearning/adsorption.py @@ -280,6 +280,7 @@ def setup_mlcalc( atoms=None, prior=None, baseline=RepulsionCalculator(), + use_derivatives=True, database_reduction=False, calc_forces=True, bayesian=True, @@ -321,6 +322,7 @@ def setup_mlcalc( atoms=atoms, prior=prior, baseline=baseline, + use_derivatives=use_derivatives, database_reduction=database_reduction, calc_forces=calc_forces, bayesian=bayesian, From 1be8321be71d126a02995a4812fd3b84801bc5a1 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Thu, 7 Nov 2024 16:30:59 +0100 Subject: [PATCH 046/194] Change default kappa value --- catlearn/activelearning/activelearning.py | 1 - catlearn/activelearning/adsorption.py | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/catlearn/activelearning/activelearning.py b/catlearn/activelearning/activelearning.py index 74f6b97b..c84095eb 100644 --- a/catlearn/activelearning/activelearning.py +++ b/catlearn/activelearning/activelearning.py @@ -380,7 +380,6 @@ def setup_mlcalc( kappa: float The scaling of the uncertainty relative to the energy. The uncertainty is added to the predicted energy. - Default is 2.0. verbose: bool Whether to print on screen the full output (True) or not (False). diff --git a/catlearn/activelearning/adsorption.py b/catlearn/activelearning/adsorption.py index f545edf0..ffe47ba4 100644 --- a/catlearn/activelearning/adsorption.py +++ b/catlearn/activelearning/adsorption.py @@ -284,7 +284,7 @@ def setup_mlcalc( database_reduction=False, calc_forces=True, bayesian=True, - kappa=-2.0, + kappa=-1.0, verbose=True, **kwargs, ): From 8c6bb55d2e89725df0b72fe27933397da3958ed8 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Thu, 7 Nov 2024 16:31:54 +0100 Subject: [PATCH 047/194] Make it possible to reuse data from previous mlcalc in setup_mlcalc --- catlearn/activelearning/activelearning.py | 15 +++++++++++++++ catlearn/activelearning/adsorption.py | 2 ++ catlearn/activelearning/mlgo.py | 1 + 3 files changed, 18 insertions(+) diff --git a/catlearn/activelearning/activelearning.py b/catlearn/activelearning/activelearning.py index c84095eb..a4eea823 100644 --- a/catlearn/activelearning/activelearning.py +++ b/catlearn/activelearning/activelearning.py @@ -341,6 +341,7 @@ def setup_mlcalc( calc_forces=True, bayesian=True, kappa=2.0, + reuse_mlcalc_data=False, verbose=True, **kwargs, ): @@ -380,6 +381,8 @@ def setup_mlcalc( kappa: float The scaling of the uncertainty relative to the energy. The uncertainty is added to the predicted energy. + reuse_mlcalc_data: bool + Whether to reuse the data from a previous mlcalc. verbose: bool Whether to print on screen the full output (True) or not (False). @@ -387,9 +390,11 @@ def setup_mlcalc( Returns: self: The object itself. """ + # Check if the ML calculator is given if mlcalc is not None: self.mlcalc = mlcalc return self + # Create the ML calculator from ..regression.gp.calculator.mlmodel import get_default_mlmodel from ..regression.gp.calculator.bocalc import BOCalculator from ..regression.gp.calculator.mlcalc import MLCalculator @@ -436,6 +441,12 @@ def setup_mlcalc( database_reduction=database_reduction, verbose=verbose, ) + # Get the data from a previous mlcalc if requested and it exist + if reuse_mlcalc_data: + if hasattr(self, "mlcalc"): + data = self.get_data_atoms() + else: + data = [] # Setup the ML calculator if bayesian: self.mlcalc = BOCalculator( @@ -455,6 +466,10 @@ def setup_mlcalc( mlmodel=mlmodel, calc_forces=calc_forces, ) + # Reuse the data from a previous mlcalc if requested + if reuse_mlcalc_data: + if len(data): + self.add_training(data) return self def setup_acq( diff --git a/catlearn/activelearning/adsorption.py b/catlearn/activelearning/adsorption.py index ffe47ba4..941465da 100644 --- a/catlearn/activelearning/adsorption.py +++ b/catlearn/activelearning/adsorption.py @@ -285,6 +285,7 @@ def setup_mlcalc( calc_forces=True, bayesian=True, kappa=-1.0, + reuse_mlcalc_data=False, verbose=True, **kwargs, ): @@ -327,6 +328,7 @@ def setup_mlcalc( calc_forces=calc_forces, bayesian=bayesian, kappa=kappa, + reuse_mlcalc_data=reuse_mlcalc_data, verbose=verbose, **kwargs, ) diff --git a/catlearn/activelearning/mlgo.py b/catlearn/activelearning/mlgo.py index ddc7b4b7..48944637 100644 --- a/catlearn/activelearning/mlgo.py +++ b/catlearn/activelearning/mlgo.py @@ -344,6 +344,7 @@ def switch_mlcalcs(self, **kwargs): mlcalc_local=self.mlcalc_local, save_memory=self.save_memory, atoms=structures, + reuse_mlcalc_data=False, verbose=self.verbose, **kwargs, ) From 69ec8824124cb0e800b87a0239abf58906e18d90 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Fri, 8 Nov 2024 10:19:17 +0100 Subject: [PATCH 048/194] Chane default pdis of length --- catlearn/regression/gp/calculator/mlmodel.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/catlearn/regression/gp/calculator/mlmodel.py b/catlearn/regression/gp/calculator/mlmodel.py index 4d3b3c58..4e5a68f8 100644 --- a/catlearn/regression/gp/calculator/mlmodel.py +++ b/catlearn/regression/gp/calculator/mlmodel.py @@ -926,7 +926,7 @@ def get_default_mlmodel( from ..pdistributions.normal import Normal_prior pdis = dict( - length=Normal_prior(mu=[-0.5], std=[1.0]), + length=Normal_prior(mu=[-0.8], std=[0.2]), noise=Normal_prior(mu=[-9.0], std=[1.0]), ) else: From fffd027076d11c205b398b388452d00a3962593f Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Fri, 8 Nov 2024 10:25:54 +0100 Subject: [PATCH 049/194] Change kappa to -2 for adsorption active learning --- catlearn/activelearning/adsorption.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/catlearn/activelearning/adsorption.py b/catlearn/activelearning/adsorption.py index 941465da..7c86a858 100644 --- a/catlearn/activelearning/adsorption.py +++ b/catlearn/activelearning/adsorption.py @@ -284,7 +284,7 @@ def setup_mlcalc( database_reduction=False, calc_forces=True, bayesian=True, - kappa=-1.0, + kappa=-2.0, reuse_mlcalc_data=False, verbose=True, **kwargs, From cb9b643d17bd56febb8fc8b3f0fe03fc1e69fac2 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Tue, 12 Nov 2024 12:31:17 +0100 Subject: [PATCH 050/194] Option to get the mlcalc from active learning --- catlearn/activelearning/activelearning.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/catlearn/activelearning/activelearning.py b/catlearn/activelearning/activelearning.py index a4eea823..a48dca3a 100644 --- a/catlearn/activelearning/activelearning.py +++ b/catlearn/activelearning/activelearning.py @@ -1301,6 +1301,21 @@ def save_mlcalc(self, filename="mlcalc.pkl", **kwargs): self.mlcalc.save_mlcalc(filename, **kwargs) return self + def get_mlcalc(self, copy_mlcalc=True, **kwargs): + """ + Get the ML calculator instance. + + Parameters: + copy_mlcalc : bool + Whether to copy the instance. + + Returns: + MLCalculator: The ML calculator instance. + """ + if copy_mlcalc: + return self.mlcalc.copy() + return self.mlcalc + def check_attributes(self, **kwargs): """ Check that the active learning and the method From 477ccf849ac3f67ea39ba1f542e85e79bac657d7 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Tue, 12 Nov 2024 13:16:26 +0100 Subject: [PATCH 051/194] Remove initial convergence check since it is within the loop --- catlearn/activelearning/activelearning.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/catlearn/activelearning/activelearning.py b/catlearn/activelearning/activelearning.py index a48dca3a..1eed58a6 100644 --- a/catlearn/activelearning/activelearning.py +++ b/catlearn/activelearning/activelearning.py @@ -226,10 +226,6 @@ def run( # Set the random seed if seed is not None: np.random.seed(seed) - # Check if the method is converged - if self.converged(): - self.message_system("Active learning is converged.") - return self.best_structures # Check if there are any training data self.extra_initial_data() # Run the active learning From 0dad4f5762a5b5a982695583f34d5294dcd4f42a Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Tue, 12 Nov 2024 14:39:45 +0100 Subject: [PATCH 052/194] Minor change in initiate_structure in activelearning --- catlearn/activelearning/activelearning.py | 28 ++++++++++++----------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/catlearn/activelearning/activelearning.py b/catlearn/activelearning/activelearning.py index 1eed58a6..ee59a335 100644 --- a/catlearn/activelearning/activelearning.py +++ b/catlearn/activelearning/activelearning.py @@ -885,23 +885,25 @@ def run_method( def initiate_structure(self, step=1, **kwargs): "Initiate the method with right structure." + # Define boolean for using the temporary structure + use_tmp = True # Do not use the temporary structure if not self.use_restart or step == 1: self.message_system("The initial structure is used.") - self.update_method(self.best_structures) - return + use_tmp = False # Reuse the temporary structure if it passes tests - self.update_method(self.structures) - # Get uncertainty and fmax - uncmax_tmp, energy_tmp, fmax_tmp = self.get_predictions() - use_tmp = True - # Check uncertainty is low enough - if self.check_unc: - if uncmax_tmp > self.unc_convergence: - self.message_system( - "The uncertainty is too large to use the last structure." - ) - use_tmp = False + if use_tmp: + self.update_method(self.structures) + # Get uncertainty and fmax + uncmax_tmp, energy_tmp, fmax_tmp = self.get_predictions() + # Check uncertainty is low enough + if self.check_unc: + if uncmax_tmp > self.unc_convergence: + self.message_system( + "The uncertainty is too large to " + "use the last structure." + ) + use_tmp = False # Check fmax is lower than previous structure if use_tmp and (self.check_fmax or self.check_energy): self.update_method(self.best_structures) From a447a9302314a40bbead24042087bac37de8bb05 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Tue, 12 Nov 2024 16:08:09 +0100 Subject: [PATCH 053/194] Store the best structures after initiate check --- catlearn/activelearning/activelearning.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/catlearn/activelearning/activelearning.py b/catlearn/activelearning/activelearning.py index ee59a335..f3723595 100644 --- a/catlearn/activelearning/activelearning.py +++ b/catlearn/activelearning/activelearning.py @@ -924,7 +924,10 @@ def initiate_structure(self, step=1, **kwargs): if use_tmp: self.copy_best_structures() self.message_system("The last structure is used.") + # Set the best structures as the initial structures for the method self.update_method(self.best_structures) + # Store the best structures with the ML calculator + self.copy_best_structures() return def get_predictions(self, **kwargs): From 4db3896c2d9acbbd5a91a8ad83bdc0164f54fe7c Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Wed, 13 Nov 2024 10:52:29 +0100 Subject: [PATCH 054/194] Make a trajectory file with the initial structure(s) --- catlearn/activelearning/activelearning.py | 35 +++++++++++++++++------ catlearn/activelearning/adsorption.py | 11 +++++-- catlearn/activelearning/local.py | 11 +++++-- catlearn/activelearning/mlgo.py | 11 +++++-- catlearn/activelearning/mlneb.py | 11 +++++-- 5 files changed, 63 insertions(+), 16 deletions(-) diff --git a/catlearn/activelearning/activelearning.py b/catlearn/activelearning/activelearning.py index f3723595..4ae417eb 100644 --- a/catlearn/activelearning/activelearning.py +++ b/catlearn/activelearning/activelearning.py @@ -35,6 +35,7 @@ def __init__( trajectory="predicted.traj", trainingset="evaluated.traj", converged_trajectory="converged.traj", + initial_traj="initial_struc.traj", tabletxt="ml_summary.txt", prev_calculations=None, restart=False, @@ -124,9 +125,13 @@ def __init__( Or the TrajectoryWriter instance to store the evaluated training data. converged_trajectory: str or TrajectoryWriter instance - Trajectory filename to store the converged structures. + Trajectory filename to store the converged structure(s). Or the TrajectoryWriter instance to store the converged - structures. + structure(s). + initial_traj: str or TrajectoryWriter instance + Trajectory filename to store the initial structure(s). + Or the TrajectoryWriter instance to store the initial + structure(s). tabletxt: str Name of the .txt file where the summary table is printed. It is not saved to the file if tabletxt=None. @@ -182,6 +187,7 @@ def __init__( trajectory=trajectory, trainingset=trainingset, converged_trajectory=converged_trajectory, + initial_traj=initial_traj, tabletxt=tabletxt, comm=comm, **kwargs, @@ -632,6 +638,7 @@ def update_arguments( trajectory=None, trainingset=None, converged_trajectory=None, + initial_traj=None, tabletxt=None, comm=None, **kwargs, @@ -641,14 +648,14 @@ def update_arguments( The existing arguments are used if they are not given. Parameters: - method: OptimizationMethod instance. + method: OptimizationMethod instance The quantum mechanincal simulation method instance. - ase_calc: ASE calculator instance. + ase_calc: ASE calculator instance ASE calculator as implemented in ASE. - mlcalc: ML-calculator instance. + mlcalc: ML-calculator instance The ML-calculator instance used as surrogate surface. The default BOCalculator instance is used if mlcalc is None. - acq: Acquisition class instance. + acq: Acquisition class instance The Acquisition instance used for calculating the acq. function and choose a candidate to calculate next. The default AcqUME instance is used if acq is None. @@ -719,9 +726,13 @@ def update_arguments( Or the TrajectoryWriter instance to store the evaluated training data. converged_trajectory: str or TrajectoryWriter instance - Trajectory filename to store the converged structures. + Trajectory filename to store the converged structure(s). Or the TrajectoryWriter instance to store the converged - structures. + structure(s). + initial_traj: str or TrajectoryWriter instance + Trajectory filename to store the initial structure(s). + Or the TrajectoryWriter instance to store the initial + structure(s). tabletxt: str Name of the .txt file where the summary table is printed. It is not saved to the file if tabletxt=None. @@ -796,6 +807,10 @@ def update_arguments( self.converged_trajectory = converged_trajectory elif not hasattr(self, "converged_trajectory"): self.converged_trajectory = None + if initial_traj is not None: + self.initial_traj = initial_traj + elif not hasattr(self, "initial_traj"): + self.initial_traj = None if tabletxt is not None: self.tabletxt = str(tabletxt) elif not hasattr(self, "tabletxt"): @@ -928,6 +943,9 @@ def initiate_structure(self, step=1, **kwargs): self.update_method(self.best_structures) # Store the best structures with the ML calculator self.copy_best_structures() + # Save the initial trajectory + if step == 1 and self.initial_traj is not None: + self.save_trajectory(self.initial_traj, self.best_structures) return def get_predictions(self, **kwargs): @@ -1451,6 +1469,7 @@ def get_arguments(self): trajectory=self.trajectory, trainingset=self.trainingset, converged_trajectory=self.converged_trajectory, + initial_traj=self.initial_traj, tabletxt=self.tabletxt, comm=self.comm, ) diff --git a/catlearn/activelearning/adsorption.py b/catlearn/activelearning/adsorption.py index 7c86a858..d0c5bf12 100644 --- a/catlearn/activelearning/adsorption.py +++ b/catlearn/activelearning/adsorption.py @@ -37,6 +37,7 @@ def __init__( trajectory="predicted.traj", trainingset="evaluated.traj", converged_trajectory="converged.traj", + initial_traj="initial_struc.traj", tabletxt="ml_summary.txt", prev_calculations=None, restart=False, @@ -140,9 +141,13 @@ def __init__( Or the TrajectoryWriter instance to store the evaluated training data. converged_trajectory: str or TrajectoryWriter instance - Trajectory filename to store the converged structures. + Trajectory filename to store the converged structure(s). Or the TrajectoryWriter instance to store the converged - structures. + structure(s). + initial_traj: str or TrajectoryWriter instance + Trajectory filename to store the initial structure(s). + Or the TrajectoryWriter instance to store the initial + structure(s). tabletxt: str Name of the .txt file where the summary table is printed. It is not saved to the file if tabletxt=None. @@ -196,6 +201,7 @@ def __init__( trajectory=trajectory, trainingset=trainingset, converged_trajectory=converged_trajectory, + initial_traj=initial_traj, tabletxt=tabletxt, prev_calculations=prev_calculations, restart=restart, @@ -366,6 +372,7 @@ def get_arguments(self): trajectory=self.trajectory, trainingset=self.trainingset, converged_trajectory=self.converged_trajectory, + initial_traj=self.initial_traj, tabletxt=self.tabletxt, comm=self.comm, ) diff --git a/catlearn/activelearning/local.py b/catlearn/activelearning/local.py index f73d2821..aa7b6d68 100644 --- a/catlearn/activelearning/local.py +++ b/catlearn/activelearning/local.py @@ -36,6 +36,7 @@ def __init__( trajectory="predicted.traj", trainingset="evaluated.traj", converged_trajectory="converged.traj", + initial_traj="initial_struc.traj", tabletxt="ml_summary.txt", prev_calculations=None, restart=False, @@ -129,9 +130,13 @@ def __init__( Or the TrajectoryWriter instance to store the evaluated training data. converged_trajectory: str or TrajectoryWriter instance - Trajectory filename to store the converged structures. + Trajectory filename to store the converged structure(s). Or the TrajectoryWriter instance to store the converged - structures. + structure(s). + initial_traj: str or TrajectoryWriter instance + Trajectory filename to store the initial structure(s). + Or the TrajectoryWriter instance to store the initial + structure(s). tabletxt: str Name of the .txt file where the summary table is printed. It is not saved to the file if tabletxt=None. @@ -182,6 +187,7 @@ def __init__( trajectory=trajectory, trainingset=trainingset, converged_trajectory=converged_trajectory, + initial_traj=initial_traj, tabletxt=tabletxt, prev_calculations=prev_calculations, restart=restart, @@ -266,6 +272,7 @@ def get_arguments(self): trajectory=self.trajectory, trainingset=self.trainingset, converged_trajectory=self.converged_trajectory, + initial_traj=self.initial_traj, tabletxt=self.tabletxt, comm=self.comm, ) diff --git a/catlearn/activelearning/mlgo.py b/catlearn/activelearning/mlgo.py index 48944637..4dbcaa5d 100644 --- a/catlearn/activelearning/mlgo.py +++ b/catlearn/activelearning/mlgo.py @@ -41,6 +41,7 @@ def __init__( trajectory="predicted.traj", trainingset="evaluated.traj", converged_trajectory="converged.traj", + initial_traj="initial_struc.traj", tabletxt="ml_summary.txt", prev_calculations=None, restart=False, @@ -161,9 +162,13 @@ def __init__( Or the TrajectoryWriter instance to store the evaluated training data. converged_trajectory: str or TrajectoryWriter instance - Trajectory filename to store the converged structures. + Trajectory filename to store the converged structure(s). Or the TrajectoryWriter instance to store the converged - structures. + structure(s). + initial_traj: str or TrajectoryWriter instance + Trajectory filename to store the initial structure(s). + Or the TrajectoryWriter instance to store the initial + structure(s). tabletxt: str Name of the .txt file where the summary table is printed. It is not saved to the file if tabletxt=None. @@ -208,6 +213,7 @@ def __init__( trajectory=trajectory, trainingset=trainingset, converged_trajectory=converged_trajectory, + initial_traj=initial_traj, tabletxt=tabletxt, prev_calculations=prev_calculations, restart=restart, @@ -407,6 +413,7 @@ def get_arguments(self): trajectory=self.trajectory, trainingset=self.trainingset, converged_trajectory=self.converged_trajectory, + initial_traj=self.initial_traj, tabletxt=self.tabletxt, comm=self.comm, ) diff --git a/catlearn/activelearning/mlneb.py b/catlearn/activelearning/mlneb.py index 81c21705..9d492b20 100644 --- a/catlearn/activelearning/mlneb.py +++ b/catlearn/activelearning/mlneb.py @@ -44,6 +44,7 @@ def __init__( trajectory="predicted.traj", trainingset="evaluated.traj", converged_trajectory="converged.traj", + initial_traj="initial_struc.traj", tabletxt="ml_summary.txt", prev_calculations=None, restart=False, @@ -159,9 +160,13 @@ def __init__( Or the TrajectoryWriter instance to store the evaluated training data. converged_trajectory: str or TrajectoryWriter instance - Trajectory filename to store the converged structures. + Trajectory filename to store the converged structure(s). Or the TrajectoryWriter instance to store the converged - structures. + structure(s). + initial_traj: str or TrajectoryWriter instance + Trajectory filename to store the initial structure(s). + Or the TrajectoryWriter instance to store the initial + structure(s). tabletxt: str Name of the .txt file where the summary table is printed. It is not saved to the file if tabletxt=None. @@ -221,6 +226,7 @@ def __init__( trajectory=trajectory, trainingset=trainingset, converged_trajectory=converged_trajectory, + initial_traj=initial_traj, tabletxt=tabletxt, prev_calculations=self.prev_calculations, restart=restart, @@ -390,6 +396,7 @@ def get_arguments(self): trajectory=self.trajectory, trainingset=self.trainingset, converged_trajectory=self.converged_trajectory, + initial_traj=self.initial_traj, tabletxt=self.tabletxt, comm=self.comm, ) From 37c9942f2f2e86f6541ef5803c90ee9525db7f12 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Wed, 13 Nov 2024 10:57:29 +0100 Subject: [PATCH 055/194] Make NEB plots with mlcalc curve predictions --- catlearn/tools/__init__.py | 4 +- catlearn/tools/plot.py | 324 +++++++++++++++++++++++++++++-------- 2 files changed, 258 insertions(+), 70 deletions(-) diff --git a/catlearn/tools/__init__.py b/catlearn/tools/__init__.py index 2e6d7b47..d6014786 100644 --- a/catlearn/tools/__init__.py +++ b/catlearn/tools/__init__.py @@ -1,3 +1,3 @@ -from .plot import plot_minimize, plot_neb, plot_all_neb +from .plot import plot_minimize, plot_neb, plot_neb_fit_mlcalc, plot_all_neb -__all__ = ["plot_minimize", "plot_neb", "plot_all_neb"] +__all__ = ["plot_minimize", "plot_neb", "plot_neb_fit_mlcalc", "plot_all_neb"] diff --git a/catlearn/tools/plot.py b/catlearn/tools/plot.py index 993363c0..cfce3e71 100644 --- a/catlearn/tools/plot.py +++ b/catlearn/tools/plot.py @@ -38,6 +38,17 @@ def plot_minimize( if isinstance(pred_atoms, str): pred_atoms = read(pred_atoms, ":") pred_energies = [atoms.get_potential_energy() for atoms in pred_atoms] + if ( + "results" in pred_atoms[0].info + and "predicted energy" in pred_atoms[0].info["results"] + ): + for i, atoms in enumerate(pred_atoms): + pred_energies[i] = atoms.info["results"]["predicted energy"] + elif hasattr(pred_atoms[0].calc, "results"): + if "predicted energy" in pred_atoms[0].calc.results: + for i, atoms in enumerate(pred_atoms): + atoms.get_potential_energy() + pred_atoms[i] = atoms.calc.results["predicted energy"] # Get the energies of the evaluated atoms if isinstance(eval_atoms, str): eval_atoms = read(eval_atoms, ":") @@ -47,13 +58,18 @@ def plot_minimize( # Get the uncertainties of the atoms if requested uncertainties = None if use_uncertainty: - if "results" in pred_atoms[0].info: - if "uncertainty" in pred_atoms[0].info["results"]: - uncertainties = [ - atoms.info["results"]["uncertainty"] - for atoms in pred_atoms - ] - uncertainties = np.array(uncertainties) + if ( + "results" in pred_atoms[0].info + and "uncertainty" in pred_atoms[0].info["results"] + ): + uncertainties = [ + atoms.info["results"]["uncertainty"] for atoms in pred_atoms + ] + else: + uncertainties = [ + atoms.calc.get_uncertainty(atoms) for atoms in pred_atoms + ] + uncertainties = np.array(uncertainties) # Make the energies relative to the first energy pred_energies = np.array(pred_energies) - e_ref eval_energies = np.array(eval_energies) - e_ref @@ -86,18 +102,16 @@ def plot_minimize( return ax -def plot_neb( +def get_neb_data( images, neb_method=ImprovedTangentNEB, neb_kwargs={}, - climb=True, - use_uncertainty=True, - use_projection=True, - ax=None, - **kwargs, + climb=False, + use_uncertainty=False, + use_projection=False, ): """ - Plot the NEB images in a 2D plot. + Get the NEB data for plotting. Parameters: images: list of ASE atoms instances @@ -112,11 +126,6 @@ def plot_neb( If True, use the uncertainty of the images. use_projection: bool If True, use the projection of the derivatives on the tangent. - ax: matplotlib axis instance - The axis to plot the NEB images. - - Returns: - ax: matplotlib axis instance """ # Default values for the neb method used_neb_kwargs = dict( @@ -129,30 +138,34 @@ def plot_neb( neb = neb_method(images, climb=climb, **used_neb_kwargs) # Get the energies of the images energies = [image.get_potential_energy() for image in images] - if "results" in images[1].info: - if "predicted energy" in images[1].info["results"]: + if ( + "results" in images[1].info + and "predicted energy" in images[1].info["results"] + ): + for i, image in enumerate(images[1:-1]): + energies[i + 1] = image.info["results"]["predicted energy"] + elif hasattr(images[1].calc, "results"): + if "predicted energy" in images[1].calc.results: for i, image in enumerate(images[1:-1]): - energies[i + 1] = image.info["results"]["predicted energy"] + image.get_potential_energy() + energies[i + 1] = image.calc.results["predicted energy"] energies = np.array(energies) - energies[0] - # Get the forces - forces = [image.get_forces() for image in images] - forces = np.array(forces) - if "results" in images[1].info: - if "predicted forces" in images[1].info["results"]: - for i, image in enumerate(images[1:-1]): - forces[i + 1] = image.info["results"][ - "predicted forces" - ].copy() # Get the uncertainties of the images if requested uncertainties = None if use_uncertainty: - if "results" in images[1].info: - if "uncertainty" in images[1].info["results"]: - uncertainties = [ - image.info["results"]["uncertainty"] - for image in images[1:-1] - ] - uncertainties = np.concatenate([[0.0], uncertainties, [0.0]]) + if ( + "results" in images[1].info + and "uncertainty" in images[1].info["results"] + ): + uncertainties = [ + image.info["results"]["uncertainty"] for image in images[1:-1] + ] + + else: + uncertainties = [ + image.calc.get_uncertainty(image) for image in images[1:-1] + ] + uncertainties = np.concatenate([[0.0], uncertainties, [0.0]]) # Get the distances between the images pos_p, pos_m = neb.get_position_diff() distances = np.linalg.norm(pos_p, axis=(1, 2)) @@ -160,17 +173,79 @@ def plot_neb( distances = np.cumsum(distances) # Use projection of the derivatives on the tangent if use_projection: + # Get the forces + forces = [image.get_forces() for image in images] + forces = np.array(forces) + if ( + "results" in images[1].info + and "predicted forces" in images[1].info["results"] + ): + for i, image in enumerate(images[1:-1]): + forces[i + 1] = image.info["results"][ + "predicted forces" + ].copy() + elif hasattr(images[1].calc, "results"): + if "predicted forces" in images[1].calc.results: + for i, image in enumerate(images[1:-1]): + image.get_forces() + forces[i + 1] = image.calc.results["predicted forces"] # Get the tangent tangent = neb.get_tangent(pos_p, pos_m) tangent = np.concatenate([[pos_m[0]], tangent, [pos_p[0]]], axis=0) - tangent = tangent / np.linalg.norm(tangent, axis=(1, 2)).reshape( - -1, 1, 1 - ) + tangent_norm = np.linalg.norm(tangent, axis=(1, 2)).reshape(-1, 1, 1) + tangent = tangent / tangent_norm # Get the projection of the derivatives on the tangent deriv_proj = -np.sum(forces * tangent, axis=(1, 2)) - # Get length of projection - proj_len = distances[-1] / len(images) - proj_len *= 0.4 + else: + deriv_proj = None + return neb, distances, energies, uncertainties, deriv_proj + + +def plot_neb( + images, + neb_method=ImprovedTangentNEB, + neb_kwargs={}, + climb=True, + use_uncertainty=True, + use_projection=True, + proj_len_scale=0.4, + ax=None, + **kwargs, +): + """ + Plot the NEB images in a 2D plot. + + Parameters: + images: list of ASE atoms instances + The images of the NEB calculation. + neb_method: class + The NEB method to use. + neb_kwargs: dict + The keyword arguments for the NEB method. + climb: bool + If True, use the climbing image method. + use_uncertainty: bool + If True, use the uncertainty of the images. + use_projection: bool + If True, use the projection of the derivatives on the tangent. + proj_len_scale: float + The scale of the projection length. + It is only used if use_projection is True. + ax: matplotlib axis instance + The axis to plot the NEB images. + + Returns: + ax: matplotlib axis instance + """ + # Get data from NEB + _, distances, energies, uncertainties, deriv_proj = get_neb_data( + images, + neb_method=neb_method, + neb_kwargs=neb_kwargs, + climb=climb, + use_uncertainty=use_uncertainty, + use_projection=use_projection, + ) # Make figure if it is not given if ax is None: _, ax = plt.subplots() @@ -178,7 +253,11 @@ def plot_neb( ax.plot(distances, energies, "o-", color="black") if uncertainties is not None: ax.errorbar( - distances, energies, yerr=uncertainties, color="black", capsize=3 + distances, + energies, + yerr=uncertainties, + color="black", + capsize=3, ) ax.errorbar( distances, @@ -189,6 +268,8 @@ def plot_neb( ) # Plot the projection of the derivatives if use_projection: + # Get length of projection + proj_len = proj_len_scale * distances[-1] / len(images) for i, deriv in enumerate(deriv_proj): dist = distances[i] energy = energies[i] @@ -208,6 +289,119 @@ def plot_neb( return ax +def plot_neb_fit_mlcalc( + images, + mlcalc, + neb_method=ImprovedTangentNEB, + neb_kwargs={}, + climb=True, + use_uncertainty=True, + distance_step=0.01, + ax=None, + **kwargs, +): + """ + Plot the NEB images in a 2D plot with the ML calculator predictions. + + Parameters: + images: list of ASE atoms instances + The images of the NEB calculation. + mlcalc: ML calculator instance + The ML calculator to use for the predictions. + neb_method: class + The NEB method to use. + neb_kwargs: dict + The keyword arguments for the NEB method. + climb: bool + If True, use the climbing image method. + use_uncertainty: bool + If True, use the uncertainty of the predictions. + distance_step: float + The step size for the distance between the images. + ax: matplotlib axis instance + The axis to plot the NEB images. + + Returns: + ax: matplotlib axis instance + """ + # Get data from NEB + neb, distances, energies, _, _ = get_neb_data( + images, + neb_method=neb_method, + neb_kwargs=neb_kwargs, + climb=climb, + use_uncertainty=False, + use_projection=False, + ) + # Get the first image + image = images[0].copy() + image.calc = mlcalc + pos0 = image.get_positions() + # Get the distances between the images + pos_p, pos_m = neb.get_position_diff() + displacements = np.append([pos_m[0]], pos_p, axis=0) + # Get the curve positions, energies, and uncertainties + cum_distance = 0.0 + pred_distance = [] + pred_energies = [] + uncertainties = [] + for i, disp in enumerate(displacements): + # Get the distance between the points on the curve + dist = np.linalg.norm(disp) + scalings = np.arange(0.0, dist, distance_step) / dist + scalings = np.append(scalings, [1.0]) + if i != 0: + scalings = scalings[1:] + for scaling in scalings: + # Calculate the position for the point on the curve + pred_pos = pos0 + scaling * disp + pred_distance.append(cum_distance + scaling * dist) + image.set_positions(pred_pos) + # Get the curve energy + energy = image.get_potential_energy() + if hasattr(image.calc, "results"): + if "predicted energy" in image.calc.results: + energy = image.calc.results["predicted energy"] + pred_energies.append(energy) + # Get the curve uncertainty + if use_uncertainty: + unc = image.calc.get_uncertainty(image) + uncertainties.append(unc) + pos0 += disp + cum_distance += dist + pred_distance = np.array(pred_distance) + pred_energies = np.array(pred_energies) - pred_energies[0] + uncertainties = np.array(uncertainties) + # Make figure if it is not given + if ax is None: + _, ax = plt.subplots() + # Plot the NEB images + ax.plot(distances, energies, "o", color="black") + ax.plot(pred_distance, pred_energies, "-", color="red") + if len(uncertainties): + ax.fill_between( + pred_distance, + pred_energies - uncertainties, + pred_energies + uncertainties, + color="red", + alpha=0.3, + ) + ax.fill_between( + pred_distance, + pred_energies - 2.0 * uncertainties, + pred_energies + 2.0 * uncertainties, + color="red", + alpha=0.2, + ) + # Make labels + ax.set_xlabel("Distance / [Å]") + ax.set_ylabel("Potential energy / [eV]") + title = "Reaction energy = {:.3f} eV \n".format(energies[-1]) + title += "Activation energy = {:.3f} eV".format(pred_energies.max()) + ax.set_title(title) + return ax + + def plot_all_neb( neb_traj, n_images, @@ -215,6 +409,7 @@ def plot_all_neb( neb_kwargs={}, ax=None, cmap=cm.jet, + alpha=1.0, **kwargs, ): """ @@ -236,17 +431,12 @@ def plot_all_neb( The axis to plot the NEB images. cmap: matplotlib colormap The colormap to use for the NEB bands. + alpha: float + The transparency of the NEB bands except for the last. Returns: ax: matplotlib axis instance """ - # Default values for the neb method - used_neb_kwargs = dict( - k=3.0, - remove_rotation_and_translation=False, - mic=True, - ) - used_neb_kwargs.update(neb_kwargs) # Calculate the number of NEB bands if isinstance(neb_traj, str): neb_traj = read(neb_traj, ":") @@ -263,27 +453,25 @@ def plot_all_neb( for i in range(n_neb): # Get the images of the NEB band images = neb_traj[i * n_images : (i + 1) * n_images] - neb = neb_method(images, **used_neb_kwargs) - # Get the distances between the images - pos_p, pos_m = neb.get_position_diff() - distances = np.linalg.norm(pos_p, axis=(1, 2)) - distances = np.concatenate( - [[0.0], [np.linalg.norm(pos_m[0])], distances], + # Get data from NEB band + _, distances, energies, _, _ = get_neb_data( + images, + neb_method=neb_method, + neb_kwargs=neb_kwargs, + climb=False, + use_uncertainty=False, + use_projection=False, ) - distances = np.cumsum(distances) - # Get the energies of the images - energies = [image.get_potential_energy() for image in images] - if "results" in images[1].info: - if "predicted energy" in images[1].info["results"]: - for j, image in enumerate(images[1:-1]): - energies[j + 1] = image.info["results"]["predicted energy"] - energies = np.array(energies) - energies[0] - # Plot the NEB images + # Get the color if n_neb == 1: color = cmap(1) else: color = cmap(i / (n_neb - 1)) - ax.plot(distances, energies, "o-", color=color) + # Get the transparency + if i + 1 == n_neb: + alpha = 1.0 + # Plot the NEB images + ax.plot(distances, energies, "o-", color=color, alpha=alpha) # Add colorbar if n_neb == 1: colors = cm.ScalarMappable(cmap=cmap, norm=plt.Normalize(0, 1)) From afebe95a7861e472215c5772d79ab9e77c65df7c Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Wed, 13 Nov 2024 13:26:55 +0100 Subject: [PATCH 056/194] Debug restart in active learning --- catlearn/activelearning/activelearning.py | 36 +++++++++++++++-------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/catlearn/activelearning/activelearning.py b/catlearn/activelearning/activelearning.py index 4ae417eb..17553b09 100644 --- a/catlearn/activelearning/activelearning.py +++ b/catlearn/activelearning/activelearning.py @@ -192,10 +192,13 @@ def __init__( comm=comm, **kwargs, ) + # Restart the active learning + prev_calculations = self.restart_optimization( + restart, + prev_calculations, + ) # Use previous calculations to train ML calculator self.use_prev_calculations(prev_calculations) - # Restart the active learning - self.restart_optimization(restart, prev_calculations) def run( self, @@ -314,8 +317,10 @@ def setup_method(self, method, **kwargs): self.structures = self.get_structures() if isinstance(self.structures, list): self.n_structures = len(self.structures) + self.natoms = len(self.structures[0]) else: self.n_structures = 1 + self.natoms = len(self.structures) self.best_structures = self.get_structures() self._converged = self.method.converged() # Set the evaluated candidate and its calculator @@ -1416,28 +1421,33 @@ def restart_optimization( "Restart the active learning." # Check if the optimization should be restarted if not restart: - return self - # Check if the previous calculations are given - if prev_calculations is not None: - self.message_system( - "Warning: Given previous calculations does " - "not work with restart!" - ) + return prev_calculations # Load the previous calculations from trajectory try: + # Test if the restart is possible + structure = read(self.trajectory, "0") + assert len(structure) == self.natoms + # Load the predicted structures + if self.n_structures == 1: + index = "-1" + else: + index = f"-{self.n_structures}:" self.structures = read( self.trajectory, - f"-{self.n_structures}:", + index, ) + # Load the previous training data prev_calculations = read(self.trainingset, ":") + # Update the method with the structures + self.update_method(self.structures) + # Set the writing mode + self.mode = "a" except Exception: self.message_system( "Warning: Restart is not possible! " "Reinitalizing active learning." ) - # Set the writing mode - self.mode = "a" - return self + return prev_calculations def get_arguments(self): "Get the arguments of the class itself." From 2180ff2aa7c5e9f73f5eb5fc3d4c327d09abbe09 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Wed, 13 Nov 2024 14:31:52 +0100 Subject: [PATCH 057/194] Save properties also when ploting --- catlearn/tools/plot.py | 1 + 1 file changed, 1 insertion(+) diff --git a/catlearn/tools/plot.py b/catlearn/tools/plot.py index cfce3e71..4fc62f19 100644 --- a/catlearn/tools/plot.py +++ b/catlearn/tools/plot.py @@ -131,6 +131,7 @@ def get_neb_data( used_neb_kwargs = dict( k=3.0, remove_rotation_and_translation=False, + save_properties=True, mic=True, ) used_neb_kwargs.update(neb_kwargs) From 1deaf12b187115b94470df80ca5c16da0ae6e3b0 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Wed, 13 Nov 2024 15:03:38 +0100 Subject: [PATCH 058/194] Changes to the README --- README.md | 71 +++++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 64 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 9e8a06d3..0c9176d5 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,17 @@ # CatLearn -CatLearn utilieties machine learning in form of Gaussian Process or Student T process to accelerate catalysis simulations. The Nudged-elastic-band method (NEB) is accelerated with MLNEB code. Furthermore, a global adsorption search is accelerated with the MLGO code. -CalLearn uses ASE for handling the atomic systems and the calculator interface for the potential energy calculations. +CatLearn utilities machine learning in form of Gaussian Process or Student T process to accelerate catalysis simulations. The local optimization of a structure is accelerated with the `LocalAL` code. The Nudged-elastic-band method (NEB) is accelerated with `MLNEB` code. Furthermore, a global adsorption search is accelerated with the `MLGO` code. +CalLearn uses ASE to handle the atomic systems and the calculator interface to calculate the potential energy. ## Installation -You can simply install CatLearn by dowloading it from github as: +You can simply install CatLearn by downloading it from GitHub as: ```shell $ git clone --single-branch --branch activelearning https://github.com/avishart/CatLearn $ pip install -e CatLearn/. ``` -You can also install CatLearn directly from github: +You can also install CatLearn directly from GitHub: ```shell $ pip install git@github.com:avishart/CatLearn.git@activelearning ``` @@ -23,7 +23,8 @@ $ pip install git+https://github.com/avishart/CatLearn.git@v.x.x.x ## Usage -The following code shows how to use LocalAL: +### LocalAL +The following code shows how to use `LocalAL`: ```python from catlearn.activelearning.local import LocalAL from ase.io import read @@ -45,6 +46,7 @@ dyn = LocalAL( save_memory=False, use_restart=True, min_data=3, + restart=False, verbose=True, ) dyn.run( @@ -56,7 +58,19 @@ dyn.run( ``` -The following code shows how to use MLNEB: +The active learning minimization can be visualized by extending the Python script with the following code: +```python +import matplotlib.pyplot as plt +from catlearn.tools.plot import plot_minimize + +fig, ax = plt.subplots() +plot_minimize("predicted.traj", "evaluated.traj", ax=ax) +plt.savefig('AL_minimization.png') +plt.close() +``` + +### MLNEB +The following code shows how to use `MLNEB`: ```python from catlearn.activelearning.mlneb import MLNEB from ase.io import read @@ -86,6 +100,7 @@ mlneb = MLNEB( local_opt_kwargs={}, use_restart=True, min_data=3, + restart=False, verbose=True, ) mlneb.run( @@ -97,7 +112,47 @@ mlneb.run( ``` -The following code shows how to use MLGO: +The obtained NEB band from the MLNEB optimization can be visualized in three ways. +The converged NEB band with uncertainties can be visualized by extending the Python code with the following code: +```python +import matplotlib.pyplot as plt +from catlearn.tools.plot import plot_neb + +fig, ax = plt.subplots() +plot_neb(mlneb.get_structures(), use_uncertainty=True, ax=ax) +plt.savefig('Converged_NEB.png') +plt.close() +``` + +The converged NEB band can also be plotted with the predicted curve between the images by extending with the following code: +```python +import matplotlib.pyplot as plt +from catlearn.tools.plot import plot_neb_fit_mlcalc + +fig, ax = plt.subplots() +plot_neb_fit_mlcalc( + mlneb.get_structures(), + mlcalc=mlneb.get_mlcalc(), + use_uncertainty=True, + ax=ax, +) +plt.savefig('Converged_NEB_fit.png') +plt.close() +``` + +All the obtained NEB bands from `MLNEB` can also be visualized within the same figure by using the following code: +```python +import matplotlib.pyplot as plt +from catlearn.tools.plot import plot_all_neb + +fig, ax = plt.subplots() +plot_all_neb("predicted.traj", n_images=15, ax=ax) +plt.savefig('All_NEB_paths.png') +plt.close() +``` + +### MLGO +The following code shows how to use `MLGO`: ```python from catlearn.activelearning.mlgo import MLGO from ase.io import read @@ -136,6 +191,7 @@ mlgo = MLGO( reuse_data_local=True, parallel_run=False, min_data=3, + restart=False, verbose=True ) mlgo.run( @@ -147,3 +203,4 @@ mlgo.run( ``` +The `MLGO` optimization can be visualized in the same way as the `LocalAL` optimization. From d3da5b8e314c4098b77dfabab45945c59b191482 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Wed, 13 Nov 2024 15:24:01 +0100 Subject: [PATCH 059/194] Minor correction to README --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 0c9176d5..6446e296 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,7 @@ mlneb.run( ``` The obtained NEB band from the MLNEB optimization can be visualized in three ways. + The converged NEB band with uncertainties can be visualized by extending the Python code with the following code: ```python import matplotlib.pyplot as plt From 5451d555ff0052a70794c4e2fb2fc70e0a3e4cfc Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Fri, 15 Nov 2024 10:03:32 +0100 Subject: [PATCH 060/194] Change default alpha to 0.7 --- catlearn/tools/plot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/catlearn/tools/plot.py b/catlearn/tools/plot.py index 4fc62f19..545d2b49 100644 --- a/catlearn/tools/plot.py +++ b/catlearn/tools/plot.py @@ -410,7 +410,7 @@ def plot_all_neb( neb_kwargs={}, ax=None, cmap=cm.jet, - alpha=1.0, + alpha=0.7, **kwargs, ): """ From fea73c3316a96c90dece3113cb2c1bd025750a76 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Fri, 15 Nov 2024 10:54:38 +0100 Subject: [PATCH 061/194] Changes to the README file --- README.md | 68 ++++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 65 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 6446e296..4ffdac93 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,17 @@ # CatLearn -CatLearn utilities machine learning in form of Gaussian Process or Student T process to accelerate catalysis simulations. The local optimization of a structure is accelerated with the `LocalAL` code. The Nudged-elastic-band method (NEB) is accelerated with `MLNEB` code. Furthermore, a global adsorption search is accelerated with the `MLGO` code. +CatLearn utilizes machine learning in the form of the Gaussian Process or Student T process to accelerate catalysis simulations. + +The local optimization of a structure is accelerated with the `LocalAL` code. +The Nudged-elastic-band method (NEB) is accelerated with `MLNEB` code. +Furthermore, a global adsorption search without local relaxation is accelerated with the `AdsorptionAL` code. +Additionally, a global adsorption search with local relaxation is accelerated with the `MLGO` code. + CalLearn uses ASE to handle the atomic systems and the calculator interface to calculate the potential energy. ## Installation -You can simply install CatLearn by downloading it from GitHub as: +You can install CatLearn by downloading it from GitHub as: ```shell $ git clone --single-branch --branch activelearning https://github.com/avishart/CatLearn $ pip install -e CatLearn/. @@ -22,6 +28,12 @@ $ pip install git+https://github.com/avishart/CatLearn.git@v.x.x.x ``` ## Usage +The active learning class is generalized to work for any defined optimizer method for ASE `Atoms` structures. The optimization method is executed iteratively with a machine-learning calculator that is retrained for each iteration. The active learning converges when the uncertainty is low (`unc_convergence`) and the energy change is within `unc_convergence` or the maximum force is within the tolerance value set. + +Predefined active learning methods are created: `LocalAL`, `MLNEB`, `AdsorptionAL`, and `MLGO`. + +The output of the active learning is `predicted.traj`, `evaluated.traj`, `converged.traj`, `initial_struc.traj`, and `ml_summary.txt`. +The `predicted.traj` file contains the structures the machine-learning calculator predicts after each optimization loop. The training data and ASE calculator evaluated structures are within `evaluated.traj` file. The converged structures calculated with the machine-learning calculator are saved in the `converged.traj` file. The initial structure(s) is/are saved into the `initial_struc.traj` file. The summary of the active learning is saved into a table in the `ml_summary.txt` file. ### LocalAL The following code shows how to use `LocalAL`: @@ -152,6 +164,56 @@ plt.savefig('All_NEB_paths.png') plt.close() ``` +### AdsorptionAL +The following code shows how to use `AdsorptionAL`: +```python +from catlearn.activelearning.adsorption import AdsorptionAL +from ase.io import read + +# Load the slab and the adsorbate +slab = read("slab.traj") +ads = read("adsorbate.traj") + +# Make the ASE calculator +calc = ... + +# Make the boundary conditions for the adsorbate +bounds = np.array( + [ + [0.0, 1.0], + [0.0, 1.0], + [0.5, 1.0], + [0.0, 2 * np.pi], + [0.0, 2 * np.pi], + [0.5, 2 * np.pi], + ] +) + +# Initialize MLGO +dyn = AdsorptionAL( + slab=slab, + adsorbate=ads, + adsorbate2=None, + ase_calc=calc, + unc_convergence=0.02, + bounds=bounds, + opt_kwargs={}, + parallel_run=False, + min_data=3, + restart=False, + verbose=True +) +dyn.run( + fmax=0.05, + max_unc=0.30, + steps=100, + ml_steps=4000, +) + +``` + +The `AdsorptionAL` optimization can be visualized in the same way as the `LocalAL` optimization. + ### MLGO The following code shows how to use `MLGO`: ```python @@ -199,7 +261,7 @@ mlgo.run( fmax=0.05, max_unc=0.30, steps=100, - ml_steps=1000, + ml_steps=4000, ) ``` From 41baa002ae37998ab2850f82a9559491c14468c8 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Fri, 15 Nov 2024 12:28:48 +0100 Subject: [PATCH 062/194] Remove unused colors in plot_all_neb --- catlearn/tools/plot.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/catlearn/tools/plot.py b/catlearn/tools/plot.py index 545d2b49..522fadeb 100644 --- a/catlearn/tools/plot.py +++ b/catlearn/tools/plot.py @@ -445,11 +445,6 @@ def plot_all_neb( # Make figure if it is not given if ax is None: _, ax = plt.subplots() - # Make colors for NEB bands - colors = cm.ScalarMappable( - cmap=cmap, - norm=plt.Normalize(1, n_neb), - ) # Plot all NEB bands for i in range(n_neb): # Get the images of the NEB band From 0f155ce026ab63b2a63bb7ece4852b044576c1e3 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Fri, 15 Nov 2024 13:33:16 +0100 Subject: [PATCH 063/194] Minor change in README --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 4ffdac93..f7a7fef7 100644 --- a/README.md +++ b/README.md @@ -32,8 +32,8 @@ The active learning class is generalized to work for any defined optimizer metho Predefined active learning methods are created: `LocalAL`, `MLNEB`, `AdsorptionAL`, and `MLGO`. -The output of the active learning is `predicted.traj`, `evaluated.traj`, `converged.traj`, `initial_struc.traj`, and `ml_summary.txt`. -The `predicted.traj` file contains the structures the machine-learning calculator predicts after each optimization loop. The training data and ASE calculator evaluated structures are within `evaluated.traj` file. The converged structures calculated with the machine-learning calculator are saved in the `converged.traj` file. The initial structure(s) is/are saved into the `initial_struc.traj` file. The summary of the active learning is saved into a table in the `ml_summary.txt` file. +The outputs of the active learning are `predicted.traj`, `evaluated.traj`, `converged.traj`, `initial_struc.traj`, and `ml_summary.txt`. +The `predicted.traj` file contains the structures that the machine-learning calculator predicts after each optimization loop. The training data and ASE calculator evaluated structures are within `evaluated.traj` file. The converged structures calculated with the machine-learning calculator are saved in the `converged.traj` file. The initial structure(s) is/are saved into the `initial_struc.traj` file. The summary of the active learning is saved into a table in the `ml_summary.txt` file. ### LocalAL The following code shows how to use `LocalAL`: From 2715a954cd1b83c6051e371c7595718f1bcaf575 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Fri, 15 Nov 2024 15:23:06 +0100 Subject: [PATCH 064/194] Ensure comm and other arguments can be None --- catlearn/activelearning/activelearning.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/catlearn/activelearning/activelearning.py b/catlearn/activelearning/activelearning.py index 17553b09..a20906d7 100644 --- a/catlearn/activelearning/activelearning.py +++ b/catlearn/activelearning/activelearning.py @@ -761,7 +761,7 @@ def update_arguments( self.use_database_check = use_database_check if save_memory is not None: self.save_memory = save_memory - if comm is not None: + if comm is not None or not hasattr(self, "comm"): # Setup parallelization self.parallel_setup(comm) if parallel_run is not None: @@ -772,10 +772,17 @@ def update_arguments( # Whether to have the full output self.verbose = verbose self.set_verbose(verbose=verbose) + elif not hasattr(self, "verbose"): + self.verbose = False + self.set_verbose(verbose=False) if apply_constraint is not None: self.apply_constraint = apply_constraint + elif not hasattr(self, "apply_constraint"): + self.apply_constraint = True if force_consistent is not None: self.force_consistent = force_consistent + elif not hasattr(self, "force_consistent"): + self.force_consistent = False if scale_fmax is not None: self.scale_fmax = abs(float(scale_fmax)) if use_fmax_convergence is not None: @@ -985,9 +992,12 @@ def get_candidate_predictions(self, **kwargs): def parallel_setup(self, comm, **kwargs): "Setup the parallelization." - self.comm = comm - self.rank = comm.rank - self.size = comm.size + if comm is None: + self.comm = world + else: + self.comm = comm + self.rank = self.comm.rank + self.size = self.comm.size return self def add_training(self, atoms_list, **kwargs): From 01be4c719f0e4153ec55fa0e116e14b655c2c535 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Fri, 15 Nov 2024 15:30:24 +0100 Subject: [PATCH 065/194] No need to calculate forces in simulated annealing --- catlearn/activelearning/adsorption.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/catlearn/activelearning/adsorption.py b/catlearn/activelearning/adsorption.py index d0c5bf12..9b463f55 100644 --- a/catlearn/activelearning/adsorption.py +++ b/catlearn/activelearning/adsorption.py @@ -288,7 +288,7 @@ def setup_mlcalc( baseline=RepulsionCalculator(), use_derivatives=True, database_reduction=False, - calc_forces=True, + calc_forces=False, bayesian=True, kappa=-2.0, reuse_mlcalc_data=False, From d257c725244cf99ae33e0adfb90c643bcab204bf Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Mon, 18 Nov 2024 09:49:03 +0100 Subject: [PATCH 066/194] Use tolerance and bond lengths for FixBondLengths --- catlearn/activelearning/adsorption.py | 8 +++ catlearn/activelearning/mlgo.py | 5 ++ catlearn/optimizer/adsorption.py | 95 ++++++++++++++++++++++----- 3 files changed, 91 insertions(+), 17 deletions(-) diff --git a/catlearn/activelearning/adsorption.py b/catlearn/activelearning/adsorption.py index 9b463f55..d5fa0984 100644 --- a/catlearn/activelearning/adsorption.py +++ b/catlearn/activelearning/adsorption.py @@ -15,6 +15,7 @@ def __init__( adsorbate2=None, bounds=None, opt_kwargs={}, + bond_tol=1e-8, chains=None, acq=None, use_database_check=True, @@ -72,6 +73,8 @@ def __init__( the second adsorbate. opt_kwargs: dict The keyword arguments for the simulated annealing optimizer. + bond_tol: float + The bond tolerance used for the FixBondLengths. chains: int (optional) The number of optimization that will be run in parallel. It is only used if parallel_run=True. @@ -168,6 +171,7 @@ def __init__( adsorbate2=adsorbate2, bounds=bounds, opt_kwargs=opt_kwargs, + bond_tol=bond_tol, chains=chains, parallel_run=parallel_run, comm=comm, @@ -216,6 +220,7 @@ def build_method( adsorbate2=None, bounds=None, opt_kwargs={}, + bond_tol=1e-8, chains=None, parallel_run=False, comm=world, @@ -232,6 +237,7 @@ def build_method( self.adsorbate2 = None self.bounds = bounds self.opt_kwargs = opt_kwargs.copy() + self.bond_tol = bond_tol self.chains = chains # Build the optimizer method method = AdsorptionOptimizer( @@ -240,6 +246,7 @@ def build_method( adsorbate2=adsorbate2, bounds=bounds, opt_kwargs=opt_kwargs, + bond_tol=bond_tol, parallel_run=False, comm=comm, verbose=verbose, @@ -350,6 +357,7 @@ def get_arguments(self): adsorbate2=self.adsorbate2, bounds=self.bounds, opt_kwargs=self.opt_kwargs, + bond_tol=self.bond_tol, chains=self.chains, acq=self.acq, use_database_check=self.use_database_check, diff --git a/catlearn/activelearning/mlgo.py b/catlearn/activelearning/mlgo.py index 4dbcaa5d..dac8d693 100644 --- a/catlearn/activelearning/mlgo.py +++ b/catlearn/activelearning/mlgo.py @@ -15,6 +15,7 @@ def __init__( adsorbate2=None, bounds=None, opt_kwargs={}, + bond_tol=1e-8, chains=None, local_opt=FIRE, local_opt_kwargs={}, @@ -80,6 +81,8 @@ def __init__( the second adsorbate. opt_kwargs: dict The keyword arguments for the simulated annealing optimizer. + bond_tol: float + The bond tolerance used for the FixBondLengths. chains: int (optional) The number of optimization that will be run in parallel. It is only used if parallel_run=True. @@ -191,6 +194,7 @@ def __init__( adsorbate2=adsorbate2, bounds=bounds, opt_kwargs=opt_kwargs, + bond_tol=bond_tol, chains=chains, acq=acq, use_database_check=use_database_check, @@ -387,6 +391,7 @@ def get_arguments(self): adsorbate2=self.adsorbate2, bounds=self.bounds, opt_kwargs=self.opt_kwargs, + bond_tol=self.bond_tol, chains=self.chains, local_opt=self.local_opt, local_opt_kwargs=self.local_opt_kwargs, diff --git a/catlearn/optimizer/adsorption.py b/catlearn/optimizer/adsorption.py index eb9815fa..1f2fd46f 100644 --- a/catlearn/optimizer/adsorption.py +++ b/catlearn/optimizer/adsorption.py @@ -14,6 +14,7 @@ def __init__( adsorbate2=None, bounds=None, opt_kwargs={}, + bond_tol=1e-8, parallel_run=False, comm=world, verbose=False, @@ -43,6 +44,8 @@ def __init__( if chosen. opt_kwargs: dict The keyword arguments for the simulated annealing optimization. + bond_tol: float + The bond tolerance used for the FixBondLengths. parallel_run: bool If True, the optimization will be run in parallel. comm: ASE communicator instance @@ -52,7 +55,7 @@ def __init__( not (False). """ # Create the atoms object from the slab and adsorbate - self.create_slab_ads(slab, adsorbate, adsorbate2) + self.create_slab_ads(slab, adsorbate, adsorbate2, bond_tol=bond_tol) # Create the boundary conditions self.setup_bounds(bounds) # Set the parameters @@ -69,7 +72,14 @@ def get_structures(self, get_all=True, **kwargs): structures.set_constraint(self.constraints_org) return structures - def create_slab_ads(self, slab, adsorbate, adsorbate2=None): + def create_slab_ads( + self, + slab, + adsorbate, + adsorbate2=None, + bond_tol=1e-8, + **kwargs, + ): """ Create the structure for the adsorption optimization. @@ -80,6 +90,8 @@ def create_slab_ads(self, slab, adsorbate, adsorbate2=None): The adsorbate structure. adsorbate2: Atoms object (optional) The second adsorbate structure. + bond_tol: float + The bond tolerance used for the FixBondLengths. Returns: self: object @@ -88,6 +100,8 @@ def create_slab_ads(self, slab, adsorbate, adsorbate2=None): # Check the slab and adsorbate are given if slab is None or adsorbate is None: raise Exception("The slab and adsorbate must be given!") + # Save the bond length tolerance + self.bond_tol = float(bond_tol) # Setup the slab self.n_slab = len(slab) self.slab = slab.copy() @@ -128,21 +142,45 @@ def create_slab_ads(self, slab, adsorbate, adsorbate2=None): self.constraints_used = [FixAtoms(indices=list(range(self.n_slab)))] self.constraints_new = [FixAtoms(indices=list(range(self.n_slab)))] if self.n_ads > 1: - pairs = list( - itertools.combinations( - range(self.n_slab, self.n_slab + self.n_ads), - 2, + # Get the fixed bond length pairs + pairs = itertools.combinations( + range(self.n_slab, self.n_slab + self.n_ads), + 2, + ) + pairs = np.array(list(pairs)) + # Get the bond lengths + bondlengths = np.linalg.norm( + self.positions0[pairs[:, 0]] - self.positions0[pairs[:, 1]], + axis=1, + ) + # Add the constraints + self.constraints_new.append( + FixBondLengths( + pairs=pairs, + tolerance=self.bond_tol, + bondlengths=bondlengths, ) ) - self.constraints_new.append(FixBondLengths(pairs=pairs)) if self.n_ads2 > 1: - pairs = list( - itertools.combinations( - range(self.n_slab + self.n_ads, self.natoms), - 2, + # Get the fixed bond length pairs + pairs = itertools.combinations( + range(self.n_slab + self.n_ads, self.natoms), + 2, + ) + pairs = np.array(list(pairs)) + # Get the bond lengths + bondlengths = np.linalg.norm( + self.positions0[pairs[:, 0]] - self.positions0[pairs[:, 1]], + axis=1, + ) + # Add the constraints + self.constraints_new.append( + FixBondLengths( + pairs=pairs, + tolerance=self.bond_tol, + bondlengths=bondlengths, ) ) - self.constraints_new.append(FixBondLengths(pairs=pairs)) optimizable.set_constraint(self.constraints_new) # Setup the optimizable structure self.setup_optimizable(optimizable) @@ -233,6 +271,7 @@ def update_arguments( adsorbate2=None, bounds=None, opt_kwargs=None, + bond_tol=None, parallel_run=None, comm=None, verbose=None, @@ -243,12 +282,27 @@ def update_arguments( The existing arguments are used if they are not given. Parameters: - atoms: Atoms object - The atoms object to be optimized. + slab: Atoms instance + The slab structure. + adsorbate: Atoms instance + The adsorbate structure. + adsorbate2: Atoms instance (optional) + The second adsorbate structure. + bounds : (6,2) or (12,2) ndarray (optional). + The boundary conditions used for the global optimization in + form of the simulated annealing. + The boundary conditions are the x, y, and z coordinates of + the center of the adsorbate and 3 rotations. + Same boundary conditions can be set for the second adsorbate + if chosen. + opt_kwargs: dict + The keyword arguments for the simulated annealing optimization. + bond_tol: float + The bond tolerance used for the FixBondLengths. parallel_run: bool If True, the optimization will be run in parallel. - comm: ASE communicator object - The communicator object for parallelization. + comm: ASE communicator instance + The communicator instance for parallelization. verbose: bool Whether to print the full output (True) or not (False). @@ -261,9 +315,16 @@ def update_arguments( # Set the verbose if verbose is not None: self.verbose = verbose + if bond_tol is not None: + self.bond_tol = float(bond_tol) # Create the atoms object from the slab and adsorbate if slab is not None and adsorbate is not None: - self.create_slab_ads(slab, adsorbate, adsorbate2) + self.create_slab_ads( + slab, + adsorbate, + adsorbate2, + bond_tol=self.bond_tol, + ) # Create the boundary conditions if bounds is not None: self.setup_bounds(bounds) From 589704886d350cde27be4b0a8bdb7716790b964d Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Wed, 20 Nov 2024 09:42:49 +0100 Subject: [PATCH 067/194] More documentation on NEB interpolation in MLNEB --- catlearn/activelearning/mlneb.py | 13 +++++++++++-- catlearn/optimizer/localcineb.py | 13 +++++++++++-- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/catlearn/activelearning/mlneb.py b/catlearn/activelearning/mlneb.py index 9d492b20..10a49038 100644 --- a/catlearn/activelearning/mlneb.py +++ b/catlearn/activelearning/mlneb.py @@ -82,11 +82,20 @@ def __init__( climb : bool Whether to use the climbing image in the NEB. It is strongly recommended to have climb=True. - neb_interpolation : str + neb_interpolation : str or list of ASE Atoms or ASE Trajectory file The interpolation method used to create the NEB path. - The default is 'linear'. + The string can be: + - 'linear' (default) + - 'idpp' + - 'rep' + - 'ends' + Otherwise, the premade images can be given as a list of + ASE Atoms. + A string of the ASE Trajectory file that contains the images + can also be given. neb_interpolation_kwargs : dict The keyword arguments for the interpolation method. + It is only used when the interpolation method is a string. reuse_ci_path : bool Whether to restart from the climbing image path when the NEB without climbing image is converged. diff --git a/catlearn/optimizer/localcineb.py b/catlearn/optimizer/localcineb.py index 8e341a5e..b0d964f7 100644 --- a/catlearn/optimizer/localcineb.py +++ b/catlearn/optimizer/localcineb.py @@ -51,11 +51,20 @@ def __init__( climb : bool Whether to use the climbing image in the NEB. It is strongly recommended to have climb=True. - neb_interpolation : str + neb_interpolation : str or list of ASE Atoms or ASE Trajectory file The interpolation method used to create the NEB path. - The default is 'linear'. + The string can be: + - 'linear' (default) + - 'idpp' + - 'rep' + - 'ends' + Otherwise, the premade images can be given as a list of + ASE Atoms. + A string of the ASE Trajectory file that contains the images + can also be given. neb_interpolation_kwargs : dict The keyword arguments for the interpolation method. + It is only used when the interpolation method is a string. reuse_ci_path : bool Whether to remove the non-climbing image method when the NEB without climbing image is converged. From c83b9f8e85af4927c21da888d9e24e4a034bded3 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Fri, 22 Nov 2024 07:48:06 +0100 Subject: [PATCH 068/194] Define optimize_hp in setup_mlcalc. Use kwargs in calc and get_default_mlmodel --- catlearn/activelearning/activelearning.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/catlearn/activelearning/activelearning.py b/catlearn/activelearning/activelearning.py index a20906d7..600385e6 100644 --- a/catlearn/activelearning/activelearning.py +++ b/catlearn/activelearning/activelearning.py @@ -346,10 +346,12 @@ def setup_mlcalc( use_derivatives=True, database_reduction=False, calc_forces=True, + optimize_hp=True, bayesian=True, kappa=2.0, reuse_mlcalc_data=False, verbose=True, + calc_kwargs={}, **kwargs, ): """ @@ -383,6 +385,9 @@ def setup_mlcalc( Whether to reduce the database. calc_forces: bool Whether to calculate the forces for all energy predictions. + optimize_hp: bool + Whether to optimize the hyperparameters when the model is + trained. bayesian: bool Whether to use the Bayesian optimization calculator. kappa: float @@ -393,6 +398,8 @@ def setup_mlcalc( verbose: bool Whether to print on screen the full output (True) or not (False). + calc_kwargs: dict + The keyword arguments for the ML calculator. Returns: self: The object itself. @@ -446,7 +453,9 @@ def setup_mlcalc( use_derivatives=use_derivatives, parallel=(not save_memory), database_reduction=database_reduction, + optimize_hp=optimize_hp, verbose=verbose, + **kwargs, ) # Get the data from a previous mlcalc if requested and it exist if reuse_mlcalc_data: @@ -460,6 +469,7 @@ def setup_mlcalc( mlmodel=mlmodel, calc_forces=calc_forces, kappa=kappa, + **calc_kwargs, ) if not use_derivatives and kappa > 0.0: if world.rank == 0: @@ -472,6 +482,7 @@ def setup_mlcalc( self.mlcalc = MLCalculator( mlmodel=mlmodel, calc_forces=calc_forces, + **calc_kwargs, ) # Reuse the data from a previous mlcalc if requested if reuse_mlcalc_data: From 1d730f189bba7142732ab131998bfee8ec81b48e Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Fri, 22 Nov 2024 08:04:25 +0100 Subject: [PATCH 069/194] Use parent parameters if not specified --- catlearn/activelearning/adsorption.py | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/catlearn/activelearning/adsorption.py b/catlearn/activelearning/adsorption.py index d5fa0984..4d66c179 100644 --- a/catlearn/activelearning/adsorption.py +++ b/catlearn/activelearning/adsorption.py @@ -288,18 +288,12 @@ def extra_initial_data(self, **kwargs): def setup_mlcalc( self, mlcalc=None, - save_memory=False, fp=None, atoms=None, - prior=None, baseline=RepulsionCalculator(), use_derivatives=True, - database_reduction=False, calc_forces=False, - bayesian=True, kappa=-2.0, - reuse_mlcalc_data=False, - verbose=True, **kwargs, ): if mlcalc is None: @@ -331,18 +325,12 @@ def setup_mlcalc( ) return super().setup_mlcalc( mlcalc=mlcalc, - save_memory=save_memory, fp=fp, atoms=atoms, - prior=prior, baseline=baseline, use_derivatives=use_derivatives, - database_reduction=database_reduction, calc_forces=calc_forces, - bayesian=bayesian, kappa=kappa, - reuse_mlcalc_data=reuse_mlcalc_data, - verbose=verbose, **kwargs, ) From 0b4b1999beea0a57a5740dd59944686e596a6b20 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Thu, 28 Nov 2024 08:50:21 +0100 Subject: [PATCH 070/194] Copy the end structures and set default remove_rotation_and_translation to False --- catlearn/structures/neb/interpolate_band.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/catlearn/structures/neb/interpolate_band.py b/catlearn/structures/neb/interpolate_band.py index e06d5c2a..8b7c114a 100644 --- a/catlearn/structures/neb/interpolate_band.py +++ b/catlearn/structures/neb/interpolate_band.py @@ -1,6 +1,7 @@ import numpy as np from ase.io import read from ase.optimize import FIRE +from ase.build import minimize_rotation_and_translation from ...regression.gp.calculator.copy_atoms import copy_atoms @@ -11,18 +12,19 @@ def interpolate( n_images=15, method="linear", mic=True, - remove_rotation_and_translation=True, + remove_rotation_and_translation=False, **interpolation_kwargs, ): """ Make an interpolation between the start and end structure. A transition state structure can be given to guide the interpolation. """ + # Copy the start and end structures + start = copy_atoms(start) + end = copy_atoms(end) # The rotation and translation should be removed the end structure # is optimized compared to start structure if remove_rotation_and_translation: - from ase.build import minimize_rotation_and_translation - start.center() end.center() minimize_rotation_and_translation(start, end) @@ -38,6 +40,8 @@ def interpolate( **interpolation_kwargs, ) return images + # Copy the transition state structure + ts = copy_atoms(ts) # Get the interpolated path from the start structure to the TS structure images = make_interpolation( start, From d63a67539c3a21b6e97cf8654b49fa3a06b9deb5 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Thu, 28 Nov 2024 15:56:54 +0100 Subject: [PATCH 071/194] Load the summary table when active learning is restarted --- catlearn/activelearning/activelearning.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/catlearn/activelearning/activelearning.py b/catlearn/activelearning/activelearning.py index 600385e6..d0b4f707 100644 --- a/catlearn/activelearning/activelearning.py +++ b/catlearn/activelearning/activelearning.py @@ -1463,6 +1463,17 @@ def restart_optimization( self.update_method(self.structures) # Set the writing mode self.mode = "a" + # Load the summary table + if self.tabletxt is not None: + with open(self.tabletxt, "r") as thefile: + self.print_list = [ + line.replace("\n", "") for line in thefile + ] + # Update the total steps + self.steps = len(self.print_list) - 1 + # Make a reference energy + atoms_ref = copy_atoms(prev_calculations[0]) + self.e_ref = atoms_ref.get_potential_energy() except Exception: self.message_system( "Warning: Restart is not possible! " From df38fddfe3cbec39a4d56362dde5f0dc712051f8 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Fri, 29 Nov 2024 08:13:59 +0100 Subject: [PATCH 072/194] New version --- catlearn/_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/catlearn/_version.py b/catlearn/_version.py index 5ce7cf0a..2f3a1abb 100644 --- a/catlearn/_version.py +++ b/catlearn/_version.py @@ -1,3 +1,3 @@ -__version__ = "6.0.0" +__version__ = "6.0.1" __all__ = ["__version__"] From 715f36cf9477777732698e5fc908ef629e2fe401 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Tue, 3 Dec 2024 12:25:12 +0100 Subject: [PATCH 073/194] Set default max_unc value --- catlearn/activelearning/activelearning.py | 4 +++- catlearn/activelearning/mlgo.py | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/catlearn/activelearning/activelearning.py b/catlearn/activelearning/activelearning.py index d0b4f707..b8884044 100644 --- a/catlearn/activelearning/activelearning.py +++ b/catlearn/activelearning/activelearning.py @@ -205,7 +205,7 @@ def run( fmax=0.05, steps=200, ml_steps=1000, - max_unc=None, + max_unc=0.3, dtrust=None, seed=None, **kwargs, @@ -223,6 +223,8 @@ def run( on the predicted landscape. max_unc: float (optional) Maximum uncertainty for continuation of the optimization. + If max_unc is None, then the optimization is performed + without the maximum uncertainty. dtrust: float (optional) The trust distance for the optimization method. seed: int (optional) diff --git a/catlearn/activelearning/mlgo.py b/catlearn/activelearning/mlgo.py index dac8d693..26ea762c 100644 --- a/catlearn/activelearning/mlgo.py +++ b/catlearn/activelearning/mlgo.py @@ -278,7 +278,7 @@ def run( steps=200, ml_steps=4000, ml_steps_local=1000, - max_unc=None, + max_unc=0.3, dtrust=None, seed=None, **kwargs, @@ -298,6 +298,8 @@ def run( Maximum number of steps for the local optimization method. max_unc: float (optional) Maximum uncertainty for continuation of the optimization. + If max_unc is None, then the optimization is performed + without the maximum uncertainty. dtrust: float (optional) The trust distance for the optimization method. seed: int (optional) From 2708db7a9cbcbc4f5f7eb4609d36296ea47f92ce Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Mon, 9 Dec 2024 07:49:29 +0100 Subject: [PATCH 074/194] Parallelize over moving images --- catlearn/structures/neb/orgneb.py | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/catlearn/structures/neb/orgneb.py b/catlearn/structures/neb/orgneb.py index 67e76df4..f56c3bf6 100644 --- a/catlearn/structures/neb/orgneb.py +++ b/catlearn/structures/neb/orgneb.py @@ -69,11 +69,11 @@ def __init__( from ase.parallel import world self.world = world - if self.nimages % self.world.size != 0: + if (self.nimages - 2) % self.world.size != 0: if self.world.rank == 0: print( - "Warning: The number of images are not chosen optimal " - "for the number of processors when running in " + "Warning: The number of moving images are not chosen " + "optimal for the number of processors when running in " "parallel!" ) else: @@ -238,13 +238,8 @@ def calculate_properties_parallel(self, **kwargs): # Broadcast the results for i in range(1, self.nimages - 1): root = (i - 1) % self.world.size - self.energies[i : i + 1] = broadcast( - self.energies[i : i + 1], - root=root, - comm=self.world, - ) - self.real_forces[i : i + 1] = broadcast( - self.real_forces[i : i + 1], + self.energies[i], self.real_forces[i] = broadcast( + (self.energies[i], self.real_forces[i]), root=root, comm=self.world, ) From 31dbc3d8e6aa7e5c1830951378a017461b7ddba7 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Mon, 9 Dec 2024 08:08:52 +0100 Subject: [PATCH 075/194] Ensure any steps are left and converged is used as output in functions --- catlearn/optimizer/adsorption.py | 4 +++- catlearn/optimizer/local.py | 23 +++++++++++++++-------- catlearn/optimizer/sequential.py | 12 ++++++++---- 3 files changed, 26 insertions(+), 13 deletions(-) diff --git a/catlearn/optimizer/adsorption.py b/catlearn/optimizer/adsorption.py index 1f2fd46f..02be63db 100644 --- a/catlearn/optimizer/adsorption.py +++ b/catlearn/optimizer/adsorption.py @@ -234,6 +234,9 @@ def run( unc_convergence=None, **kwargs, ): + # Check if the optimization can take any steps + if steps <= 0: + return self._converged # Use original constraints self.optimizable.set_constraint(self.constraints_used) # Perform the simulated annealing @@ -243,7 +246,6 @@ def run( maxfun=steps, **self.opt_kwargs, ) - # Set the positions self.evaluate_value(sol["x"]) # Set the new constraints diff --git a/catlearn/optimizer/local.py b/catlearn/optimizer/local.py index b920184e..cc6f59aa 100644 --- a/catlearn/optimizer/local.py +++ b/catlearn/optimizer/local.py @@ -56,14 +56,18 @@ def run( unc_convergence=None, **kwargs, ): + # Check if the optimization can take any steps + if steps <= 0: + return self._converged # Run the local optimization with self.local_opt( self.optimizable, **self.local_opt_kwargs ) as optimizer: if max_unc is None and dtrust is None: optimizer.run(fmax=fmax, steps=steps) + converged = optimizer.converged() else: - optimizer = self.run_max_unc( + converged = self.run_max_unc( optimizer=optimizer, fmax=fmax, steps=steps, @@ -73,7 +77,7 @@ def run( ) # Check if the optimization is converged self._converged = self.check_convergence( - converged=optimizer.converged(), + converged=converged, max_unc=max_unc, dtrust=dtrust, unc_convergence=unc_convergence, @@ -106,9 +110,11 @@ def run_max_unc( The distance trust criterion. Returns: - optimizer: ASE optimizer instance - The optimizer instance. + converged: bool + Whether the optimization is converged. """ + # Set the converged parameter + converged = False # Make a copy of the atoms while self.steps < steps: # Check if the maximum number of steps is reached @@ -116,7 +122,7 @@ def run_max_unc( self.message("The maximum number of steps is reached.") break # Run a local optimization step - self.run_max_unc_step(optimizer, fmax=fmax, **kwargs) + _converged = self.run_max_unc_step(optimizer, fmax=fmax, **kwargs) # Check if the uncertainty is above the maximum allowed if max_unc is not None: # Get the uncertainty of the atoms @@ -136,9 +142,10 @@ def run_max_unc( self.message("The energy is NaN.") break # Check if the optimization is converged - if optimizer.converged(): + if _converged: + converged = True break - return optimizer + return converged def setup_local_optimizer(self, local_opt=None, local_opt_kwargs={}): """ @@ -228,7 +235,7 @@ def run_max_unc_step(self, optimizer, fmax=0.05, **kwargs): else: optimizer.run(fmax=fmax, steps=self.steps + 1, **kwargs) self.steps += 1 - return optimizer + return optimizer.converged() def get_arguments(self): "Get the arguments of the class itself." diff --git a/catlearn/optimizer/sequential.py b/catlearn/optimizer/sequential.py index 5d77252a..06e778f5 100644 --- a/catlearn/optimizer/sequential.py +++ b/catlearn/optimizer/sequential.py @@ -71,6 +71,9 @@ def run( unc_convergence=None, **kwargs, ): + # Check if the optimization can take any steps + if steps <= 0: + return self._converged # Get number of methods n_methods = len(self.methods) # Run the optimizations @@ -79,7 +82,7 @@ def run( if i > 0: method.update_optimizable(self.structures) # Run the optimization - method.run( + converged = method.run( fmax=fmax, steps=steps, max_unc=max_unc, @@ -93,11 +96,9 @@ def run( # Update the number of steps self.steps += method.get_number_of_steps() steps -= method.get_number_of_steps() - # Check if the optimization method is converged - converged = method.converged() # Check if the optimization is converged converged = self.check_convergence( - converged=method.converged(), + converged=converged, max_unc=max_unc, dtrust=dtrust, unc_convergence=unc_convergence, @@ -108,6 +109,9 @@ def run( if i + 1 == n_methods: self._converged = True break + # Check if any steps are left + if steps <= 0: + break # Check if the method should be removed if self.remove_methods and i + 1 < n_methods: self.methods = self.methods[1:] From 0808716ed5a58e63203cabc1f994c6d6e303f86b Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Mon, 9 Dec 2024 11:10:42 +0100 Subject: [PATCH 076/194] Debug parallelization of optimization methods by using the correct ranks --- catlearn/optimizer/localneb.py | 19 ++- catlearn/optimizer/method.py | 205 ++++++++++++++++++++++++++++-- catlearn/optimizer/parallelopt.py | 44 +++---- 3 files changed, 229 insertions(+), 39 deletions(-) diff --git a/catlearn/optimizer/localneb.py b/catlearn/optimizer/localneb.py index d3183163..9ee59c8a 100644 --- a/catlearn/optimizer/localneb.py +++ b/catlearn/optimizer/localneb.py @@ -1,5 +1,5 @@ from .local import LocalOptimizer -from ase.parallel import world +from ase.parallel import world, broadcast from ase.optimize import FIRE import numpy as np @@ -60,11 +60,28 @@ def get_structures(self, get_all=True, **kwargs): if not get_all: return self.copy_atoms(self.optimizable.images[0]) # Get all the images + if self.is_parallel_used(): + return self.get_structures_parallel(**kwargs) structures = [ self.copy_atoms(image) for image in self.optimizable.images ] return structures + def get_structures_parallel(self, **kwargs): + # Get the structures in parallel + structures = [self.copy_atoms(self.optimizable.images[0])] + for i, image in enumerate(self.optimizable.images[1:-1]): + root = i % self.size + structures.append( + broadcast( + self.copy_atoms(image), + root=root, + comm=self.comm, + ) + ) + structures.append(self.copy_atoms(self.optimizable.images[-1])) + return structures + def get_candidates(self, **kwargs): return self.optimizable.images[1:-1] diff --git a/catlearn/optimizer/method.py b/catlearn/optimizer/method.py index 68eae1e9..b974bf8e 100644 --- a/catlearn/optimizer/method.py +++ b/catlearn/optimizer/method.py @@ -1,5 +1,5 @@ import numpy as np -from ase.parallel import world +from ase.parallel import world, broadcast from ..regression.gp.calculator.copy_atoms import copy_atoms from ..structures.structure import Structure @@ -161,6 +161,8 @@ def get_potential_energy(self, per_candidate=False, **kwargs): The potential energy of the optimizable. """ if per_candidate: + if self.is_parallel_used(): + return self.get_potential_energy_parallel(**kwargs) energy = [ atoms.get_potential_energy(**kwargs) for atoms in self.get_candidates() @@ -169,6 +171,24 @@ def get_potential_energy(self, per_candidate=False, **kwargs): energy = self.optimizable.get_potential_energy(**kwargs) return energy + def get_potential_energy_parallel(self, **kwargs): + """ + Get the potential energies of the candidates in parallel. + + Returns: + energy: list of floats + The potential energies of the candidates. + """ + energy = [] + for i, atoms in enumerate(self.get_candidates()): + root = i % self.size + e = None + if self.rank == root: + e = atoms.get_potential_energy(**kwargs) + e = broadcast(e, root=root, comm=self.comm) + energy.append(e) + return energy + def get_forces(self, per_candidate=False, **kwargs): """ Get the forces of the optimizable. @@ -183,12 +203,32 @@ def get_forces(self, per_candidate=False, **kwargs): The forces of the optimizable. """ if per_candidate: - force = [ + if self.is_parallel_used(): + return self.get_forces_parallel(**kwargs) + forces = [ atoms.get_forces(**kwargs) for atoms in self.get_candidates() ] else: - force = self.optimizable.get_forces(**kwargs) - return force + forces = self.optimizable.get_forces(**kwargs) + return forces + + def get_forces_parallel(self, **kwargs): + """ + Get the forces of the candidates in parallel. + + Returns: + forces: list of (N,3) arrays + The forces of the candidates. + """ + forces = [] + for i, atoms in enumerate(self.get_candidates()): + root = i % self.size + f = None + if self.rank == root: + f = atoms.get_forces(**kwargs) + f = broadcast(f, root=root, comm=self.comm) + forces.append(f) + return forces def get_fmax(self, per_candidate=False, **kwargs): """ @@ -221,21 +261,51 @@ def get_uncertainty(self, per_candidate=False, **kwargs): uncertainty: float or list The uncertainty of the optimizable. """ - uncertainty = [] - for atoms in self.get_candidates(): - if isinstance(atoms, Structure): - unc = atoms.get_uncertainty(**kwargs) - else: - unc = atoms.calc.get_property( - "uncertainty", - atoms=atoms, - **kwargs, + if self.is_parallel_used(): + uncertainty = self.get_uncertainty_parallel(**kwargs) + else: + uncertainty = [ + ( + atoms.get_uncertainty(**kwargs) + if isinstance(atoms, Structure) + else atoms.calc.get_property( + "uncertainty", + atoms=atoms, + **kwargs, + ) ) - uncertainty.append(unc) + for atoms in self.get_candidates() + ] if not per_candidate: uncertainty = np.max(uncertainty) return uncertainty + def get_uncertainty_parallel(self, **kwargs): + """ + Get the uncertainty of the candidates in parallel. + It is used for active learning. + + Returns: + uncertainty: list of floats + The uncertainty of the candidates. + """ + uncertainty = [] + for i, atoms in enumerate(self.get_candidates()): + root = i % self.size + unc = None + if self.rank == root: + if isinstance(atoms, Structure): + unc = atoms.get_uncertainty(**kwargs) + else: + unc = atoms.calc.get_property( + "uncertainty", + atoms=atoms, + **kwargs, + ) + unc = broadcast(unc, root=root, comm=self.comm) + uncertainty.append(unc) + return uncertainty + def get_property( self, name, @@ -259,6 +329,12 @@ def get_property( float or list: The requested property. """ if per_candidate: + if self.is_parallel_used(): + return self.get_property_parallel( + name, + allow_calculation, + **kwargs, + ) output = [] for atoms in self.get_candidates(): if name == "energy": @@ -302,6 +378,49 @@ def get_property( ) return output + def get_property_parallel( + self, + name, + allow_calculation=True, + **kwargs, + ): + """ + Get the requested property of the candidates in parallel. + + Parameters: + name: str + The name of the requested property. + allow_calculation: bool + Whether the property is allowed to be calculated. + + Returns: + list: The list of requested property. + """ + output = [] + for i, atoms in enumerate(self.get_candidates()): + root = i % self.size + result = None + if self.rank == root: + if name == "energy": + result = atoms.get_potential_energy(**kwargs) + elif name == "forces": + result = atoms.get_forces(**kwargs) + elif name == "fmax": + force = atoms.get_forces(**kwargs) + result = np.linalg.norm(force, axis=-1).max() + elif name == "uncertainty" and isinstance(atoms, Structure): + result = atoms.get_uncertainty(**kwargs) + else: + result = atoms.calc.get_property( + name, + atoms=atoms, + allow_calculation=allow_calculation, + **kwargs, + ) + result = broadcast(result, root=root, comm=self.comm) + output.append(result) + return output + def get_properties( self, properties, @@ -325,6 +444,12 @@ def get_properties( dict: The requested properties. """ if per_candidate: + if self.is_parallel_used(): + return self.get_properties_parallel( + properties, + allow_calculation, + **kwargs, + ) results = {name: [] for name in properties} for atoms in self.get_candidates(): for name in properties: @@ -358,6 +483,52 @@ def get_properties( ) return results + def get_properties_parallel( + self, + properties, + allow_calculation=True, + **kwargs, + ): + """ + Get the requested properties of the candidates in parallel. + + Parameters: + properties: list of str + The names of the requested properties. + allow_calculation: bool + Whether the properties are allowed to be calculated. + + Returns: + dict: The requested properties. + """ + results = {name: [] for name in properties} + for i, atoms in enumerate(self.get_candidates()): + root = i % self.size + for name in properties: + output = None + if self.rank == root: + if name == "energy": + output = atoms.get_potential_energy(**kwargs) + elif name == "forces": + output = atoms.get_forces(**kwargs) + elif name == "fmax": + force = atoms.get_forces(**kwargs) + output = np.linalg.norm(force, axis=-1).max() + elif name == "uncertainty" and isinstance( + atoms, Structure + ): + output = atoms.get_uncertainty(**kwargs) + else: + output = atoms.calc.get_property( + name, + atoms=atoms, + allow_calculation=allow_calculation, + **kwargs, + ) + output = broadcast(output, root=root, comm=self.comm) + results[name].append(output) + return results + def is_within_dtrust(self, per_candidate=False, dtrust=2.0, **kwargs): """ Get whether the structures are within a trust distance to the database. @@ -422,6 +593,12 @@ def is_parallel_allowed(self): """ return False + def is_parallel_used(self): + """ + Check if the optimization method uses parallelization. + """ + return self.parallel_run and self.is_parallel_allowed() + def run( self, fmax=0.05, diff --git a/catlearn/optimizer/parallelopt.py b/catlearn/optimizer/parallelopt.py index 8f868541..39b429cb 100644 --- a/catlearn/optimizer/parallelopt.py +++ b/catlearn/optimizer/parallelopt.py @@ -71,21 +71,23 @@ def run( unc_convergence=None, **kwargs, ): - # Set the rank - rank = 0 + # Check if the optimization can take any steps + if steps <= 0: + return self._converged # Make list of properties structures = [None] * self.chains - candidates = [None] * self.chains + candidates = [[]] * self.chains converged = [False] * self.chains used_steps = [self.steps] * self.chains values = [np.inf] * self.chains # Run the optimizations for chain, method in enumerate(self.methods): - if self.rank == rank: + root = chain % self.size + if self.rank == root: # Set the random seed np.random.RandomState(chain + 1) # Run the optimization - method.run( + converged[chain] = method.run( fmax=fmax, steps=steps, max_unc=max_unc, @@ -98,50 +100,44 @@ def run( structures[chain] = method.get_structures() # Get the candidates candidates[chain] = method.get_candidates() - # Check if the optimization is converged - converged[chain] = method.converged() # Get the values if self.method.is_energy_minimized(): values[chain] = method.get_potential_energy() else: values[chain] = method.get_fmax() - - # Update the rank - rank += 1 - if rank == self.size: - rank = 0 # Broadcast the saved instances - rank = 0 for chain in range(self.chains): + root = chain % self.size structures[chain] = broadcast( structures[chain], - root=rank, + root=root, comm=self.comm, ) - candidates[chain] = broadcast( - candidates[chain], - root=rank, + candidates_tmp = broadcast( + [ + self.copy_atoms(candidate) + for candidate in candidates[chain] + ], + root=root, comm=self.comm, ) + if self.rank != root: + candidates[chain] = candidates_tmp converged[chain] = broadcast( converged[chain], - root=rank, + root=root, comm=self.comm, ) used_steps[chain] = broadcast( used_steps[chain], - root=rank, + root=root, comm=self.comm, ) values[chain] = broadcast( values[chain], - root=rank, + root=root, comm=self.comm, ) - # Update the rank - rank += 1 - if rank == self.size: - rank = 0 # Set the candidates self.candidates = [] for candidate_inner in candidates: From 93dcdc96771539de910d443c6bf74116bbbab827 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Wed, 18 Dec 2024 11:40:29 +0100 Subject: [PATCH 077/194] Edit docstrings and make updates of mlmodel possible --- catlearn/regression/gp/calculator/bocalc.py | 18 ++++---- catlearn/regression/gp/calculator/mlcalc.py | 46 ++++++++++++++------- 2 files changed, 39 insertions(+), 25 deletions(-) diff --git a/catlearn/regression/gp/calculator/bocalc.py b/catlearn/regression/gp/calculator/bocalc.py index 91c5ed03..fdaeee9a 100644 --- a/catlearn/regression/gp/calculator/bocalc.py +++ b/catlearn/regression/gp/calculator/bocalc.py @@ -32,7 +32,7 @@ def __init__( applicable as an ASE calculator. Parameters: - mlmodel : MLModel class object + mlmodel: MLModel class object Machine Learning model used for ASE Atoms and calculator. The object must have the functions: calculate, train_model, and add_training. @@ -47,10 +47,10 @@ def __init__( calc_unc_deriv: bool Whether to calculate the derivatives of the uncertainty of the energy. - calc_kwargs : dict + calc_kwargs: dict A dictionary with kwargs for the parent calculator class object. - kappa : float + kappa: float The weight of the uncertainty relative to the energy. If kappa>0, the uncertainty is added to the predicted energy. """ @@ -70,7 +70,7 @@ def get_predicted_energy(self, atoms=None, **kwargs): Get the predicted energy without the uncertainty. Parameters: - atoms : ASE Atoms (optional) + atoms: ASE Atoms (optional) The ASE Atoms instance which is used if the uncertainty is not stored. @@ -84,7 +84,7 @@ def get_predicted_forces(self, atoms=None, **kwargs): Get the predicted forces without the derivatives of the uncertainty. Parameters: - atoms : ASE Atoms (optional) + atoms: ASE Atoms (optional) The ASE Atoms instance which is used if the uncertainty is not stored. @@ -114,7 +114,7 @@ def calculate( and predicted forces using *atoms.calc.get_predicted_forces(atoms)*. Returns: - self.results : dict + self.results: dict A dictionary with all the calculated properties. """ # Atoms object. @@ -169,7 +169,7 @@ def update_arguments( The existing arguments are used if they are not given. Parameters: - mlmodel : MLModel class object + mlmodel: MLModel class object Machine Learning model used for ASE Atoms and calculator. The object must have the functions: calculate, train_model, and add_training. @@ -184,10 +184,10 @@ def update_arguments( calc_unc_deriv: bool Whether to calculate the derivatives of the uncertainty of the energy. - calc_kwargs : dict + calc_kwargs: dict A dictionary with kwargs for the parent calculator class object. - kappa : float + kappa: float The weight of the uncertainty relative to the energy. Returns: diff --git a/catlearn/regression/gp/calculator/mlcalc.py b/catlearn/regression/gp/calculator/mlcalc.py index 9872a71a..55059edb 100644 --- a/catlearn/regression/gp/calculator/mlcalc.py +++ b/catlearn/regression/gp/calculator/mlcalc.py @@ -27,7 +27,7 @@ def __init__( ML calculator object applicable as an ASE calculator. Parameters: - mlmodel : MLModel class object + mlmodel: MLModel class object Machine Learning model used for ASE Atoms and calculator. The object must have the functions: calculate, train_model, and add_training. @@ -42,7 +42,7 @@ def __init__( calc_unc_deriv: bool Whether to calculate the derivatives of the uncertainty of the energy. - calc_kwargs : dict + calc_kwargs: dict A dictionary with kwargs for the parent calculator class object. """ @@ -74,7 +74,7 @@ def get_uncertainty(self, atoms=None, **kwargs): Get the predicted uncertainty of the energy. Parameters: - atoms : ASE Atoms (optional) + atoms: ASE Atoms (optional) The ASE Atoms instance which is used if the uncertainty is not stored. @@ -88,7 +88,7 @@ def get_force_uncertainty(self, atoms=None, **kwargs): Get the predicted uncertainty of the forces. Parameters: - atoms : ASE Atoms (optional) + atoms: ASE Atoms (optional) The ASE Atoms instance which is used if the force uncertainties are not stored. @@ -102,7 +102,7 @@ def get_uncertainty_derivatives(self, atoms=None, **kwargs): Get the derivatives of the uncertainty of the energy. Parameters: - atoms : ASE Atoms (optional) + atoms: ASE Atoms (optional) The ASE Atoms instance which is used if the derivatives of the uncertainty are not stored. @@ -116,7 +116,7 @@ def set_atoms(self, atoms, **kwargs): Save the ASE Atoms instance in the calculator. Parameters: - atoms : ASE Atoms + atoms: ASE Atoms The ASE Atoms instance that are saved. Returns: @@ -132,7 +132,7 @@ def add_training(self, atoms_list, **kwarg): Add training data as ASE Atoms to the ML model. Parameters: - atoms_list : list or ASE Atoms + atoms_list: list or ASE Atoms A list of or a single ASE Atoms with calculated energies and forces. @@ -158,7 +158,7 @@ def save_data(self, trajectory="data.traj", **kwarg): Save the ASE Atoms data to a trajectory. Parameters: - trajectory : str + trajectory: str The name of the trajectory file where the data is saved. Returns: @@ -190,7 +190,7 @@ def is_in_database(self, atoms, **kwargs): Check if the ASE Atoms is in the database. Parameters: - atoms : ASE Atoms + atoms: ASE Atoms The ASE Atoms instance with a calculator. Returns: @@ -203,7 +203,7 @@ def copy_atoms(self, atoms, **kwargs): Copy the atoms object together with the calculated properties. Parameters: - atoms : ASE Atoms + atoms: ASE Atoms The ASE Atoms object with a calculator that is copied. Returns: @@ -212,12 +212,26 @@ def copy_atoms(self, atoms, **kwargs): """ return self.mlmodel.copy_atoms(atoms, **kwargs) + def update_mlmodel_arguments(self, **kwargs): + """ + Update the arguments in the ML model. + + Parameters: + kwargs: dict + A dictionary with the arguments to update. + + Returns: + self: The updated object itself. + """ + self.mlmodel.update_arguments(**kwargs) + return self + def update_database_arguments(self, point_interest=None, **kwargs): """ Update the arguments in the database. Parameters: - point_interest : list + point_interest: list A list of the points of interest as ASE Atoms instances. Returns: @@ -247,7 +261,7 @@ def calculate( using *atoms.calc.get_uncertainty_derivatives(atoms)*. Returns: - self.results : dict + self.results: dict A dictionary with all the calculated properties. """ # Atoms object @@ -278,7 +292,7 @@ def save_mlcalc(self, filename="mlcalc.pkl", **kwargs): Save the ML calculator object to a file. Parameters: - filename : str + filename: str The name of the file where the object is saved. Returns: @@ -295,7 +309,7 @@ def load_mlcalc(self, filename="mlcalc.pkl", **kwargs): Load the ML calculator object from a file. Parameters: - filename : str + filename: str The name of the file where the object is saved. Returns: @@ -322,7 +336,7 @@ def update_arguments( The existing arguments are used if they are not given. Parameters: - mlmodel : MLModel class object + mlmodel: MLModel class object Machine Learning model used for ASE Atoms and calculator. The object must have the functions: calculate, train_model, and add_training. @@ -337,7 +351,7 @@ def update_arguments( calc_unc_deriv: bool Whether to calculate the derivatives of the uncertainty of the energy. - calc_kwargs : dict + calc_kwargs: dict A dictionary with kwargs for the parent calculator class object. From 36482cdd34d8091b2c41ba79361c16db8a380861 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Wed, 18 Dec 2024 11:41:46 +0100 Subject: [PATCH 078/194] Edit docstrings and set whether to include noise in uncertainty --- .../regression/gp/calculator/hiermodel.py | 54 +++++--- catlearn/regression/gp/calculator/mlmodel.py | 122 ++++++++++-------- 2 files changed, 101 insertions(+), 75 deletions(-) diff --git a/catlearn/regression/gp/calculator/hiermodel.py b/catlearn/regression/gp/calculator/hiermodel.py index 3169f177..80bee35d 100644 --- a/catlearn/regression/gp/calculator/hiermodel.py +++ b/catlearn/regression/gp/calculator/hiermodel.py @@ -12,6 +12,7 @@ def __init__( optimize=True, hp=None, pdis=None, + include_noise=False, verbose=False, npoints=25, initial_indicies=[0], @@ -25,27 +26,29 @@ def __init__( The old models are used as a baseline. Parameters: - model : Model + model: Model The Machine Learning Model with kernel and prior that are optimized. - database : Database object + database: Database object The Database object with ASE atoms. - baseline : Baseline object + baseline: Baseline object The Baseline object calculator that calculates energy and forces. - optimize : bool + optimize: bool Whether to optimize the hyperparameters when the model is trained. - hp : dict + hp: dict Use a set of hyperparameters to optimize from else the current set is used. - pdis : dict + pdis: dict A dict of prior distributions for each hyperparameter type. - verbose : bool + include_noise: bool + Whether to include noise in the uncertainty from the model. + verbose: bool Whether to print statements in the optimization. - npoints : int + npoints: int Number of points that are used from the database in the models. - initial_indicies : list + initial_indicies: list The indicies of the data points that must be included in the used data base for every model. """ @@ -56,6 +59,7 @@ def __init__( optimize=optimize, hp=hp, pdis=pdis, + include_noise=include_noise, verbose=verbose, npoints=npoints, initial_indicies=initial_indicies, @@ -67,7 +71,7 @@ def add_training(self, atoms_list, **kwargs): Add training data in form of the ASE Atoms to the database. Parameters: - atoms_list : list or ASE Atoms + atoms_list: list or ASE Atoms A list of or a single ASE Atoms with calculated energies and forces. @@ -109,6 +113,7 @@ def update_arguments( optimize=None, hp=None, pdis=None, + include_noise=None, verbose=None, npoints=None, initial_indicies=None, @@ -119,27 +124,29 @@ def update_arguments( The existing arguments are used if they are not given. Parameters: - model : Model + model: Model The Machine Learning Model with kernel and prior that are optimized. - database : Database object + database: Database object The Database object with ASE atoms. - baseline : Baseline object + baseline: Baseline object The Baseline object calculator that calculates energy and forces. - optimize : bool + optimize: bool Whether to optimize the hyperparameters when the model is trained. - hp : dict + hp: dict Use a set of hyperparameters to optimize from else the current set is used. - pdis : dict + pdis: dict A dict of prior distributions for each hyperparameter type. - verbose : bool + include_noise: bool + Whether to include noise in the uncertainty from the model. + verbose: bool Whether to print statements in the optimization. - npoints : int + npoints: int Number of points that are used from the database in the models. - initial_indicies : list + initial_indicies: list The indicies of the data points that must be included in the used data base for every model. @@ -152,12 +159,20 @@ def update_arguments( self.database = database.copy() if baseline is not None: self.baseline = baseline.copy() + elif not hasattr(self, "baseline"): + self.baseline = None if optimize is not None: self.optimize = optimize if hp is not None: self.hp = hp.copy() + elif not hasattr(self, "hp"): + self.hp = None if pdis is not None: self.pdis = pdis.copy() + elif not hasattr(self, "pdis"): + self.pdis = None + if include_noise is not None: + self.include_noise = include_noise if verbose is not None: self.verbose = verbose if npoints is not None: @@ -186,6 +201,7 @@ def get_arguments(self): optimize=self.optimize, hp=self.hp, pdis=self.pdis, + include_noise=self.include_noise, verbose=self.verbose, npoints=self.npoints, initial_indicies=self.initial_indicies, diff --git a/catlearn/regression/gp/calculator/mlmodel.py b/catlearn/regression/gp/calculator/mlmodel.py index 4e5a68f8..e30ee0e7 100644 --- a/catlearn/regression/gp/calculator/mlmodel.py +++ b/catlearn/regression/gp/calculator/mlmodel.py @@ -10,6 +10,7 @@ def __init__( optimize=True, hp=None, pdis=None, + include_noise=False, verbose=False, **kwargs, ): @@ -17,23 +18,25 @@ def __init__( Machine Learning model used for ASE Atoms and calculator. Parameters: - model : Model + model: Model The Machine Learning Model with kernel and prior that are optimized. - database : Database object + database: Database object The Database object with ASE atoms. - baseline : Baseline object + baseline: Baseline object The Baseline object calculator that calculates energy and forces. - optimize : bool + optimize: bool Whether to optimize the hyperparameters when the model is trained. - hp : dict + hp: dict Use a set of hyperparameters to optimize from else the current set is used. - pdis : dict + pdis: dict A dict of prior distributions for each hyperparameter type. - verbose : bool + include_noise: bool + Whether to include noise in the uncertainty from the model. + verbose: bool Whether to print statements in the optimization. """ # Make default model if it is not given @@ -50,6 +53,7 @@ def __init__( optimize=optimize, hp=hp, pdis=pdis, + include_noise=include_noise, verbose=verbose, **kwargs, ) @@ -59,7 +63,7 @@ def add_training(self, atoms_list, **kwargs): Add training data in form of the ASE Atoms to the database. Parameters: - atoms_list : list or ASE Atoms + atoms_list: list or ASE Atoms A list of or a single ASE Atoms with calculated energies and forces. @@ -106,29 +110,29 @@ def calculate( If get_variance=False, variance is returned as None. Parameters: - atoms : ASE Atoms + atoms: ASE Atoms The ASE Atoms object that the properties (incl. energy) are calculated for. - get_uncertainty : bool + get_uncertainty: bool Whether to calculate the uncertainty. The uncertainty is None if get_uncertainty=False. - get_forces : bool + get_forces: bool Whether to calculate the forces. - get_force_uncertainties : bool + get_force_uncertainties: bool Whether to calculate the uncertainties of the predicted forces. - get_unc_derivatives : bool + get_unc_derivatives: bool Whether to calculate the derivatives of the uncertainty of the predicted energy. Returns: - energy : float + energy: float The predicted energy of the ASE Atoms. - forces : (Nat,3) array or None + forces: (Nat,3) array or None The predicted forces if get_forces=True. - uncertainty : float or None + uncertainty: float or None The predicted uncertainty of the energy if get_uncertainty=True. - uncertainty_forces : (Nat,3) array or None + uncertainty_forces: (Nat,3) array or None The predicted uncertainties of the forces if get_uncertainty=True and get_forces=True. """ @@ -156,7 +160,7 @@ def save_data(self, trajectory="data.traj", **kwarg): Save the ASE Atoms data to a trajectory. Parameters: - trajectory : str + trajectory: str The name of the trajectory file where the data is saved. Returns: @@ -180,7 +184,7 @@ def is_in_database(self, atoms, **kwargs): Check if the ASE Atoms is in the database. Parameters: - atoms : ASE Atoms + atoms: ASE Atoms The ASE Atoms object with a calculator. Returns: @@ -193,7 +197,7 @@ def copy_atoms(self, atoms, **kwargs): Copy the atoms object together with the calculated properties. Parameters: - atoms : ASE Atoms + atoms: ASE Atoms The ASE Atoms object with a calculator that is copied. Returns: @@ -207,7 +211,7 @@ def update_database_arguments(self, point_interest=None, **kwargs): Update the arguments in the database. Parameters: - point_interest : list + point_interest: list A list of the points of interest as ASE Atoms instances. Returns: @@ -224,6 +228,7 @@ def update_arguments( optimize=None, hp=None, pdis=None, + include_noise=None, verbose=None, **kwargs, ): @@ -232,23 +237,25 @@ def update_arguments( The existing arguments are used if they are not given. Parameters: - model : Model + model: Model The Machine Learning Model with kernel and prior that are optimized. - database : Database object + database: Database object The Database object with ASE atoms. - baseline : Baseline object + baseline: Baseline object The Baseline object calculator that calculates energy and forces. - optimize : bool + optimize: bool Whether to optimize the hyperparameters when the model is trained. - hp : dict + hp: dict Use a set of hyperparameters to optimize from else the current set is used. - pdis : dict + pdis: dict A dict of prior distributions for each hyperparameter type. - verbose : bool + include_noise: bool + Whether to include noise in the uncertainty from the model. + verbose: bool Whether to print statements in the optimization. Returns: @@ -272,6 +279,8 @@ def update_arguments( self.pdis = pdis.copy() elif not hasattr(self, "pdis"): self.pdis = None + if include_noise is not None: + self.include_noise = include_noise if verbose is not None: self.verbose = verbose # Check if the baseline is used @@ -325,7 +334,7 @@ def model_prediction( np.array([fp]), get_derivatives=get_forces, get_variance=get_uncertainty, - include_noise=False, + include_noise=self.include_noise, get_derivtives_var=get_force_uncertainties, get_var_derivatives=get_unc_derivatives, ) @@ -538,6 +547,7 @@ def get_arguments(self): optimize=self.optimize, hp=self.hp, pdis=self.pdis, + include_noise=self.include_noise, verbose=self.verbose, ) # Get the constants made within the class @@ -584,29 +594,29 @@ def get_default_model( Get the default ML model from the simple given arguments. Parameters: - model : str + model: str Either the tp that gives the Studen T process or gp that gives the Gaussian process. - prior : str + prior: str Specify what prior mean should be used. - use_derivatives : bool + use_derivatives: bool Whether to use derivatives of the targets. - use_fingerprint : bool + use_fingerprint: bool Whether to use fingerprints for the features. This has to be the same as for the database! - global_optimization : bool + global_optimization: bool Whether to perform a global optimization of the hyperparameters. A local optimization is used if global_optimization=False, which can not be parallelized. - parallel : bool + parallel: bool Whether to optimize the hyperparameters in parallel. - n_reduced : int or None + n_reduced: int or None If n_reduced is an integer, the hyperparameters are only optimized when the data set size is equal to or below the integer. If n_reduced is None, the hyperparameter is always optimized. Returns: - model : Model + model: Model The Machine Learning Model with kernel and prior that are optimized. """ @@ -756,20 +766,20 @@ def get_default_database( Get the default Database from the simple given arguments. Parameters: - fp : Fingerprint class object or None + fp: Fingerprint class object or None The fingerprint object used to generate the fingerprints. Cartesian coordinates are used if it is None. - use_derivatives : bool + use_derivatives: bool Whether to use derivatives of the targets. - database_reduction : bool + database_reduction: bool Whether to used a reduced database after a number of training points. - database_reduction_kwargs : dict + database_reduction_kwargs: dict A dictionary with the arguments for the reduced database if it is used. Returns: - database : Database object + database: Database object The Database object with ASE atoms. """ # Set a fingerprint @@ -859,43 +869,43 @@ def get_default_mlmodel( from the simple given arguments. Parameters: - model : str + model: str Either the tp that gives the Studen T process or gp that gives the Gaussian process. - fp : Fingerprint class object or None + fp: Fingerprint class object or None The fingerprint object used to generate the fingerprints. Cartesian coordinates are used if it is None. - baseline : Baseline object + baseline: Baseline object The Baseline object calculator that calculates energy and forces. - prior : str + prior: str Specify what prior mean should be used. - use_derivatives : bool + use_derivatives: bool Whether to use derivatives of the targets. - optimize_hp : bool + optimize_hp: bool Whether to optimize the hyperparameters when the model is trained. - global_optimization : bool + global_optimization: bool Whether to perform a global optimization of the hyperparameters. A local optimization is used if global_optimization=False, which can not be parallelized. - parallel : bool + parallel: bool Whether to optimize the hyperparameters in parallel. - use_pdis : bool + use_pdis: bool Whether to make prior distributions for the hyperparameters. - n_reduced : int or None + n_reduced: int or None If n_reduced is an integer, the hyperparameters are only optimized when the data set size is equal to or below the integer. If n_reduced is None, the hyperparameter is always optimized. - database_reduction : bool + database_reduction: bool Whether to used a reduced database after a number of training points. - database_reduction_kwargs : dict + database_reduction_kwargs: dict A dictionary with the arguments for the reduced database if it is used. - verbose : bool + verbose: bool Whether to print statements in the optimization. Returns: - mlmodel : MLModel class object + mlmodel: MLModel class object Machine Learning model used for ASE Atoms and calculator. """ # Check if fingerprints are used From 0b4f541a4629ace89fc77bd055aaf280b6b957b5 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Wed, 18 Dec 2024 11:42:29 +0100 Subject: [PATCH 079/194] Use the true initial energy as reference --- catlearn/tools/plot.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/catlearn/tools/plot.py b/catlearn/tools/plot.py index 522fadeb..626ff71c 100644 --- a/catlearn/tools/plot.py +++ b/catlearn/tools/plot.py @@ -334,6 +334,8 @@ def plot_neb_fit_mlcalc( use_uncertainty=False, use_projection=False, ) + # Get the reference energy + e0 = images[0].get_potential_energy() # Get the first image image = images[0].copy() image.calc = mlcalc @@ -370,8 +372,9 @@ def plot_neb_fit_mlcalc( uncertainties.append(unc) pos0 += disp cum_distance += dist + # Make numpy arrays pred_distance = np.array(pred_distance) - pred_energies = np.array(pred_energies) - pred_energies[0] + pred_energies = np.array(pred_energies) - e0 uncertainties = np.array(uncertainties) # Make figure if it is not given if ax is None: From f27d7eb905e083fbb6c1aa413e22a770c1f2d543 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Wed, 18 Dec 2024 11:49:57 +0100 Subject: [PATCH 080/194] Include noise in the uncertainty for plotting NEB band --- README.md | 1 + catlearn/tools/plot.py | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/README.md b/README.md index f7a7fef7..e1939e9e 100644 --- a/README.md +++ b/README.md @@ -147,6 +147,7 @@ plot_neb_fit_mlcalc( mlneb.get_structures(), mlcalc=mlneb.get_mlcalc(), use_uncertainty=True, + include_noise=True, ax=ax, ) plt.savefig('Converged_NEB_fit.png') diff --git a/catlearn/tools/plot.py b/catlearn/tools/plot.py index 626ff71c..df746b70 100644 --- a/catlearn/tools/plot.py +++ b/catlearn/tools/plot.py @@ -298,6 +298,7 @@ def plot_neb_fit_mlcalc( climb=True, use_uncertainty=True, distance_step=0.01, + include_noise=True, ax=None, **kwargs, ): @@ -319,6 +320,8 @@ def plot_neb_fit_mlcalc( If True, use the uncertainty of the predictions. distance_step: float The step size for the distance between the images. + include_noise: bool + Whether to include noise in the uncertainty from the model. ax: matplotlib axis instance The axis to plot the NEB images. @@ -336,6 +339,8 @@ def plot_neb_fit_mlcalc( ) # Get the reference energy e0 = images[0].get_potential_energy() + # Update whether to include noise in uncertainty prediction + mlcalc = mlcalc.update_mlmodel_arguments(include_noise=include_noise) # Get the first image image = images[0].copy() image.calc = mlcalc From 23560364e06261c2809467e34216cbf8e78f9896 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Tue, 7 Jan 2025 12:42:22 +0100 Subject: [PATCH 081/194] Only make plots on master rank --- catlearn/tools/plot.py | 31 ++++++++++++++++++++++--------- 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/catlearn/tools/plot.py b/catlearn/tools/plot.py index df746b70..482dccc2 100644 --- a/catlearn/tools/plot.py +++ b/catlearn/tools/plot.py @@ -2,6 +2,7 @@ import matplotlib.pyplot as plt import matplotlib.cm as cm from ase.io import read +from ase.parallel import world from ..structures.neb import ImprovedTangentNEB @@ -34,6 +35,9 @@ def plot_minimize( # Make figure if it is not given if ax is None: _, ax = plt.subplots() + # Only plot the atoms on the master rank + if world.rank != 0: + return ax # Get the energies of the predicted atoms if isinstance(pred_atoms, str): pred_atoms = read(pred_atoms, ":") @@ -238,6 +242,12 @@ def plot_neb( Returns: ax: matplotlib axis instance """ + # Make figure if it is not given + if ax is None: + _, ax = plt.subplots() + # Only plot the atoms on the master rank + if world.rank != 0: + return ax # Get data from NEB _, distances, energies, uncertainties, deriv_proj = get_neb_data( images, @@ -247,9 +257,6 @@ def plot_neb( use_uncertainty=use_uncertainty, use_projection=use_projection, ) - # Make figure if it is not given - if ax is None: - _, ax = plt.subplots() # Plot the NEB images ax.plot(distances, energies, "o-", color="black") if uncertainties is not None: @@ -328,6 +335,12 @@ def plot_neb_fit_mlcalc( Returns: ax: matplotlib axis instance """ + # Make figure if it is not given + if ax is None: + _, ax = plt.subplots() + # Only plot the atoms on the master rank + if world.rank != 0: + return ax # Get data from NEB neb, distances, energies, _, _ = get_neb_data( images, @@ -381,9 +394,6 @@ def plot_neb_fit_mlcalc( pred_distance = np.array(pred_distance) pred_energies = np.array(pred_energies) - e0 uncertainties = np.array(uncertainties) - # Make figure if it is not given - if ax is None: - _, ax = plt.subplots() # Plot the NEB images ax.plot(distances, energies, "o", color="black") ax.plot(pred_distance, pred_energies, "-", color="red") @@ -446,13 +456,16 @@ def plot_all_neb( Returns: ax: matplotlib axis instance """ + # Make figure if it is not given + if ax is None: + _, ax = plt.subplots() + # Only plot the atoms on the master rank + if world.rank != 0: + return ax # Calculate the number of NEB bands if isinstance(neb_traj, str): neb_traj = read(neb_traj, ":") n_neb = len(neb_traj) // n_images - # Make figure if it is not given - if ax is None: - _, ax = plt.subplots() # Plot all NEB bands for i in range(n_neb): # Get the images of the NEB band From 0b50608f9690f82f5a43de3cb2141ae14a8846c7 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Wed, 26 Mar 2025 08:41:18 +0100 Subject: [PATCH 082/194] New version --- catlearn/_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/catlearn/_version.py b/catlearn/_version.py index 2f3a1abb..1873a126 100644 --- a/catlearn/_version.py +++ b/catlearn/_version.py @@ -1,3 +1,3 @@ -__version__ = "6.0.1" +__version__ = "6.0.2" __all__ = ["__version__"] From 2b4e51af3cff45e3b6d18ef49baff73ed378f288 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Wed, 26 Mar 2025 08:41:40 +0100 Subject: [PATCH 083/194] Check if endpoints are the same in NEB --- catlearn/structures/neb/orgneb.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/catlearn/structures/neb/orgneb.py b/catlearn/structures/neb/orgneb.py index f56c3bf6..997e150d 100644 --- a/catlearn/structures/neb/orgneb.py +++ b/catlearn/structures/neb/orgneb.py @@ -45,6 +45,8 @@ def __init__( The communicator instance for parallelization. """ + # Check that the endpoints are the same + self.check_endpoints(images) # Set images if save_properties: self.images = [Structure(image) for image in images] @@ -81,6 +83,20 @@ def __init__( # Set the properties self.reset() + def check_endpoints(self, images): + "Check that the endpoints of the images are the same structures." + initial_atomic_numbers = images[0].get_atomic_numbers() + final_atomic_numbers = images[-1].get_atomic_numbers() + if ( + len(initial_atomic_numbers) != len(final_atomic_numbers) + or (initial_atomic_numbers != final_atomic_numbers).any() + ): + raise Exception( + "The atoms in the initial and final images " + "are not the same." + ) + return self + def interpolate(self, method="linear", mic=True, **kwargs): """ Make an interpolation between the start and end structure. From 20255f3aca118ff6756fb8958c9ae19b743c62bf Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Wed, 26 Mar 2025 09:40:28 +0100 Subject: [PATCH 084/194] Make it possible to change saving and only write last structure in databases --- catlearn/activelearning/activelearning.py | 10 ++- catlearn/regression/gp/calculator/database.py | 65 ++++++++++++------- .../gp/calculator/database_reduction.py | 15 ----- catlearn/regression/gp/calculator/mlcalc.py | 25 ++++++- catlearn/regression/gp/calculator/mlmodel.py | 26 ++++++-- 5 files changed, 94 insertions(+), 47 deletions(-) diff --git a/catlearn/activelearning/activelearning.py b/catlearn/activelearning/activelearning.py index b8884044..489b268d 100644 --- a/catlearn/activelearning/activelearning.py +++ b/catlearn/activelearning/activelearning.py @@ -1034,7 +1034,15 @@ def train_mlmodel(self, point_interest=None, **kwargs): def save_data(self, **kwargs): "Save the training data to a file." - self.mlcalc.save_data(trajectory=self.trainingset) + if self.steps > 1: + self.mlcalc.save_data( + trajectory=self.trainingset, + mode="a", + write_last=True, + **kwargs, + ) + else: + self.mlcalc.save_data(trajectory=self.trainingset, **kwargs) return self def save_trajectory(self, trajectory, structures, mode="w", **kwargs): diff --git a/catlearn/regression/gp/calculator/database.py b/catlearn/regression/gp/calculator/database.py index c52fc9dc..19ad8e7e 100644 --- a/catlearn/regression/gp/calculator/database.py +++ b/catlearn/regression/gp/calculator/database.py @@ -19,14 +19,14 @@ def __init__( into fingerprints and targets. Parameters: - fingerprint : Fingerprint object + fingerprint: Fingerprint object An object as a fingerprint class that convert atoms to fingerprint. reduce_dimensions: bool Whether to reduce the fingerprint space if constrains are used. - use_derivatives : bool + use_derivatives: bool Whether to use derivatives/forces in the targets. - use_fingerprint : bool + use_fingerprint: bool Whether the kernel uses fingerprint objects (True) or arrays (False). """ @@ -54,7 +54,7 @@ def add(self, atoms, **kwargs): Add an ASE Atoms object to the database. Parameters: - atoms : ASE Atoms + atoms: ASE Atoms The ASE Atoms object with a calculator. Returns: @@ -68,7 +68,7 @@ def add_set(self, atoms_list, **kwargs): Add a set of ASE Atoms objects to the database. Parameters: - atoms_list : list or ASE Atoms + atoms_list: list or ASE Atoms A list of or a single ASE Atoms with calculated energies and forces. @@ -84,11 +84,11 @@ def get_constraints(self, atoms, **kwargs): Get the indicies of the atoms that does not have fixed constraints. Parameters: - atoms : ASE Atoms + atoms: ASE Atoms The ASE Atoms object with a calculator. Returns: - not_masked : list + not_masked: list A list of indicies for the moving atoms. """ not_masked = list(range(len(atoms))) @@ -131,16 +131,27 @@ def get_targets(self, **kwargs): """ return np.array(self.targets) - def save_data(self, trajectory="data.traj", mode="w", **kwargs): + def save_data( + self, + trajectory="data.traj", + mode="w", + write_last=False, + **kwargs, + ): """ Save the ASE Atoms data to a trajectory. Parameters: - trajectory : str or TrajectoryWriter instance + trajectory: str or TrajectoryWriter instance The name of the trajectory file where the data is saved. Or a TrajectoryWriter instance where the data is saved to. - mode : str + mode: str The mode of the trajectory file. + write_last: bool + Whether to only write the last atoms instance to the + trajectory. + If False, all atoms instances in the database are written + to the trajectory. Returns: self: The updated object itself. @@ -149,11 +160,17 @@ def save_data(self, trajectory="data.traj", mode="w", **kwargs): return self if isinstance(trajectory, str): with TrajectoryWriter(trajectory, mode=mode) as traj: - for atoms in self.atoms_list: - traj.write(atoms) + if write_last: + traj.write(self.atoms_list[-1]) + else: + for atoms in self.atoms_list: + traj.write(atoms) elif isinstance(trajectory, TrajectoryWriter): - for atoms in self.atoms_list: - trajectory.write(atoms) + if write_last: + trajectory.write(self.atoms_list[-1]) + else: + for atoms in self.atoms_list: + trajectory.write(atoms) return self def copy_atoms(self, atoms, **kwargs): @@ -161,7 +178,7 @@ def copy_atoms(self, atoms, **kwargs): Copy the atoms object together with the calculated properties. Parameters: - atoms : ASE Atoms + atoms: ASE Atoms The ASE Atoms object with a calculator that is copied. Returns: @@ -176,7 +193,7 @@ def make_atoms_feature(self, atoms, **kwargs): It can e.g. be used for predicting. Parameters: - atoms : ASE Atoms + atoms: ASE Atoms The ASE Atoms object with a calculator. Returns: @@ -199,11 +216,11 @@ def make_target( Calculate the target as the energy and forces if selected. Parameters: - atoms : ASE Atoms + atoms: ASE Atoms The ASE Atoms object with a calculator. - use_derivatives : bool + use_derivatives: bool Whether to use derivatives/forces in the targets. - use_negative_forces : bool + use_negative_forces: bool Whether derivatives (True) or forces (False) are used. Returns: @@ -240,9 +257,9 @@ def is_in_database(self, atoms, dtol=1e-8, **kwargs): Check if the ASE Atoms is in the database. Parameters: - atoms : ASE Atoms + atoms: ASE Atoms The ASE Atoms object with a calculator. - dtol : float + dtol: float The tolerance value to determine identical Atoms. Returns: @@ -311,14 +328,14 @@ def update_arguments( if they are not given. Parameters: - fingerprint : Fingerprint object + fingerprint: Fingerprint object An object as a fingerprint class that convert atoms to fingerprint. reduce_dimensions: bool Whether to reduce the fingerprint space if constrains are used. - use_derivatives : bool + use_derivatives: bool Whether to use derivatives/forces in the targets. - use_fingerprint : bool + use_fingerprint: bool Whether the kernel uses fingerprint objects (True) or arrays (False). diff --git a/catlearn/regression/gp/calculator/database_reduction.py b/catlearn/regression/gp/calculator/database_reduction.py index 2165021a..49515d7f 100644 --- a/catlearn/regression/gp/calculator/database_reduction.py +++ b/catlearn/regression/gp/calculator/database_reduction.py @@ -1,7 +1,6 @@ import numpy as np from scipy.spatial.distance import cdist from .database import Database -from ase.io import write class DatabaseReduction(Database): @@ -238,20 +237,6 @@ def get_not_indicies(self, indicies, all_indicies, **kwargs): """ return list(set(all_indicies).difference(indicies)) - def save_data(self, trajectory="data.traj", **kwargs): - """ - Save the ASE Atoms data to a trajectory. - - Parameters: - trajectory : str - The name of the trajectory file where the data is saved. - - Returns: - self: The updated object itself. - """ - write(trajectory, self.get_all_atoms()) - return self - def append(self, atoms, **kwargs): "Append the atoms object, the fingerprint, and target(s) to lists." # Store that the data base has changed diff --git a/catlearn/regression/gp/calculator/mlcalc.py b/catlearn/regression/gp/calculator/mlcalc.py index 55059edb..3b116877 100644 --- a/catlearn/regression/gp/calculator/mlcalc.py +++ b/catlearn/regression/gp/calculator/mlcalc.py @@ -153,18 +153,37 @@ def train_model(self, **kwarg): self.mlmodel.train_model(**kwarg) return self - def save_data(self, trajectory="data.traj", **kwarg): + def save_data( + self, + trajectory="data.traj", + mode="w", + write_last=False, + **kwargs, + ): """ Save the ASE Atoms data to a trajectory. Parameters: - trajectory: str + trajectory: str or TrajectoryWriter instance The name of the trajectory file where the data is saved. + Or a TrajectoryWriter instance where the data is saved to. + mode: str + The mode of the trajectory file. + write_last: bool + Whether to only write the last atoms instance to the + trajectory. + If False, all atoms instances in the database are written + to the trajectory. Returns: self: The updated object itself. """ - self.mlmodel.save_data(trajectory=trajectory, **kwarg) + self.mlmodel.save_data( + trajectory=trajectory, + mode=mode, + write_last=write_last, + **kwargs, + ) return self def get_data_atoms(self, **kwargs): diff --git a/catlearn/regression/gp/calculator/mlmodel.py b/catlearn/regression/gp/calculator/mlmodel.py index e30ee0e7..100115c3 100644 --- a/catlearn/regression/gp/calculator/mlmodel.py +++ b/catlearn/regression/gp/calculator/mlmodel.py @@ -155,19 +155,37 @@ def calculate( ) return results - def save_data(self, trajectory="data.traj", **kwarg): + def save_data( + self, + trajectory="data.traj", + mode="w", + write_last=False, + **kwargs, + ): """ Save the ASE Atoms data to a trajectory. Parameters: - trajectory: str + trajectory: str or TrajectoryWriter instance The name of the trajectory file where the data is saved. + Or a TrajectoryWriter instance where the data is saved to. + mode: str + The mode of the trajectory file. + write_last: bool + Whether to only write the last atoms instance to the + trajectory. + If False, all atoms instances in the database are written + to the trajectory. Returns: self: The updated object itself. """ - " Save the ASE atoms data to a trajectory. " - self.database.save_data(trajectory=trajectory, **kwarg) + self.database.save_data( + trajectory=trajectory, + mode=mode, + write_last=write_last, + **kwargs, + ) return self def get_training_set_size(self, **kwargs): From e6cf4b19045ae22a6bd5da7456189adc8059abf7 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Wed, 26 Mar 2025 10:38:54 +0100 Subject: [PATCH 085/194] Better exception handling --- catlearn/activelearning/activelearning.py | 14 +++++++------- catlearn/regression/gp/calculator/mlmodel.py | 4 ++-- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/catlearn/activelearning/activelearning.py b/catlearn/activelearning/activelearning.py index 489b268d..3a55479c 100644 --- a/catlearn/activelearning/activelearning.py +++ b/catlearn/activelearning/activelearning.py @@ -421,16 +421,16 @@ def setup_mlcalc( if save_memory is None: try: save_memory = self.save_memory - except Exception: - raise Exception("The save_memory is not given.") + except NameError: + raise NameError("The save_memory is not given.") # Setup the fingerprint if fp is None: # Check if the Atoms object is given if atoms is None: try: atoms = self.get_structures(get_all=False) - except Exception: - raise Exception("The Atoms object is not given or stored.") + except NameError: + raise NameError("The Atoms object is not given or stored.") # Can only use distances if there are more than one atom if len(atoms) > 1: if atoms.pbc.any(): @@ -539,7 +539,7 @@ def setup_acq( # Check if the objective is the same objective = self.get_objective_str() if acq.objective != objective: - raise Exception( + raise ValueError( "The objective of the acquisition function " "does not match the active learner." ) @@ -1377,7 +1377,7 @@ def check_attributes(self, **kwargs): agree upon the attributes. """ if self.parallel_run != self.method.parallel_run: - raise Exception( + raise ValueError( "Active learner and Optimization method does " "not agree whether to run in parallel!" ) @@ -1484,7 +1484,7 @@ def restart_optimization( # Make a reference energy atoms_ref = copy_atoms(prev_calculations[0]) self.e_ref = atoms_ref.get_potential_energy() - except Exception: + except (AssertionError, FileNotFoundError, IndexError, StopIteration): self.message_system( "Warning: Restart is not possible! " "Reinitalizing active learning." diff --git a/catlearn/regression/gp/calculator/mlmodel.py b/catlearn/regression/gp/calculator/mlmodel.py index 100115c3..128d9d76 100644 --- a/catlearn/regression/gp/calculator/mlmodel.py +++ b/catlearn/regression/gp/calculator/mlmodel.py @@ -541,7 +541,7 @@ def check_attributes(self): self.model.get_use_fingerprint() != self.database.get_use_fingerprint() ): - raise Exception( + raise ValueError( "Model and Database do not agree " "whether to use fingerprints!" ) @@ -549,7 +549,7 @@ def check_attributes(self): self.model.get_use_derivatives() != self.database.get_use_derivatives() ): - raise Exception( + raise ValueError( "Model and Database do not agree " "whether to use derivatives/forces!" ) From 87f8cb47f20cf5737b9438215af11a2f18a495ec Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Wed, 26 Mar 2025 10:48:00 +0100 Subject: [PATCH 086/194] Better exception handling --- catlearn/structures/neb/orgneb.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/catlearn/structures/neb/orgneb.py b/catlearn/structures/neb/orgneb.py index 997e150d..1346cb69 100644 --- a/catlearn/structures/neb/orgneb.py +++ b/catlearn/structures/neb/orgneb.py @@ -91,7 +91,7 @@ def check_endpoints(self, images): len(initial_atomic_numbers) != len(final_atomic_numbers) or (initial_atomic_numbers != final_atomic_numbers).any() ): - raise Exception( + raise ValueError( "The atoms in the initial and final images " "are not the same." ) @@ -342,7 +342,7 @@ def set_calculator(self, calculators, copy_calc=False, **kwargs): self.reset() if isinstance(calculators, (list, tuple)): if len(calculators) != self.nimages - 2: - raise Exception( + raise ValueError( "The number of calculators must be " "equal to the number of moving images." ) From c5d4e2adbd294a82a05bb5e25c4e793d79b70d36 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Tue, 1 Apr 2025 16:02:56 +0200 Subject: [PATCH 087/194] New version --- catlearn/_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/catlearn/_version.py b/catlearn/_version.py index 1873a126..e908fe0b 100644 --- a/catlearn/_version.py +++ b/catlearn/_version.py @@ -1,3 +1,3 @@ -__version__ = "6.0.2" +__version__ = "6.1.0" __all__ = ["__version__"] From cb81c805d152e1a64fd1c73e93b91ef32794891b Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Wed, 2 Apr 2025 12:35:00 +0200 Subject: [PATCH 088/194] Rounding targets and use data type --- catlearn/regression/gp/calculator/database.py | 78 ++++-- .../gp/calculator/database_reduction.py | 250 ++++++++++++++---- 2 files changed, 262 insertions(+), 66 deletions(-) diff --git a/catlearn/regression/gp/calculator/database.py b/catlearn/regression/gp/calculator/database.py index 19ad8e7e..666df246 100644 --- a/catlearn/regression/gp/calculator/database.py +++ b/catlearn/regression/gp/calculator/database.py @@ -1,4 +1,5 @@ -import numpy as np +from numpy import array, asarray, concatenate +from numpy import round as round_ from scipy.spatial.distance import cdist from ase.constraints import FixAtoms from ase.io.trajectory import TrajectoryWriter @@ -12,6 +13,8 @@ def __init__( reduce_dimensions=True, use_derivatives=True, use_fingerprint=True, + round_targets=None, + dtype=None, **kwargs, ): """ @@ -29,6 +32,11 @@ def __init__( use_fingerprint: bool Whether the kernel uses fingerprint objects (True) or arrays (False). + round_targets: int (optional) + The number of decimals to round the targets to. + If None, the targets are not rounded. + dtype: type + The data type of the arrays. """ # The negative forces have to be used since the derivatives are used self.use_negative_forces = True @@ -46,6 +54,8 @@ def __init__( reduce_dimensions=reduce_dimensions, use_derivatives=use_derivatives, use_fingerprint=use_fingerprint, + round_targets=round_targets, + dtype=dtype, **kwargs, ) @@ -100,7 +110,7 @@ def get_constraints(self, atoms, **kwargs): c.get_indices() for c in constraints if isinstance(c, FixAtoms) ] if len(masked): - masked = set(np.concatenate(masked)) + masked = set(concatenate(masked)) return list(set(not_masked).difference(masked)) return not_masked @@ -120,7 +130,7 @@ def get_features(self, **kwargs): Returns: array: A matrix array with the saved features or fingerprints. """ - return np.array(self.features) + return array(self.features, dtype=self.dtype) def get_targets(self, **kwargs): """ @@ -129,7 +139,7 @@ def get_targets(self, **kwargs): Returns: array: A matrix array with the saved targets. """ - return np.array(self.targets) + return array(self.targets, dtype=self.dtype) def save_data( self, @@ -205,6 +215,31 @@ def make_atoms_feature(self, atoms, **kwargs): return self.fingerprint(atoms) return self.fingerprint(atoms).get_vector() + def append_target(self, atoms, **kwargs): + """ + Append the target(s) to the list. + + Parameters: + atoms: ASE Atoms + The ASE Atoms object with a calculator. + + Returns: + self: The updated object + """ + # Make the target(s) + target = self.make_target( + atoms, + use_derivatives=self.use_derivatives, + use_negative_forces=self.use_negative_forces, + **kwargs, + ) + # Round the target if needed + if self.round_targets is not None: + target = round_(target, self.round_targets) + # Append the target(s) + self.targets.append(target) + return self + def make_target( self, atoms, @@ -236,9 +271,9 @@ def make_target( f = atoms.get_forces(apply_constraint=False) f = f[not_masked].reshape(-1) if use_negative_forces: - return np.concatenate([[e], -f]).reshape(-1) - return np.concatenate([[e], f]).reshape(-1) - return np.array([e]) + return concatenate([[e], -f], dtype=self.dtype).reshape(-1) + return concatenate([[e], f], dtype=self.dtype).reshape(-1) + return array([e], dtype=self.dtype) def reset_database(self, **kwargs): """ @@ -275,9 +310,12 @@ def is_in_database(self, atoms, dtol=1e-8, **kwargs): # Transform the fingerprints into vectors if self.use_fingerprint: fp_atoms = fp_atoms.get_vector() - fp_database = np.array([fp.get_vector() for fp in fp_database]) + fp_database = asarray( + [fp.get_vector() for fp in fp_database], + dtype=self.dtype, + ) # Get the minimum distance between atoms object and the database - dis_min = np.min(cdist([fp_atoms], fp_database)) + dis_min = cdist([fp_atoms], fp_database).min() # Check if the atoms object is in the database if dis_min < dtol: return True @@ -292,12 +330,7 @@ def append(self, atoms, **kwargs): # Append the feature self.features.append(self.make_atoms_feature(atoms)) # Append the target(s) - target = self.make_target( - atoms, - use_derivatives=self.use_derivatives, - use_negative_forces=self.use_negative_forces, - ) - self.targets.append(target) + self.append_target(atoms) return self def get_use_derivatives(self): @@ -321,6 +354,8 @@ def update_arguments( reduce_dimensions=None, use_derivatives=None, use_fingerprint=None, + round_targets=None, + dtype=None, **kwargs, ): """ @@ -338,6 +373,11 @@ def update_arguments( use_fingerprint: bool Whether the kernel uses fingerprint objects (True) or arrays (False). + round_targets: int (optional) + The number of decimals to round the targets to. + If None, the targets are not rounded. + dtype: type + The data type of the arrays. Returns: self: The updated object itself. @@ -356,6 +396,10 @@ def update_arguments( if use_fingerprint is not None: self.use_fingerprint = use_fingerprint reset_database = True + if round_targets is not None or not hasattr(self, "round_targets"): + self.round_targets = round_targets + if dtype is not None or not hasattr(self, "dtype"): + self.dtype = dtype # Check that the database and the fingerprint have the same attributes self.check_attributes() # Reset the database if an argument has been changed @@ -366,7 +410,7 @@ def update_arguments( def check_attributes(self): "Check if all attributes agree between the class and subclasses." if self.reduce_dimensions != self.fingerprint.get_reduce_dimensions(): - raise Exception( + raise ValueError( "Database and Fingerprint do not agree " "whether to reduce dimensions!" ) @@ -384,6 +428,8 @@ def get_arguments(self): reduce_dimensions=self.reduce_dimensions, use_derivatives=self.use_derivatives, use_fingerprint=self.use_fingerprint, + round_targets=self.round_targets, + dtype=self.dtype, ) # Get the constants made within the class constant_kwargs = dict() diff --git a/catlearn/regression/gp/calculator/database_reduction.py b/catlearn/regression/gp/calculator/database_reduction.py index 49515d7f..42c9a69e 100644 --- a/catlearn/regression/gp/calculator/database_reduction.py +++ b/catlearn/regression/gp/calculator/database_reduction.py @@ -1,4 +1,18 @@ -import numpy as np +# import numpy as np +from numpy import ( + append, + arange, + argmax, + argmin, + argsort, + array, + asarray, + delete, + einsum, + nanmin, + sqrt, +) +from numpy.random import choice, permutation from scipy.spatial.distance import cdist from .database import Database @@ -10,6 +24,8 @@ def __init__( reduce_dimensions=True, use_derivatives=True, use_fingerprint=True, + round_targets=None, + dtype=None, npoints=25, initial_indicies=[0], include_last=1, @@ -32,6 +48,11 @@ def __init__( use_fingerprint : bool Whether the kernel uses fingerprint objects (True) or arrays (False). + round_targets: int (optional) + The number of decimals to round the targets to. + If None, the targets are not rounded. + dtype: type + The data type of the arrays. npoints : int Number of points that are used from the database. initial_indicies : list @@ -58,6 +79,8 @@ def __init__( reduce_dimensions=reduce_dimensions, use_derivatives=use_derivatives, use_fingerprint=use_fingerprint, + round_targets=round_targets, + dtype=dtype, npoints=npoints, initial_indicies=initial_indicies, include_last=include_last, @@ -70,6 +93,8 @@ def update_arguments( reduce_dimensions=None, use_derivatives=None, use_fingerprint=None, + round_targets=None, + dtype=None, npoints=None, initial_indicies=None, include_last=None, @@ -90,6 +115,11 @@ def update_arguments( use_fingerprint : bool Whether the kernel uses fingerprint objects (True) or arrays (False). + round_targets: int (optional) + The number of decimals to round the targets to. + If None, the targets are not rounded. + dtype: type + The data type of the arrays. npoints : int Number of points that are used from the database. initial_indicies : list @@ -115,10 +145,14 @@ def update_arguments( if use_fingerprint is not None: self.use_fingerprint = use_fingerprint reset_database = True + if round_targets is not None or not hasattr(self, "round_targets"): + self.round_targets = round_targets + if dtype is not None or not hasattr(self, "dtype"): + self.dtype = dtype if npoints is not None: self.npoints = int(npoints) if initial_indicies is not None: - self.initial_indicies = np.array(initial_indicies, dtype=int) + self.initial_indicies = array(initial_indicies, dtype=int) if include_last is not None: self.include_last = int(abs(include_last)) # Check that too many last points are not included @@ -165,14 +199,14 @@ def get_features(self, **kwargs): array: A matrix array with the saved features or fingerprints. """ indicies = self.get_reduction_indicies() - return np.array(self.features)[indicies] + return array(self.features, dtype=self.dtype)[indicies] def get_all_feature_vectors(self, **kwargs): "Get all the features in numpy array form." if self.use_fingerprint: features = [feature.get_vector() for feature in self.features] - return np.array(features) - return np.array(self.features) + return array(features, dtype=self.dtype) + return array(self.features, dtype=self.dtype) def get_targets(self, **kwargs): """ @@ -182,7 +216,7 @@ def get_targets(self, **kwargs): array: A matrix array with the saved targets. """ indicies = self.get_reduction_indicies() - return np.array(self.targets)[indicies] + return array(self.targets, dtype=self.dtype)[indicies] def get_all_targets(self, **kwargs): """ @@ -191,7 +225,7 @@ def get_all_targets(self, **kwargs): Returns: array: A matrix array with the saved targets. """ - return np.array(self.targets) + return array(self.targets, dtype=self.dtype) def get_initial_indicies(self, **kwargs): """ @@ -216,7 +250,7 @@ def get_last_indicies(self, indicies, not_indicies, **kwargs): list: A list of the used indicies including the last indicies. """ if self.include_last != 0: - indicies = np.append( + indicies = append( indicies, [not_indicies[-self.include_last :]], ) @@ -253,7 +287,7 @@ def get_reduction_indicies(self, **kwargs): # Set up all the indicies self.update_indicies = False data_len = self.__len__() - all_indicies = np.arange(data_len) + all_indicies = arange(data_len) # No reduction is needed if the database is not large if data_len <= self.npoints: self.indicies = all_indicies.copy() @@ -274,6 +308,8 @@ def get_arguments(self): reduce_dimensions=self.reduce_dimensions, use_derivatives=self.use_derivatives, use_fingerprint=self.use_fingerprint, + round_targets=self.round_targets, + dtype=self.dtype, npoints=self.npoints, initial_indicies=self.initial_indicies, include_last=self.include_last, @@ -297,6 +333,8 @@ def __init__( reduce_dimensions=True, use_derivatives=True, use_fingerprint=True, + round_targets=None, + dtype=None, npoints=25, initial_indicies=[0], include_last=1, @@ -319,6 +357,11 @@ def __init__( use_fingerprint : bool Whether the kernel uses fingerprint objects (True) or arrays (False). + round_targets: int (optional) + The number of decimals to round the targets to. + If None, the targets are not rounded. + dtype: type + The data type of the arrays. npoints : int Number of points that are used from the database. initial_indicies : list @@ -332,6 +375,8 @@ def __init__( reduce_dimensions=reduce_dimensions, use_derivatives=use_derivatives, use_fingerprint=use_fingerprint, + round_targets=round_targets, + dtype=dtype, npoints=npoints, initial_indicies=initial_indicies, include_last=include_last, @@ -348,7 +393,7 @@ def make_reduction(self, all_indicies, **kwargs): indicies = self.get_last_indicies(indicies, not_indicies) # Get a random index if no fixed index exist if len(indicies) == 0: - indicies = np.array([np.random.choice(all_indicies)]) + indicies = asarray([choice(all_indicies)]) # Get all the features features = self.get_all_feature_vectors() fdim = len(features[0]) @@ -361,9 +406,9 @@ def make_reduction(self, all_indicies, **kwargs): features[not_indicies].reshape(-1, fdim), ) # Choose the point furthest from the points already used - i_max = np.argmax(np.nanmin(dist, axis=0)) - indicies = np.append(indicies, [not_indicies[i_max]]) - return np.array(indicies, dtype=int) + i_max = argmax(nanmin(dist, axis=0)) + indicies = append(indicies, [not_indicies[i_max]]) + return array(indicies, dtype=int) class DatabaseRandom(DatabaseReduction): @@ -373,6 +418,8 @@ def __init__( reduce_dimensions=True, use_derivatives=True, use_fingerprint=True, + round_targets=None, + dtype=None, npoints=25, initial_indicies=[0], include_last=1, @@ -395,6 +442,11 @@ def __init__( use_fingerprint : bool Whether the kernel uses fingerprint objects (True) or arrays (False). + round_targets: int (optional) + The number of decimals to round the targets to. + If None, the targets are not rounded. + dtype: type + The data type of the arrays. npoints : int Number of points that are used from the database. initial_indicies : list @@ -408,6 +460,8 @@ def __init__( reduce_dimensions=reduce_dimensions, use_derivatives=use_derivatives, use_fingerprint=use_fingerprint, + round_targets=round_targets, + dtype=dtype, npoints=npoints, initial_indicies=initial_indicies, include_last=include_last, @@ -427,11 +481,11 @@ def make_reduction(self, all_indicies, **kwargs): # Get the number of missing points npoints = int(self.npoints - len(indicies)) # Randomly get the indicies - indicies = np.append( + indicies = append( indicies, - np.random.permutation(not_indicies)[:npoints], + permutation(not_indicies)[:npoints], ) - return np.array(indicies, dtype=int) + return array(indicies, dtype=int) class DatabaseHybrid(DatabaseReduction): @@ -441,6 +495,8 @@ def __init__( reduce_dimensions=True, use_derivatives=True, use_fingerprint=True, + round_targets=None, + dtype=None, npoints=25, initial_indicies=[0], include_last=1, @@ -465,6 +521,11 @@ def __init__( use_fingerprint : bool Whether the kernel uses fingerprint objects (True) or arrays (False). + round_targets: int (optional) + The number of decimals to round the targets to. + If None, the targets are not rounded. + dtype: type + The data type of the arrays. npoints : int Number of points that are used from the database. initial_indicies : list @@ -480,6 +541,8 @@ def __init__( reduce_dimensions=reduce_dimensions, use_derivatives=use_derivatives, use_fingerprint=use_fingerprint, + round_targets=round_targets, + dtype=dtype, npoints=npoints, initial_indicies=initial_indicies, include_last=include_last, @@ -493,6 +556,8 @@ def update_arguments( reduce_dimensions=None, use_derivatives=None, use_fingerprint=None, + round_targets=None, + dtype=None, npoints=None, initial_indicies=None, include_last=None, @@ -514,6 +579,11 @@ def update_arguments( use_fingerprint : bool Whether the kernel uses fingerprint objects (True) or arrays (False). + round_targets: int (optional) + The number of decimals to round the targets to. + If None, the targets are not rounded. + dtype: type + The data type of the arrays. npoints : int Number of points that are used from the database. initial_indicies : list @@ -541,10 +611,14 @@ def update_arguments( if use_fingerprint is not None: self.use_fingerprint = use_fingerprint reset_database = True + if round_targets is not None or not hasattr(self, "round_targets"): + self.round_targets = round_targets + if dtype is not None or not hasattr(self, "dtype"): + self.dtype = dtype if npoints is not None: self.npoints = int(npoints) if initial_indicies is not None: - self.initial_indicies = np.array(initial_indicies, dtype=int) + self.initial_indicies = array(initial_indicies, dtype=int) if include_last is not None: self.include_last = int(abs(include_last)) if random_fraction is not None: @@ -577,7 +651,7 @@ def make_reduction(self, all_indicies, **kwargs): indicies = self.get_last_indicies(indicies, not_indicies) # Get a random index if no fixed index exist if len(indicies) == 0: - indicies = [np.random.choice(all_indicies)] + indicies = [choice(all_indicies)] # Get all the features features = self.get_all_feature_vectors() fdim = len(features[0]) @@ -586,9 +660,9 @@ def make_reduction(self, all_indicies, **kwargs): not_indicies = self.get_not_indicies(indicies, all_indicies) if i % self.random_fraction == 0: # Get a random index - indicies = np.append( + indicies = append( indicies, - [np.random.choice(not_indicies)], + [choice(not_indicies)], ) else: # Calculate the distances to the points already used @@ -597,9 +671,9 @@ def make_reduction(self, all_indicies, **kwargs): features[not_indicies].reshape(-1, fdim), ) # Choose the point furthest from the points already used - i_max = np.argmax(np.nanmin(dist, axis=0)) - indicies = np.append(indicies, [not_indicies[i_max]]) - return np.array(indicies, dtype=int) + i_max = argmax(nanmin(dist, axis=0)) + indicies = append(indicies, [not_indicies[i_max]]) + return array(indicies, dtype=int) def get_arguments(self): "Get the arguments of the class itself." @@ -609,6 +683,8 @@ def get_arguments(self): reduce_dimensions=self.reduce_dimensions, use_derivatives=self.use_derivatives, use_fingerprint=self.use_fingerprint, + round_targets=self.round_targets, + dtype=self.dtype, npoints=self.npoints, initial_indicies=self.initial_indicies, include_last=self.include_last, @@ -633,6 +709,8 @@ def __init__( reduce_dimensions=True, use_derivatives=True, use_fingerprint=True, + round_targets=None, + dtype=None, npoints=25, initial_indicies=[0], include_last=1, @@ -656,6 +734,11 @@ def __init__( use_fingerprint : bool Whether the kernel uses fingerprint objects (True) or arrays (False). + round_targets: int (optional) + The number of decimals to round the targets to. + If None, the targets are not rounded. + dtype: type + The data type of the arrays. npoints : int Number of points that are used from the database. initial_indicies : list @@ -672,6 +755,8 @@ def __init__( reduce_dimensions=reduce_dimensions, use_derivatives=use_derivatives, use_fingerprint=use_fingerprint, + round_targets=round_targets, + dtype=dtype, npoints=npoints, initial_indicies=initial_indicies, include_last=include_last, @@ -685,6 +770,8 @@ def update_arguments( reduce_dimensions=None, use_derivatives=None, use_fingerprint=None, + round_targets=None, + dtype=None, npoints=None, initial_indicies=None, include_last=None, @@ -706,6 +793,11 @@ def update_arguments( use_fingerprint : bool Whether the kernel uses fingerprint objects (True) or arrays (False). + round_targets: int (optional) + The number of decimals to round the targets to. + If None, the targets are not rounded. + dtype: type + The data type of the arrays. npoints : int Number of points that are used from the database. initial_indicies : list @@ -734,10 +826,14 @@ def update_arguments( if use_fingerprint is not None: self.use_fingerprint = use_fingerprint reset_database = True + if round_targets is not None or not hasattr(self, "round_targets"): + self.round_targets = round_targets + if dtype is not None or not hasattr(self, "dtype"): + self.dtype = dtype if npoints is not None: self.npoints = int(npoints) if initial_indicies is not None: - self.initial_indicies = np.array(initial_indicies, dtype=int) + self.initial_indicies = array(initial_indicies, dtype=int) if include_last is not None: self.include_last = int(abs(include_last)) if force_targets is not None: @@ -764,22 +860,23 @@ def make_reduction(self, all_indicies, **kwargs): # Include the last point indicies = self.get_last_indicies(indicies, not_indicies) # Get the indicies for the system not already included - not_indicies = np.array(self.get_not_indicies(indicies, all_indicies)) + not_indicies = array(self.get_not_indicies(indicies, all_indicies)) # Get the targets targets = self.get_all_targets()[not_indicies] # Get sorting of the targets if self.force_targets: # Get the points with the lowest norm of the targets - i_sort = np.argsort(np.linalg.norm(targets, axis=1)) + targets_norm = sqrt(einsum("ij,ij->i", targets, targets)) + i_sort = argsort(targets_norm) else: # Get the points with the lowest energies - i_sort = np.argsort(targets[:, 0]) + i_sort = argsort(targets[:, 0]) # Get the number of missing points npoints = int(self.npoints - len(indicies)) # Get the indicies for the system not already included i_sort = i_sort[:npoints] - indicies = np.append(indicies, not_indicies[i_sort]) - return np.array(indicies, dtype=int) + indicies = append(indicies, not_indicies[i_sort]) + return array(indicies, dtype=int) def get_arguments(self): "Get the arguments of the class itself." @@ -789,6 +886,8 @@ def get_arguments(self): reduce_dimensions=self.reduce_dimensions, use_derivatives=self.use_derivatives, use_fingerprint=self.use_fingerprint, + round_targets=self.round_targets, + dtype=self.dtype, npoints=self.npoints, initial_indicies=self.initial_indicies, include_last=self.include_last, @@ -813,6 +912,8 @@ def __init__( reduce_dimensions=True, use_derivatives=True, use_fingerprint=True, + round_targets=None, + dtype=None, npoints=25, initial_indicies=[0], include_last=1, @@ -835,6 +936,11 @@ def __init__( use_fingerprint : bool Whether the kernel uses fingerprint objects (True) or arrays (False). + round_targets: int (optional) + The number of decimals to round the targets to. + If None, the targets are not rounded. + dtype: type + The data type of the arrays. npoints : int Number of points that are used from the database. initial_indicies : list @@ -848,6 +954,8 @@ def __init__( reduce_dimensions=reduce_dimensions, use_derivatives=use_derivatives, use_fingerprint=use_fingerprint, + round_targets=round_targets, + dtype=dtype, npoints=npoints, initial_indicies=initial_indicies, include_last=include_last, @@ -864,8 +972,8 @@ def make_reduction(self, all_indicies, **kwargs): npoints = int(self.npoints - len(indicies)) # Get the last points in the database if npoints > 0: - indicies = np.append(indicies, not_indicies[-npoints:]) - return np.array(indicies, dtype=int) + indicies = append(indicies, not_indicies[-npoints:]) + return array(indicies, dtype=int) class DatabaseRestart(DatabaseReduction): @@ -875,6 +983,8 @@ def __init__( reduce_dimensions=True, use_derivatives=True, use_fingerprint=True, + round_targets=None, + dtype=None, npoints=25, initial_indicies=[0], include_last=1, @@ -898,6 +1008,11 @@ def __init__( use_fingerprint : bool Whether the kernel uses fingerprint objects (True) or arrays (False). + round_targets: int (optional) + The number of decimals to round the targets to. + If None, the targets are not rounded. + dtype: type + The data type of the arrays. npoints : int Number of points that are used from the database. initial_indicies : list @@ -911,6 +1026,8 @@ def __init__( reduce_dimensions=reduce_dimensions, use_derivatives=use_derivatives, use_fingerprint=use_fingerprint, + round_targets=round_targets, + dtype=dtype, npoints=npoints, initial_indicies=initial_indicies, include_last=include_last, @@ -940,8 +1057,8 @@ def make_reduction(self, all_indicies, **kwargs): # Get the indicies for the system not already included not_indicies = self.get_not_indicies(indicies, all_indicies) # Include the indicies - indicies = np.append(indicies, not_indicies[-(n_extra + lasts) :]) - return np.array(indicies, dtype=int) + indicies = append(indicies, not_indicies[-(n_extra + lasts) :]) + return array(indicies, dtype=int) class DatabasePointsInterest(DatabaseLast): @@ -951,6 +1068,8 @@ def __init__( reduce_dimensions=True, use_derivatives=True, use_fingerprint=True, + round_targets=None, + dtype=None, npoints=25, initial_indicies=[0], include_last=1, @@ -978,6 +1097,11 @@ def __init__( use_fingerprint : bool Whether the kernel uses fingerprint objects (True) or arrays (False). + round_targets: int (optional) + The number of decimals to round the targets to. + If None, the targets are not rounded. + dtype: type + The data type of the arrays. npoints : int Number of points that are used from the database. initial_indicies : list @@ -996,6 +1120,8 @@ def __init__( reduce_dimensions=reduce_dimensions, use_derivatives=use_derivatives, use_fingerprint=use_fingerprint, + round_targets=round_targets, + dtype=dtype, npoints=npoints, initial_indicies=initial_indicies, include_last=include_last, @@ -1013,10 +1139,11 @@ def get_feature_interest(self, **kwargs): the points of interest. """ if self.use_fingerprint: - return np.array( - [feature.get_vector() for feature in self.fp_interest] + return array( + [feature.get_vector() for feature in self.fp_interest], + dtype=self.dtype, ) - return np.array(self.fp_interest) + return array(self.fp_interest, dtype=self.dtype) def get_positions(self, atoms_list, **kwargs): """ @@ -1029,8 +1156,9 @@ def get_positions(self, atoms_list, **kwargs): Returns: list: A list of the positions of the atoms for each system. """ - return np.array( - [atoms.get_positions().reshape(-1) for atoms in atoms_list] + return array( + [atoms.get_positions().reshape(-1) for atoms in atoms_list], + dtype=self.dtype, ) def get_positions_interest(self, **kwargs): @@ -1087,6 +1215,8 @@ def update_arguments( reduce_dimensions=None, use_derivatives=None, use_fingerprint=None, + round_targets=None, + dtype=None, npoints=None, initial_indicies=None, include_last=None, @@ -1109,6 +1239,11 @@ def update_arguments( use_fingerprint : bool Whether the kernel uses fingerprint objects (True) or arrays (False). + round_targets: int (optional) + The number of decimals to round the targets to. + If None, the targets are not rounded. + dtype: type + The data type of the arrays. npoints : int Number of points that are used from the database. initial_indicies : list @@ -1139,10 +1274,14 @@ def update_arguments( if use_fingerprint is not None: self.use_fingerprint = use_fingerprint reset_database = True + if round_targets is not None or not hasattr(self, "round_targets"): + self.round_targets = round_targets + if dtype is not None or not hasattr(self, "dtype"): + self.dtype = dtype if npoints is not None: self.npoints = int(npoints) if initial_indicies is not None: - self.initial_indicies = np.array(initial_indicies, dtype=int) + self.initial_indicies = array(initial_indicies, dtype=int) if include_last is not None: self.include_last = int(abs(include_last)) if feature_distance is not None: @@ -1180,17 +1319,17 @@ def make_reduction(self, all_indicies, **kwargs): # Include the last point indicies = self.get_last_indicies(indicies, not_indicies) # Get the indicies for the system not already included - not_indicies = np.array(self.get_not_indicies(indicies, all_indicies)) + not_indicies = array(self.get_not_indicies(indicies, all_indicies)) # Get the number of missing points npoints = int(self.npoints - len(indicies)) # Calculate the distances to the points of interest dist = self.get_distances(not_indicies) # Get the minimum distances to the points of interest - dist = np.min(dist, axis=0) - i_min = np.argsort(dist)[:npoints] + dist = dist.min(axis=0) + i_min = argsort(dist)[:npoints] # Get the indicies - indicies = np.append(indicies, [not_indicies[i_min]]) - return np.array(indicies, dtype=int) + indicies = append(indicies, [not_indicies[i_min]]) + return array(indicies, dtype=int) def get_arguments(self): "Get the arguments of the class itself." @@ -1200,6 +1339,8 @@ def get_arguments(self): reduce_dimensions=self.reduce_dimensions, use_derivatives=self.use_derivatives, use_fingerprint=self.use_fingerprint, + round_targets=self.round_targets, + dtype=self.dtype, npoints=self.npoints, initial_indicies=self.initial_indicies, include_last=self.include_last, @@ -1225,6 +1366,8 @@ def __init__( reduce_dimensions=True, use_derivatives=True, use_fingerprint=True, + round_targets=None, + dtype=None, npoints=25, initial_indicies=[0], include_last=1, @@ -1252,6 +1395,11 @@ def __init__( use_fingerprint : bool Whether the kernel uses fingerprint objects (True) or arrays (False). + round_targets: int (optional) + The number of decimals to round the targets to. + If None, the targets are not rounded. + dtype: type + The data type of the arrays. npoints : int Number of points that are used from the database. initial_indicies : list @@ -1270,6 +1418,8 @@ def __init__( reduce_dimensions=reduce_dimensions, use_derivatives=use_derivatives, use_fingerprint=use_fingerprint, + round_targets=round_targets, + dtype=dtype, npoints=npoints, initial_indicies=initial_indicies, include_last=include_last, @@ -1293,7 +1443,7 @@ def make_reduction(self, all_indicies, **kwargs): # Include the last point indicies = self.get_last_indicies(indicies, not_indicies) # Get the indicies for the system not already included - not_indicies = np.array(self.get_not_indicies(indicies, all_indicies)) + not_indicies = array(self.get_not_indicies(indicies, all_indicies)) # Calculate the distances to the points of interest dist = self.get_distances(not_indicies) # Get the number of points of interest @@ -1302,14 +1452,14 @@ def make_reduction(self, all_indicies, **kwargs): p = 0 while len(indicies) < self.npoints: # Get the point with the minimum distance - i_min = np.argmin(dist[p]) + i_min = argmin(dist[p]) # Get and append the index - indicies = np.append(indicies, [not_indicies[i_min]]) + indicies = append(indicies, [not_indicies[i_min]]) # Remove the index - not_indicies = np.delete(not_indicies, i_min) - dist = np.delete(dist, i_min, axis=1) + not_indicies = delete(not_indicies, i_min) + dist = delete(dist, i_min, axis=1) # Use the next point p += 1 if p >= n_points_interest: p = 0 - return np.array(indicies, dtype=int) + return array(indicies, dtype=int) From 6839cae6b34cf71e94d938842befeb20b62fa3ec Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Wed, 2 Apr 2025 12:39:05 +0200 Subject: [PATCH 089/194] Round predictions and use data type --- catlearn/regression/gp/calculator/bocalc.py | 42 +++++++++++---- catlearn/regression/gp/calculator/mlcalc.py | 28 ++++++++-- catlearn/regression/gp/calculator/mlmodel.py | 57 +++++++++++++------- 3 files changed, 96 insertions(+), 31 deletions(-) diff --git a/catlearn/regression/gp/calculator/bocalc.py b/catlearn/regression/gp/calculator/bocalc.py index fdaeee9a..0f44e10a 100644 --- a/catlearn/regression/gp/calculator/bocalc.py +++ b/catlearn/regression/gp/calculator/bocalc.py @@ -24,6 +24,7 @@ def __init__( calc_force_unc=False, calc_unc_deriv=True, calc_kwargs={}, + round_pred=None, kappa=2.0, **kwargs, ): @@ -50,6 +51,9 @@ def __init__( calc_kwargs: dict A dictionary with kwargs for the parent calculator class object. + round_pred: int (optional) + The number of decimals to round the preditions to. + If None, the predictions are not rounded. kappa: float The weight of the uncertainty relative to the energy. If kappa>0, the uncertainty is added to the predicted energy. @@ -61,6 +65,7 @@ def __init__( calc_force_unc=calc_force_unc, calc_unc_deriv=calc_unc_deriv, calc_kwargs=calc_kwargs, + round_pred=round_pred, kappa=kappa, **kwargs, ) @@ -135,21 +140,31 @@ def calculate( get_unc_derivatives=get_unc_derivatives, ) # Store the properties that are implemented - for key, value in results.items(): - if key in self.implemented_properties: - self.results[key] = value + self.store_properties(results) # Save the predicted properties - self.results["predicted energy"] = results["energy"] + self.modify_results_bo( + get_forces=get_forces, + ) + return self.results + + def modify_results_bo( + self, + get_forces, + **kwargs, + ): + """ + Modify the results of the Bayesian optimization calculator. + """ + # Save the predicted properties + self.results["predicted energy"] = self.results["energy"] if get_forces: - self.results["predicted forces"] = results["forces"].copy() + self.results["predicted forces"] = self.results["forces"].copy() # Calculate the acquisition function and its derivative if self.kappa != 0.0: - self.results["energy"] = ( - results["energy"] + self.kappa * results["uncertainty"] - ) + self.results["energy"] += self.kappa * self.results["uncertainty"] if get_forces: - self.results["forces"] = results["forces"] - ( - self.kappa * results["uncertainty derivatives"] + self.results["forces"] -= ( + self.kappa * self.results["uncertainty derivatives"] ) return self.results @@ -161,6 +176,7 @@ def update_arguments( calc_force_unc=None, calc_unc_deriv=None, calc_kwargs=None, + round_pred=None, kappa=None, **kwargs, ): @@ -187,6 +203,9 @@ def update_arguments( calc_kwargs: dict A dictionary with kwargs for the parent calculator class object. + round_pred: int (optional) + The number of decimals to round the preditions to. + If None, the predictions are not rounded. kappa: float The weight of the uncertainty relative to the energy. @@ -205,6 +224,8 @@ def update_arguments( self.calc_unc_deriv = calc_unc_deriv if calc_kwargs is not None: self.calc_kwargs = calc_kwargs.copy() + if round_pred is not None or not hasattr(self, "round_pred"): + self.round_pred = round_pred if kappa is not None: self.kappa = float(kappa) # Empty the results @@ -257,6 +278,7 @@ def get_arguments(self): calc_force_unc=self.calc_force_unc, calc_unc_deriv=self.calc_unc_deriv, calc_kwargs=self.calc_kwargs, + round_pred=self.round_pred, kappa=self.kappa, ) # Get the constants made within the class diff --git a/catlearn/regression/gp/calculator/mlcalc.py b/catlearn/regression/gp/calculator/mlcalc.py index 3b116877..4bed4b17 100644 --- a/catlearn/regression/gp/calculator/mlcalc.py +++ b/catlearn/regression/gp/calculator/mlcalc.py @@ -1,3 +1,4 @@ +from numpy import round as round_ from ase.calculators.calculator import Calculator, all_changes @@ -21,6 +22,7 @@ def __init__( calc_force_unc=False, calc_unc_deriv=False, calc_kwargs={}, + round_pred=None, **kwargs, ): """ @@ -45,6 +47,9 @@ def __init__( calc_kwargs: dict A dictionary with kwargs for the parent calculator class object. + round_pred: int (optional) + The number of decimals to round the predictions to. + If None, the predictions are not rounded. """ # Inherit from the Calculator object Calculator.__init__(self, **calc_kwargs) @@ -66,6 +71,7 @@ def __init__( calc_force_unc=calc_force_unc, calc_unc_deriv=calc_unc_deriv, calc_kwargs=calc_kwargs, + round_pred=round_pred, **kwargs, ) @@ -301,9 +307,7 @@ def calculate( get_unc_derivatives=get_unc_derivatives, ) # Store the properties that are implemented - for key, value in results.items(): - if key in self.implemented_properties: - self.results[key] = value + self.store_properties(results) return self.results def save_mlcalc(self, filename="mlcalc.pkl", **kwargs): @@ -348,6 +352,7 @@ def update_arguments( calc_force_unc=None, calc_unc_deriv=None, calc_kwargs=None, + round_pred=None, **kwargs, ): """ @@ -373,6 +378,9 @@ def update_arguments( calc_kwargs: dict A dictionary with kwargs for the parent calculator class object. + round_pred: int (optional) + The number of decimals to round the predictions to. + If None, the predictions are not rounded. Returns: self: The updated object itself. @@ -389,6 +397,8 @@ def update_arguments( self.calc_unc_deriv = calc_unc_deriv if calc_kwargs is not None: self.calc_kwargs = calc_kwargs.copy() + if round_pred is not None or not hasattr(self, "round_pred"): + self.round_pred = round_pred # Empty the results self.reset() return self @@ -447,6 +457,17 @@ def model_prediction( ) return results + def store_properties(self, results, **kwargs): + "Store the properties that are implemented." + for key, value in results.items(): + if key in self.implemented_properties: + # Round the predictions if needed + if self.round_pred is not None: + value = round_(value, self.round_pred) + # Save the properties in the results + self.results[key] = value + return self.results + def get_arguments(self): "Get the arguments of the class itself." # Get the arguments given to the class in the initialization @@ -457,6 +478,7 @@ def get_arguments(self): calc_force_unc=self.calc_force_unc, calc_unc_deriv=self.calc_unc_deriv, calc_kwargs=self.calc_kwargs, + round_pred=self.round_pred, ) # Get the constants made within the class constant_kwargs = dict() diff --git a/catlearn/regression/gp/calculator/mlmodel.py b/catlearn/regression/gp/calculator/mlmodel.py index 128d9d76..5ba658dd 100644 --- a/catlearn/regression/gp/calculator/mlmodel.py +++ b/catlearn/regression/gp/calculator/mlmodel.py @@ -1,4 +1,5 @@ -import numpy as np +from numpy import asarray, ndarray, sqrt, zeros +import warnings class MLModel: @@ -12,6 +13,7 @@ def __init__( pdis=None, include_noise=False, verbose=False, + dtype=None, **kwargs, ): """ @@ -38,6 +40,8 @@ def __init__( Whether to include noise in the uncertainty from the model. verbose: bool Whether to print statements in the optimization. + dtype: type + The data type of the arrays. """ # Make default model if it is not given if model is None: @@ -55,6 +59,7 @@ def __init__( pdis=pdis, include_noise=include_noise, verbose=verbose, + dtype=dtype, **kwargs, ) @@ -70,7 +75,7 @@ def add_training(self, atoms_list, **kwargs): Returns: self: The updated object itself. """ - if not isinstance(atoms_list, (list, np.ndarray)): + if not isinstance(atoms_list, (list, ndarray)): atoms_list = [atoms_list] self.database.add_set(atoms_list) self.store_baseline_targets(atoms_list) @@ -248,6 +253,7 @@ def update_arguments( pdis=None, include_noise=None, verbose=None, + dtype=None, **kwargs, ): """ @@ -275,6 +281,8 @@ def update_arguments( Whether to include noise in the uncertainty from the model. verbose: bool Whether to print statements in the optimization. + dtype: type + The data type of the arrays. Returns: self: The updated object itself. @@ -301,6 +309,8 @@ def update_arguments( self.include_noise = include_noise if verbose is not None: self.verbose = verbose + if dtype is not None or not hasattr(self, "dtype"): + self.dtype = dtype # Check if the baseline is used if self.baseline is None: self.use_baseline = False @@ -349,7 +359,7 @@ def model_prediction( fp = self.database.make_atoms_feature(atoms) # Calculate energy, forces, and uncertainty y, var, var_deriv = self.model.predict( - np.array([fp]), + asarray([fp], dtype=self.dtype), get_derivatives=get_forces, get_variance=get_uncertainty, include_noise=self.include_noise, @@ -371,10 +381,10 @@ def model_prediction( forces = None # Get the uncertainties if they are requested if get_uncertainty: - unc = np.sqrt(var[0][0]) + unc = sqrt(var[0][0]) # Get the uncertainty of the forces if they are requested if get_force_uncertainties and get_forces: - unc_forces = np.sqrt(unc[0][1:]) + unc_forces = sqrt(unc[0][1:]) else: unc_forces = None # Get the derivatives of the predicted uncertainty @@ -417,22 +427,22 @@ def store_results( if forces is not None: results["forces"] = self.not_masked_reshape( forces, - not_masked, - natoms, + not_masked=not_masked, + natoms=natoms, ) # Make the full matrix of force uncertainties and save it if unc_forces is not None: results["force uncertainties"] = self.not_masked_reshape( unc_forces, - not_masked, - natoms, + not_masked=not_masked, + natoms=natoms, ) # Make the full matrix of derivatives of uncertainty and save it if unc_deriv is not None: results["uncertainty derivatives"] = self.not_masked_reshape( unc_deriv, - not_masked, - natoms, + not_masked=not_masked, + natoms=natoms, ) return results @@ -448,7 +458,7 @@ def add_baseline_correction( **kwargs, ) # Add baseline correction to the targets - return targets + np.array(y_base)[0] + return targets + asarray(y_base, dtype=self.dtype)[0] return targets def get_baseline_corrected_targets(self, targets, **kwargs): @@ -457,7 +467,7 @@ def get_baseline_corrected_targets(self, targets, **kwargs): The baseline correction is subtracted from training targets. """ if self.use_baseline: - return targets - np.array(self.baseline_targets) + return targets - asarray(self.baseline_targets, dtype=self.dtype) return targets def store_baseline_targets(self, atoms_list, **kwargs): @@ -483,13 +493,13 @@ def calculate_baseline(self, atoms_list, use_derivatives=True, **kwargs): ) return y_base - def not_masked_reshape(self, array, not_masked, natoms, **kwargs): + def not_masked_reshape(self, nm_array, not_masked, natoms, **kwargs): """ Reshape an array so that it works for all atom coordinates and set constrained indicies to 0. """ - full_array = np.zeros((natoms, 3)) - full_array[not_masked] = array.reshape(-1, 3) + full_array = zeros((natoms, 3), dtype=self.dtype) + full_array[not_masked] = nm_array.reshape(-1, 3) return full_array def get_data(self, **kwargs): @@ -567,6 +577,7 @@ def get_arguments(self): pdis=self.pdis, include_noise=self.include_noise, verbose=self.verbose, + dtype=self.dtype, ) # Get the constants made within the class constant_kwargs = dict() @@ -707,8 +718,6 @@ def get_default_model( tol=1e-12, ) if parallel: - import warnings - warnings.warn( "Parallel optimization is not implemented" "with local optimization!" @@ -778,6 +787,8 @@ def get_default_database( use_derivatives=True, database_reduction=False, database_reduction_kwargs={}, + round_targets=None, + dtype=None, **kwargs, ): """ @@ -815,9 +826,12 @@ def get_default_database( reduce_dimensions=True, use_derivatives=use_derivatives, use_fingerprint=use_fingerprint, + round_targets=round_targets, + dtype=dtype, npoints=50, initial_indicies=[0, 1], include_last=1, + **kwargs, ) data_kwargs.update(database_reduction_kwargs) if database_reduction.lower() == "distance": @@ -862,6 +876,8 @@ def get_default_database( reduce_dimensions=True, use_derivatives=use_derivatives, use_fingerprint=use_fingerprint, + round_targets=round_targets, + dtype=dtype, ) return database @@ -879,7 +895,9 @@ def get_default_mlmodel( n_reduced=None, database_reduction=False, database_reduction_kwargs={}, + round_targets=None, verbose=False, + dtype=None, **kwargs, ): """ @@ -948,6 +966,8 @@ def get_default_mlmodel( use_derivatives=use_derivatives, database_reduction=database_reduction, database_reduction_kwargs=database_reduction_kwargs, + round_targets=round_targets, + dtype=dtype, ) # Make prior distributions for the hyperparameters if specified if use_pdis: @@ -967,4 +987,5 @@ def get_default_mlmodel( optimize=optimize_hp, pdis=pdis, verbose=verbose, + dtype=dtype, ) From 93781c303f0d2a3c24e304bb7636818e91f8c5e3 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Wed, 2 Apr 2025 12:40:08 +0200 Subject: [PATCH 090/194] Round hyperparameters and use data type --- catlearn/regression/gp/hpfitter/fbpmgp.py | 180 +++++++++++------- catlearn/regression/gp/hpfitter/hpfitter.py | 57 ++++-- .../regression/gp/hpfitter/redhpfitter.py | 40 ++-- 3 files changed, 172 insertions(+), 105 deletions(-) diff --git a/catlearn/regression/gp/hpfitter/fbpmgp.py b/catlearn/regression/gp/hpfitter/fbpmgp.py index 3c673a28..530e3eb6 100644 --- a/catlearn/regression/gp/hpfitter/fbpmgp.py +++ b/catlearn/regression/gp/hpfitter/fbpmgp.py @@ -1,7 +1,29 @@ -import numpy as np -from numpy.linalg import eigh +from numpy import ( + asarray, + append, + argsort, + diag, + einsum, + empty, + exp, + finfo, + full, + inf, + log, + matmul, + nanargmin, + nanmax, + pi, + triu_indices, + where, + zeros, +) +from numpy.linalg import eigh, LinAlgError +import numpy.random as random +from scipy.linalg import eigh as scipy_eigh from scipy.spatial.distance import pdist from scipy.optimize import OptimizeResult +import logging from .hpfitter import HyperparameterFitter @@ -13,6 +35,8 @@ def __init__( ngrid=80, bounds=None, get_prior_mean=False, + round_hp=None, + dtype=None, **kwargs, ): """ @@ -34,6 +58,11 @@ def __init__( of the hyperparameter. get_prior_mean: bool Whether to get the prior arguments in the solution. + round_hp: int (optional) + The number of decimals to round the hyperparameters to. + If None, the hyperparameters are not rounded. + dtype: type + The data type of the arrays. """ # Set the default test points self.Q = None @@ -49,6 +78,8 @@ def __init__( ngrid=ngrid, bounds=bounds, get_prior_mean=get_prior_mean, + round_hp=round_hp, + dtype=dtype, **kwargs, ) @@ -78,6 +109,8 @@ def update_arguments( ngrid=None, bounds=None, get_prior_mean=None, + round_hp=None, + dtype=None, **kwargs, ): """ @@ -98,6 +131,11 @@ def update_arguments( of the hyperparameter. get_prior_mean: bool Whether to get the prior arguments in the solution. + round_hp: int (optional) + The number of decimals to round the hyperparameters to. + If None, the hyperparameters are not rounded. + dtype: type + The data type of the arrays. Returns: self: The updated object itself. @@ -112,11 +150,16 @@ def update_arguments( self.bounds = bounds.copy() if get_prior_mean is not None: self.get_prior_mean = get_prior_mean + if round_hp is not None or not hasattr(self, "round_hp"): + self.round_hp = round_hp + if dtype is not None or not hasattr(self, "dtype"): + self.dtype = dtype return self def get_hp(self, theta, parameters, **kwargs): "Make hyperparameter dictionary from lists." - theta, parameters = np.array(theta), np.array(parameters) + theta = asarray(theta) + parameters = asarray(parameters) parameters_set = sorted(set(parameters)) hp = { para_s: self.numeric_limits(theta[parameters == para_s]) @@ -124,12 +167,12 @@ def get_hp(self, theta, parameters, **kwargs): } return hp, parameters_set - def numeric_limits(self, array, dh=0.4 * np.log(np.finfo(float).max)): + def numeric_limits(self, theta, dh=0.4 * log(finfo(float).max)): """ Replace hyperparameters if they are outside of the numeric limits in log-space. """ - return np.where(-dh < array, np.where(array < dh, array, dh), -dh) + return where(-dh < theta, where(theta < dh, theta, dh), -dh) def update_model(self, model, hp, **kwargs): "Update model." @@ -146,7 +189,7 @@ def kxx_corr(self, model, X, **kwargs): def add_correction(self, model, KXX, n_data, **kwargs): "Add noise correction to covariance matrix." - corr = model.get_correction(np.diag(KXX)) + corr = model.get_correction(diag(KXX)) if corr != 0.0: KXX[range(n_data), range(n_data)] += corr return KXX @@ -172,33 +215,27 @@ def get_eig(self, model, X, Y, **kwargs): # Eigendecomposition try: D, U = eigh(KXX) - except Exception as e: - import logging - import scipy.linalg - + except LinAlgError as e: logging.error("An error occurred: %s", str(e)) # More robust but slower eigendecomposition - D, U = scipy.linalg.eigh(KXX, driver="ev") + D, U = scipy_eigh(KXX, driver="ev") # Subtract the prior mean to the training target Y_p = self.y_prior(X, Y, model, D=D, U=U) - UTY = (np.matmul(U.T, Y_p)).reshape(-1) ** 2 + UTY = (matmul(U.T, Y_p)).reshape(-1) ** 2 return D, U, Y_p, UTY, KXX, n_data def get_eig_without_Yp(self, model, X, Y_p, n_data, **kwargs): "Calculate the eigenvalues without using the prior mean." # Calculate the kernel with and without noise - KXX, n_data = self.kxx_corr(model, X) + KXX, _ = self.kxx_corr(model, X) # Eigendecomposition try: D, U = eigh(KXX) - except Exception as e: - import logging - import scipy.linalg - + except LinAlgError as e: logging.error("An error occurred: %s", str(e)) # More robust but slower eigendecomposition - D, U = scipy.linalg.eigh(KXX, driver="ev") - UTY = np.matmul(U.T, Y_p) + D, U = scipy_eigh(KXX, driver="ev") + UTY = matmul(U.T, Y_p) UTY2 = UTY.reshape(-1) ** 2 return D, U, UTY, UTY2, Y_p, KXX @@ -223,7 +260,7 @@ def get_grids( if para_bool[para]: grids[para] = lines[p].copy() else: - grids[para] = np.array([model_hp[para][0]]) + grids[para] = asarray([model_hp[para][0]], dtype=self.dtype) return grids def trapz_coef(self, grids, para_bool, **kwargs): @@ -231,16 +268,16 @@ def trapz_coef(self, grids, para_bool, **kwargs): cs = {} for para, pbool in para_bool.items(): if pbool: - cs[para] = np.log(self.trapz_append(grids[para])) + cs[para] = log(self.trapz_append(grids[para])) else: - cs[para] = np.array([0.0]) + cs[para] = asarray([0.0], dtype=self.dtype) return cs def prior_grid(self, grids, pdis=None, i=0, **kwargs): "Get prior distribution of hyperparameters on the grid." if pdis is None: return { - para: np.array([0.0] * len(grid)) + para: zeros((len(grid)), dtype=self.dtype) for para, grid in grids.items() } pr_grid = {} @@ -248,7 +285,7 @@ def prior_grid(self, grids, pdis=None, i=0, **kwargs): if para in pdis.keys(): pr_grid[para] = pdis[para].ln_pdf(grid) else: - pr_grid[para] = np.array([0.0] * len(grid)) + pr_grid[para] = zeros((len(grid)), dtype=self.dtype) return pr_grid def get_all_grids( @@ -285,23 +322,24 @@ def get_all_grids( def trapz_append(self, grid, **kwargs): "Get the weights in linear space from the trapezoidal rule." g1 = [grid[1] - grid[0]] - g2 = np.append(grid[2:] - grid[:-2], grid[-1] - grid[-2]) - return 0.5 * np.append(g1, g2) + g2 = append(grid[2:] - grid[:-2], grid[-1] - grid[-2]) + return 0.5 * append(g1, g2) def get_test_points(self, Q, X_tr, **kwargs): "Get the test point if they are not given." if Q is not None: return Q - i_sort = np.argsort(pdist(X_tr))[: self.n_test] - i_list, j_list = np.triu_indices(len(X_tr), k=1, m=None) + i_sort = argsort(pdist(X_tr))[: self.n_test] + i_list, j_list = triu_indices(len(X_tr), k=1, m=None) i_list, j_list = i_list[i_sort], j_list[i_sort] - r = np.random.uniform(low=0.01, high=0.99, size=(2, len(i_list))) - r = r / np.sum(r, axis=0) - Q = np.array( + r = random.uniform(low=0.01, high=0.99, size=(2, len(i_list))) + r = r / r.sum(axis=0) + Q = asarray( [ X_tr[i] * r[0, k] + X_tr[j] * r[1, k] for k, (i, j) in enumerate(zip(i_list, j_list)) - ] + ], + dtype=self.dtype, ) return Q @@ -320,7 +358,7 @@ def get_test_KQ(self, model, Q, X_tr, use_derivatives=False, **kwargs): def get_prefactors(self, grids, n_data, **kwargs): "Get the prefactor values for log-likelihood." - prefactors = np.exp(2 * grids["prefactor"]).reshape(-1, 1) + prefactors = exp(2.0 * grids["prefactor"]).reshape(-1, 1) ln_prefactor = (n_data * grids["prefactor"]).reshape(-1, 1) return prefactors, ln_prefactor @@ -366,7 +404,7 @@ def get_all_eig_matrices( include_noise=False, ) KQX = model.get_kernel(Q, X, get_derivatives=get_derivatives) - UKQX = np.matmul(KQX, U) + UKQX = matmul(KQX, U) return D, UTY, UTY2, KQQ, UKQX def posterior_value( @@ -384,20 +422,20 @@ def posterior_value( **kwargs, ): "Get the posterior distribution value and add it to the existing sum." - nlp1 = 0.5 * np.sum(UTY2 / D_n, axis=1) - nlp2 = 0.5 * np.sum(np.log(D_n), axis=1) + nlp1 = 0.5 * (UTY2 / D_n).sum(axis=1) + nlp2 = 0.5 * log(D_n).sum(axis=1) like = -( (nlp1 / prefactors + ln_prefactor) + (nlp2 + ln2pi) ) + self.get_grid_sum(pr_grid, l_index) - like_max = np.nanmax(like) + like_max = nanmax(like) if like_max > lp_max: - ll_scale = np.exp(lp_max - like_max) + ll_scale = exp(lp_max - like_max) lp_max = like_max else: ll_scale = 1.0 like = like - lp_max - like = np.exp(like + self.get_grid_sum(cs, l_index)) - like_sum = like_sum * ll_scale + np.sum(like) + like = exp(like + self.get_grid_sum(cs, l_index)) + like_sum = like_sum * ll_scale + like.sum() return like_sum, like, lp_max, ll_scale def get_grid_sum(self, the_grids, l_index): @@ -410,8 +448,8 @@ def get_grid_sum(self, the_grids, l_index): def pred_unc(self, UKQX, UTY, D_n, KQQ, yp, **kwargs): "Make prediction mean and uncertainty from eigendecomposition." UKQXD = UKQX / D_n[:, None, :] - pred = yp + np.einsum("dij,ji->di", UKQXD, UTY, optimize=True) - var = KQQ - np.einsum("dij,ji->di", UKQXD, UKQX.T) + pred = yp + einsum("dij,ji->di", UKQXD, UTY, optimize=True) + var = KQQ - einsum("dij,ji->di", UKQXD, UKQX.T) return pred, var def update_df_ybar( @@ -429,19 +467,19 @@ def update_df_ybar( **kwargs, ): "Update the dict and add values to ybar and y2bar_ubar." - ybar = (ybar * ll_scale) + np.einsum("nj,pn->j", pred, like) + ybar = (ybar * ll_scale) + einsum("nj,pn->j", pred, like) y2bar_ubar = (y2bar_ubar * ll_scale) + ( - np.einsum("nj,pn->j", pred**2, like) - + np.einsum("nj,pn->j", var, prefactors * like) + einsum("nj,pn->j", pred**2, like) + + einsum("nj,pn->j", var, prefactors * like) ) # Store the hyperparameters and prediction mean and variance - df["length"] = np.append( + df["length"] = append( df["length"], - np.full(np.shape(noises), length), + full(noises.shape, length, dtype=self.dtype), ) - df["noise"] = np.append(df["noise"], noises) - df["pred"] = np.append(df["pred"], pred, axis=0) - df["var"] = np.append(df["var"], var, axis=0) + df["noise"] = append(df["noise"], noises) + df["pred"] = append(df["pred"], pred, axis=0) + df["var"] = append(df["var"], var, axis=0) return df, ybar, y2bar_ubar def evaluate_for_noise( @@ -471,7 +509,7 @@ def evaluate_for_noise( Evaluate log-posterior and update the data frame for all noise hyperparameter in grid simulatenously. """ - D_n = D + np.exp(2 * grids["noise"]).reshape(-1, 1) + D_n = D + exp(2.0 * grids["noise"]).reshape(-1, 1) # Calculate log-posterior like_sum, like, lp_max, ll_scale = self.posterior_value( like_sum, @@ -521,26 +559,25 @@ def get_solution( ybar = ybar / like_sum y2bar_ubar = y2bar_ubar / like_sum # Get the analytic solution to the prefactor - prefactor = np.mean( - (y2bar_ubar + (df["pred"] ** 2) - (2 * df["pred"] * ybar)) + prefactor = ( + (y2bar_ubar + (df["pred"] ** 2) - (2.0 * df["pred"] * ybar)) / df["var"], - axis=1, - ) + ).mean(axis=1) # Calculate all Kullback-Leibler divergences kl = 0.5 * ( - n_test * (1 + np.log(2 * np.pi)) - + (np.sum(np.log(df["var"]), axis=1) + n_test * np.log(prefactor)) + n_test * (1 + log(2.0 * pi)) + + (log(df["var"]).sum(axis=1) + n_test * log(prefactor)) ) # Find the best solution - i_min = np.nanargmin(kl) + i_min = nanargmin(kl) kl_min = kl[i_min] / n_test hp_best = dict( - length=np.array([df["length"][i_min]]), - noise=np.array([df["noise"][i_min]]), - prefactor=np.array([0.5 * np.log(prefactor[i_min])]), + length=asarray([df["length"][i_min]], dtype=self.dtype), + noise=asarray([df["noise"][i_min]], dtype=self.dtype), + prefactor=asarray([0.5 * log(prefactor[i_min])], dtype=self.dtype), ) theta = [hp_best[para] for para in hp_best.keys()] - theta = np.array(theta).reshape(-1) + theta = asarray(theta, dtype=self.dtype).reshape(-1) sol = { "fun": kl_min, "hp": hp_best, @@ -565,7 +602,6 @@ def fbpmgp( **kwargs, ): "Only works with the FBPMGP object function." - np.random.seed(12) # Update hyperparameters hp, parameters_set = self.get_hp(theta, parameters) model = self.update_model(model, hp) @@ -586,25 +622,25 @@ def fbpmgp( use_derivatives = model.use_derivatives yp = model.get_priormean( Q, - np.zeros((len(Q), len(Y[0]))), + zeros((len(Q), len(Y[0])), dtype=self.dtype), get_derivatives=use_derivatives, ) yp = yp.reshape(-1) n_data = len(Y_p) # Initialize fb df = { - key: np.array([]) for key in ["ll", "length", "noise", "prefactor"] + key: asarray([]) for key in ["ll", "length", "noise", "prefactor"] } if model.use_derivatives: - df["pred"] = np.empty((0, len(Q) * len(Y[0]))) - df["var"] = np.empty((0, len(Q) * len(Y[0]))) + df["pred"] = empty((0, len(Q) * len(Y[0])), dtype=self.dtype) + df["var"] = empty((0, len(Q) * len(Y[0])), dtype=self.dtype) else: - df["pred"] = np.empty((0, len(Q))) - df["var"] = np.empty((0, len(Q))) + df["pred"] = empty((0, len(Q)), dtype=self.dtype) + df["var"] = empty((0, len(Q)), dtype=self.dtype) like_sum, ybar, y2bar_ubar = 0.0, 0.0, 0.0 - lp_max = -np.inf + lp_max = -inf prefactors, ln_prefactor = self.get_prefactors(grids, n_data) - ln2pi = 0.5 * n_data * np.log(2 * np.pi) + ln2pi = 0.5 * n_data * log(2.0 * pi) for l_index, length in enumerate(grids["length"]): D, UTY, UTY2, KQQ, UKQX = self.get_all_eig_matrices( length, @@ -656,6 +692,8 @@ def get_arguments(self): ngrid=self.ngrid, bounds=self.bounds, get_prior_mean=self.get_prior_mean, + round_hp=self.round_hp, + dtype=self.dtype, ) # Get the constants made within the class constant_kwargs = dict() diff --git a/catlearn/regression/gp/hpfitter/hpfitter.py b/catlearn/regression/gp/hpfitter/hpfitter.py index ba032edc..1323eedd 100644 --- a/catlearn/regression/gp/hpfitter/hpfitter.py +++ b/catlearn/regression/gp/hpfitter/hpfitter.py @@ -1,4 +1,4 @@ -import numpy as np +from numpy import asarray, round as round_ class HyperparameterFitter: @@ -10,6 +10,7 @@ def __init__( use_update_pdis=False, get_prior_mean=False, use_stored_sols=False, + round_hp=None, **kwargs, ): """ @@ -17,24 +18,27 @@ def __init__( the hyperparameters on different given objective functions. Parameters: - func : ObjectiveFunction class + func: ObjectiveFunction class A class with the objective function used to optimize the hyperparameters. - optimizer : Optimizer class + optimizer: Optimizer class A class with the used optimization method. - bounds : HPBoundaries class + bounds: HPBoundaries class A class of the boundary conditions of the hyperparameters. Most of the global optimizers are using boundary conditions. The bounds in this class will be used for the optimizer and func. - use_update_pdis : bool + use_update_pdis: bool Whether to update the prior distributions of the hyperparameters with the given boundary conditions. - get_prior_mean : bool + get_prior_mean: bool Whether to get the parameters of the prior mean in the solution. - use_stored_sols : bool + use_stored_sols: bool Whether to store the solutions. + round_hp: int (optional) + The number of decimals to round the hyperparameters to. + If None, the hyperparameters are not rounded. """ # Set the default optimizer if optimizer is None: @@ -54,6 +58,7 @@ def __init__( use_update_pdis=use_update_pdis, get_prior_mean=get_prior_mean, use_stored_sols=use_stored_sols, + round_hp=round_hp, **kwargs, ) @@ -62,22 +67,22 @@ def fit(self, X, Y, model, hp=None, pdis=None, **kwargs): Optimize the hyperparameters. Parameters: - X : (N,D) array + X: (N,D) array Training features with N data points and D dimensions. - Y : (N,1) array or (N,D+1) array + Y: (N,1) array or (N,D+1) array Training targets with or without derivatives with N data points. - model : Model + model: Model The Machine Learning Model with kernel and prior that are optimized. - hp : dict + hp: dict Use a set of hyperparameters to optimize from else the current set is used. - pdis : dict + pdis: dict A dict of prior distributions for each hyperparameter type. Returns: - dict : A solution dictionary with objective function value, + dict: A solution dictionary with objective function value, optimized hyperparameters, success statement, and number of used evaluations. """ @@ -117,6 +122,7 @@ def update_arguments( use_update_pdis=None, get_prior_mean=None, use_stored_sols=None, + round_hp=None, **kwargs, ): """ @@ -124,24 +130,27 @@ def update_arguments( The existing arguments are used if they are not given. Parameters: - func : ObjectiveFunction class + func: ObjectiveFunction class A class with the objective function used to optimize the hyperparameters. - optimizer : Optimizer class + optimizer: Optimizer class A class with the used optimization method. - bounds : HPBoundaries class + bounds: HPBoundaries class A class of the boundary conditions of the hyperparameters. Most of the global optimizers are using boundary conditions. The bounds in this class will be used for the optimizer and func. - use_update_pdis : bool + use_update_pdis: bool Whether to update the prior distributions of the hyperparameters with the given boundary conditions. - get_prior_mean : bool + get_prior_mean: bool Whether to get the parameters of the prior mean in the solution. - use_stored_sols : bool + use_stored_sols: bool Whether to store the solutions. + round_hp: int (optional) + The number of decimals to round the hyperparameters to. + If None, the hyperparameters are not rounded. Returns: self: The updated object itself. @@ -158,6 +167,8 @@ def update_arguments( self.get_prior_mean = get_prior_mean if use_stored_sols is not None: self.use_stored_sols = use_stored_sols + if round_hp is not None or not hasattr(self, "round_hp"): + self.round_hp = round_hp # Empty the stored solutions self.sols = [] # Make sure that the objective function gets the prior mean parameters @@ -205,7 +216,7 @@ def hp_to_theta(self, hp): parameters = sum( [[para] * len(hp[para]) for para in parameters_set], [] ) - return np.array(theta), parameters + return asarray(theta), parameters def update_bounds(self, model, X, Y, parameters, **kwargs): "Update the boundary condition class with the data." @@ -244,6 +255,11 @@ def get_full_hp(self, sol, model, **kwargs): that are optimized and within the model. """ sol["full hp"] = model.get_hyperparams() + # Round the hyperparameters if needed + if self.round_hp is not None: + for key, value in sol["hp"].items(): + sol["hp"][key] = round_(value, self.round_hp) + # Update the optimized hyperparameters sol["full hp"].update(sol["hp"]) return sol @@ -267,6 +283,7 @@ def get_arguments(self): use_update_pdis=self.use_update_pdis, get_prior_mean=self.get_prior_mean, use_stored_sols=self.use_stored_sols, + round_hp=self.round_hp, ) # Get the constants made within the class constant_kwargs = dict() diff --git a/catlearn/regression/gp/hpfitter/redhpfitter.py b/catlearn/regression/gp/hpfitter/redhpfitter.py index bfc2d451..07f7d51c 100644 --- a/catlearn/regression/gp/hpfitter/redhpfitter.py +++ b/catlearn/regression/gp/hpfitter/redhpfitter.py @@ -1,4 +1,4 @@ -import numpy as np +from numpy import inf from scipy.optimize import OptimizeResult from .hpfitter import HyperparameterFitter @@ -12,6 +12,7 @@ def __init__( use_update_pdis=False, get_prior_mean=False, use_stored_sols=False, + round_hp=None, opt_tr_size=50, **kwargs, ): @@ -22,24 +23,27 @@ def __init__( the training set size is below a number. Parameters: - func : ObjectiveFunction class + func: ObjectiveFunction class A class with the objective function used to optimize the hyperparameters. - optimizer : Optimizer class + optimizer: Optimizer class A class with the used optimization method. - bounds : HPBoundaries class + bounds: HPBoundaries class A class of the boundary conditions of the hyperparameters. Most of the global optimizers are using boundary conditions. The bounds in this class will be used for the optimizer and func. - use_update_pdis : bool + use_update_pdis: bool Whether to update the prior distributions of the hyperparameters with the given boundary conditions. - get_prior_mean : bool + get_prior_mean: bool Whether to get the parameters of the prior mean in the solution. - use_stored_sols : bool + use_stored_sols: bool Whether to store the solutions. + round_hp: int (optional) + The number of decimals to round the hyperparameters to. + If None, the hyperparameters are not rounded. opt_tr_size: int The maximum size of the training set before the hyperparameters are not optimized. @@ -51,6 +55,7 @@ def __init__( use_update_pdis=use_update_pdis, get_prior_mean=get_prior_mean, use_stored_sols=use_stored_sols, + round_hp=round_hp, opt_tr_size=opt_tr_size, **kwargs, ) @@ -64,7 +69,7 @@ def fit(self, X, Y, model, hp=None, pdis=None, **kwargs): hp, theta, parameters = self.get_hyperparams(hp, model) # Do not optimize hyperparameters sol = { - "fun": np.inf, + "fun": inf, "x": theta, "hp": hp, "success": False, @@ -85,6 +90,7 @@ def update_arguments( use_update_pdis=None, get_prior_mean=None, use_stored_sols=None, + round_hp=None, opt_tr_size=None, **kwargs, ): @@ -93,24 +99,27 @@ def update_arguments( The existing arguments are used if they are not given. Parameters: - func : ObjectiveFunction class + func: ObjectiveFunction class A class with the objective function used to optimize the hyperparameters. - optimizer : Optimizer class + optimizer: Optimizer class A class with the used optimization method. - bounds : HPBoundaries class + bounds: HPBoundaries class A class of the boundary conditions of the hyperparameters. Most of the global optimizers are using boundary conditions. The bounds in this class will be used for the optimizer and func. - use_update_pdis : bool + use_update_pdis: bool Whether to update the prior distributions of the hyperparameters with the given boundary conditions. - get_prior_mean : bool + get_prior_mean: bool Whether to get the parameters of the prior mean in the solution. - use_stored_sols : bool + use_stored_sols: bool Whether to store the solutions. + round_hp: int (optional) + The number of decimals to round the hyperparameters to. + If None, the hyperparameters are not rounded. opt_tr_size: int The maximum size of the training set before the hyperparameters are not optimized. @@ -130,6 +139,8 @@ def update_arguments( self.get_prior_mean = get_prior_mean if use_stored_sols is not None: self.use_stored_sols = use_stored_sols + if round_hp is not None or not hasattr(self, "round_hp"): + self.round_hp = round_hp if opt_tr_size is not None: self.opt_tr_size = opt_tr_size # Empty the stored solutions @@ -148,6 +159,7 @@ def get_arguments(self): use_update_pdis=self.use_update_pdis, get_prior_mean=self.get_prior_mean, use_stored_sols=self.use_stored_sols, + round_hp=self.round_hp, opt_tr_size=self.opt_tr_size, ) # Get the constants made within the class From 3bb3ead437c83702954405c740f03954c751cb73 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Wed, 2 Apr 2025 12:44:13 +0200 Subject: [PATCH 091/194] Use data type --- catlearn/regression/gp/calculator/hiermodel.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/catlearn/regression/gp/calculator/hiermodel.py b/catlearn/regression/gp/calculator/hiermodel.py index 80bee35d..b3c2d525 100644 --- a/catlearn/regression/gp/calculator/hiermodel.py +++ b/catlearn/regression/gp/calculator/hiermodel.py @@ -1,4 +1,4 @@ -import numpy as np +from numpy import ndarray from .mlmodel import MLModel from .mlcalc import MLCalculator @@ -16,6 +16,7 @@ def __init__( verbose=False, npoints=25, initial_indicies=[0], + dtype=None, **kwargs, ): """ @@ -51,6 +52,8 @@ def __init__( initial_indicies: list The indicies of the data points that must be included in the used data base for every model. + dtype: type + The data type of the arrays. """ super().__init__( model=model, @@ -63,6 +66,7 @@ def __init__( verbose=verbose, npoints=npoints, initial_indicies=initial_indicies, + dtype=dtype, **kwargs, ) @@ -79,7 +83,7 @@ def add_training(self, atoms_list, **kwargs): self: The updated object itself. """ data_len = self.get_training_set_size() - if not isinstance(atoms_list, (list, np.ndarray)): + if not isinstance(atoms_list, (list, ndarray)): atoms_list = [atoms_list] # Store the data if data_len + len(atoms_list) <= self.npoints: @@ -117,6 +121,7 @@ def update_arguments( verbose=None, npoints=None, initial_indicies=None, + dtype=None, **kwargs, ): """ @@ -149,6 +154,8 @@ def update_arguments( initial_indicies: list The indicies of the data points that must be included in the used data base for every model. + dtype: type + The data type of the arrays. Returns: self: The updated object itself. @@ -175,6 +182,8 @@ def update_arguments( self.include_noise = include_noise if verbose is not None: self.verbose = verbose + if dtype is not None or not hasattr(self, "dtype"): + self.dtype = dtype if npoints is not None: self.npoints = int(npoints) if initial_indicies is not None: @@ -205,6 +214,7 @@ def get_arguments(self): verbose=self.verbose, npoints=self.npoints, initial_indicies=self.initial_indicies, + dtype=self.dtype, ) # Get the constants made within the class constant_kwargs = dict() From ee5cc1bbab0b07c2d8394b0a909dabca039fef0c Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Wed, 2 Apr 2025 13:06:29 +0200 Subject: [PATCH 092/194] Informative ml model --- catlearn/regression/gp/calculator/mlmodel.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/catlearn/regression/gp/calculator/mlmodel.py b/catlearn/regression/gp/calculator/mlmodel.py index 5ba658dd..4d1abbd6 100644 --- a/catlearn/regression/gp/calculator/mlmodel.py +++ b/catlearn/regression/gp/calculator/mlmodel.py @@ -731,8 +731,8 @@ def get_default_model( prior=prior, kernel=kernel, use_derivatives=use_derivatives, - a=1e-3, - b=1e-4, + a=1e-4, + b=2.0, ) # Set objective function if global_optimization: @@ -975,7 +975,7 @@ def get_default_mlmodel( pdis = dict( length=Normal_prior(mu=[-0.8], std=[0.2]), - noise=Normal_prior(mu=[-9.0], std=[1.0]), + noise=Normal_prior(mu=[-6.0], std=[0.2]), ) else: pdis = None From 8f1c1c764d854a5d0cb747e147a40e6651e10bac Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Wed, 2 Apr 2025 14:20:59 +0200 Subject: [PATCH 093/194] A given result can be given to a copy of atoms --- .../regression/gp/calculator/copy_atoms.py | 24 +++++++++++-------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/catlearn/regression/gp/calculator/copy_atoms.py b/catlearn/regression/gp/calculator/copy_atoms.py index 8cbc9d73..2b62e6dc 100644 --- a/catlearn/regression/gp/calculator/copy_atoms.py +++ b/catlearn/regression/gp/calculator/copy_atoms.py @@ -1,24 +1,27 @@ -import numpy as np +from numpy import array, isscalar, ndarray from ase.calculators.calculator import Calculator, PropertyNotImplementedError -def copy_atoms(atoms, **kwargs): +def copy_atoms(atoms, results={}, **kwargs): """ Copy the atoms object together with the calculated properties. Parameters: atoms : ASE Atoms The ASE Atoms object with a calculator that is copied. + results : dict (optional) + The properties to be saved in the calculator. + If not given, the properties are taken from the calculator. Returns: atoms0 : ASE Atoms The copy of the Atoms object with saved data in the calculator. """ - # Save the properties calculated - if atoms.calc is not None: - results = atoms.calc.results.copy() - else: - results = {} + # Check if results are given + if not isinstance(results, dict) or len(results) == 0: + # Save the properties calculated + if atoms.calc is not None: + results = atoms.calc.results.copy() # Copy the ASE Atoms object atoms0 = atoms.copy() # Store the properties in a calculator @@ -38,6 +41,7 @@ class StoredDataCalculator(Calculator): def __init__( self, atoms, + dtype=float, **results, ): """Save the properties for the given configuration.""" @@ -50,14 +54,14 @@ def __init__( elif isinstance(value, (float, int)): self.results[property] = value else: - self.results[property] = np.array(value, dtype=float) + self.results[property] = array(value, dtype=dtype) # Save the configuration self.atoms = atoms.copy() def __str__(self): tokens = [] for key, val in sorted(self.results.items()): - if np.isscalar(val): + if isscalar(val): txt = "{}={}".format(key, val) else: txt = "{}=...".format(key) @@ -76,7 +80,7 @@ def get_property(self, name, atoms=None, allow_calculation=True): return None # Return the property result = self.results[name] - if isinstance(result, (np.ndarray, list)): + if isinstance(result, (ndarray, list)): result = result.copy() return result From 8ede4b3579ee37ef1cf8b1343e9c2fecb75e72de Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Fri, 4 Apr 2025 15:02:42 +0200 Subject: [PATCH 094/194] Use seed for optimizers --- catlearn/optimizer/local.py | 23 ++++++++++++++++++++-- catlearn/optimizer/localcineb.py | 7 +++++++ catlearn/optimizer/localneb.py | 33 +++++++++++++++++++++++--------- catlearn/optimizer/sequential.py | 18 +++++++++++++++++ 4 files changed, 70 insertions(+), 11 deletions(-) diff --git a/catlearn/optimizer/local.py b/catlearn/optimizer/local.py index cc6f59aa..4d814142 100644 --- a/catlearn/optimizer/local.py +++ b/catlearn/optimizer/local.py @@ -2,7 +2,7 @@ import ase from ase.parallel import world from ase.optimize import FIRE -import numpy as np +from numpy import isnan class LocalOptimizer(OptimizerMethod): @@ -14,6 +14,7 @@ def __init__( parallel_run=False, comm=world, verbose=False, + seed=None, **kwargs, ): """ @@ -35,6 +36,10 @@ def __init__( verbose: bool Whether to print the full output (True) or not (False). + seed: int (optional) + The random seed for the optimization. + The seed an also be a RandomState or Generator instance. + If not given, the default random number generator is used. """ # Set the parameters self.update_arguments( @@ -44,6 +49,7 @@ def __init__( parallel_run=parallel_run, comm=comm, verbose=verbose, + seed=seed, **kwargs, ) @@ -138,7 +144,7 @@ def run_max_unc( break # Check if there is a problem with the calculation energy = self.get_potential_energy() - if np.isnan(energy): + if isnan(energy): self.message("The energy is NaN.") break # Check if the optimization is converged @@ -183,6 +189,7 @@ def update_arguments( parallel_run=None, comm=None, verbose=None, + seed=None, **kwargs, ): """ @@ -203,12 +210,23 @@ def update_arguments( verbose: bool Whether to print the full output (True) or not (False). + seed: int (optional) + The random seed for the optimization. + The seed an also be a RandomState or Generator instance. + If not given, the default random number generator is used. """ # Set the communicator if comm is not None: self.comm = comm self.rank = comm.rank self.size = comm.size + elif not hasattr(self, "comm"): + self.comm = None + self.rank = 0 + self.size = 1 + # Set the seed + if seed is not None or not hasattr(self, "seed"): + self.set_seed(seed) # Set the verbose if verbose is not None: self.verbose = verbose @@ -247,6 +265,7 @@ def get_arguments(self): parallel_run=self.parallel_run, comm=self.comm, verbose=self.verbose, + seed=self.seed, ) # Get the constants made within the class constant_kwargs = dict(steps=self.steps, _converged=self._converged) diff --git a/catlearn/optimizer/localcineb.py b/catlearn/optimizer/localcineb.py index b0d964f7..a4cf16e6 100644 --- a/catlearn/optimizer/localcineb.py +++ b/catlearn/optimizer/localcineb.py @@ -23,6 +23,7 @@ def __init__( parallel_run=False, comm=world, verbose=False, + seed=None, **kwargs, ): """ @@ -79,6 +80,10 @@ def __init__( verbose: bool Whether to print the full output (True) or not (False). + seed: int (optional) + The random seed for the optimization. + The seed an also be a RandomState or Generator instance. + If not given, the default random number generator is used. """ # Save the end points for creating the NEB self.setup_endpoints(start, end) @@ -105,6 +110,7 @@ def __init__( parallel_run=parallel_run, comm=comm, verbose=verbose, + seed=seed, **kwargs, ) @@ -296,6 +302,7 @@ def get_arguments(self): parallel_run=self.parallel_run, comm=self.comm, verbose=self.verbose, + seed=self.seed, ) # Get the constants made within the class constant_kwargs = dict(steps=self.steps, _converged=self._converged) diff --git a/catlearn/optimizer/localneb.py b/catlearn/optimizer/localneb.py index 9ee59c8a..c9edd1e4 100644 --- a/catlearn/optimizer/localneb.py +++ b/catlearn/optimizer/localneb.py @@ -1,7 +1,7 @@ from .local import LocalOptimizer from ase.parallel import world, broadcast from ase.optimize import FIRE -import numpy as np +from numpy import asarray class LocalNEB(LocalOptimizer): @@ -13,6 +13,7 @@ def __init__( parallel_run=False, comm=world, verbose=False, + seed=None, **kwargs, ): """ @@ -33,6 +34,10 @@ def __init__( verbose: bool Whether to print the full output (True) or not (False). + seed: int (optional) + The random seed for the optimization. + The seed an also be a RandomState or Generator instance. + If not given, the default random number generator is used. """ # Set the parameters self.update_arguments( @@ -42,13 +47,14 @@ def __init__( parallel_run=parallel_run, comm=comm, verbose=verbose, + seed=seed, **kwargs, ) def update_optimizable(self, structures, **kwargs): # Get the positions of the NEB images positions = [image.get_positions() for image in structures[1:-1]] - positions = np.asarray(positions).reshape(-1, 3) + positions = asarray(positions).reshape(-1, 3) # Set the positions of the NEB images self.optimizable.set_positions(positions) # Reset the optimization @@ -72,13 +78,9 @@ def get_structures_parallel(self, **kwargs): structures = [self.copy_atoms(self.optimizable.images[0])] for i, image in enumerate(self.optimizable.images[1:-1]): root = i % self.size - structures.append( - broadcast( - self.copy_atoms(image), - root=root, - comm=self.comm, - ) - ) + if self.rank == root: + image = self.copy_atoms(image) + structures.append(broadcast(image, root=root, comm=self.comm)) structures.append(self.copy_atoms(self.optimizable.images[-1])) return structures @@ -122,6 +124,7 @@ def update_arguments( parallel_run=None, comm=None, verbose=None, + seed=None, **kwargs, ): """ @@ -142,12 +145,23 @@ def update_arguments( verbose: bool Whether to print the full output (True) or not (False). + seed: int (optional) + The random seed for the optimization. + The seed an also be a RandomState or Generator instance. + If not given, the default random number generator is used. """ # Set the communicator if comm is not None: self.comm = comm self.rank = comm.rank self.size = comm.size + elif not hasattr(self, "comm"): + self.comm = None + self.rank = 0 + self.size = 1 + # Set the seed + if seed is not None or not hasattr(self, "seed"): + self.set_seed(seed) # Set the verbose if verbose is not None: self.verbose = verbose @@ -174,6 +188,7 @@ def get_arguments(self): parallel_run=self.parallel_run, comm=self.comm, verbose=self.verbose, + seed=self.seed, ) # Get the constants made within the class constant_kwargs = dict(steps=self.steps, _converged=self._converged) diff --git a/catlearn/optimizer/sequential.py b/catlearn/optimizer/sequential.py index 06e778f5..0f4030f2 100644 --- a/catlearn/optimizer/sequential.py +++ b/catlearn/optimizer/sequential.py @@ -10,6 +10,7 @@ def __init__( parallel_run=False, comm=world, verbose=False, + seed=None, **kwargs, ): """ @@ -30,6 +31,10 @@ def __init__( verbose: bool Whether to print the full output (True) or not (False). + seed: int (optional) + The random seed for the optimization. + The seed an also be a RandomState or Generator instance. + If not given, the default random number generator is used. """ # Set the parameters self.update_arguments( @@ -38,6 +43,7 @@ def __init__( parallel_run=parallel_run, comm=comm, verbose=verbose, + seed=seed, **kwargs, ) @@ -142,6 +148,7 @@ def update_arguments( parallel_run=None, comm=None, verbose=None, + seed=None, **kwargs, ): """ @@ -160,12 +167,23 @@ def update_arguments( verbose: bool Whether to print the full output (True) or not (False). + seed: int (optional) + The random seed for the optimization. + The seed an also be a RandomState or Generator instance. + If not given, the default random number generator is used. """ # Set the communicator if comm is not None: self.comm = comm self.rank = comm.rank self.size = comm.size + elif not hasattr(self, "comm"): + self.comm = None + self.rank = 0 + self.size = 1 + # Set the seed + if seed is not None or not hasattr(self, "seed"): + self.set_seed(seed) # Set the verbose if verbose is not None: self.verbose = verbose From 496128dcf0c4b4228c5008759d2f494e3506dc70 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Fri, 4 Apr 2025 15:03:41 +0200 Subject: [PATCH 095/194] Use seed and copy_candidates --- catlearn/optimizer/method.py | 269 +++++++++++++++++++---------------- 1 file changed, 146 insertions(+), 123 deletions(-) diff --git a/catlearn/optimizer/method.py b/catlearn/optimizer/method.py index b974bf8e..32aca313 100644 --- a/catlearn/optimizer/method.py +++ b/catlearn/optimizer/method.py @@ -1,4 +1,6 @@ -import numpy as np +from numpy import max as max_ +from numpy.linalg import norm +from numpy.random import default_rng, Generator, RandomState from ase.parallel import world, broadcast from ..regression.gp.calculator.copy_atoms import copy_atoms from ..structures.structure import Structure @@ -11,6 +13,7 @@ def __init__( parallel_run=False, comm=world, verbose=False, + seed=None, **kwargs, ): """ @@ -31,6 +34,10 @@ def __init__( verbose: bool Whether to print the full output (True) or not (False). + seed: int (optional) + The random seed for the optimization. + The seed an also be a RandomState or Generator instance. + If not given, the default random number generator is used. """ # Set the parameters self.update_arguments( @@ -38,6 +45,7 @@ def __init__( parallel_run=parallel_run, comm=comm, verbose=verbose, + seed=seed, **kwargs, ) @@ -93,6 +101,52 @@ def get_candidates(self, **kwargs): """ return [self.optimizable] + def copy_candidates( + self, + properties=["energy", "forces"], + allow_calculation=True, + **kwargs, + ): + """ + Get the candidate structure instances with copied properties. + It is used for active learning. + + Parameters: + properties: list of str + The names of the requested properties. + allow_calculation: bool + Whether the properties are allowed to be calculated. + + Returns: + candidates_copy: list of Atoms instances + The candidates with copied properties. + """ + # Check if the parallelization is used + is_parallel = self.is_parallel_used() + candidates_copy = [] + for i, atoms in enumerate(self.get_candidates()): + # Check the rank of the process + atoms_new = None + root = i % self.size + if self.rank == root: + # Get the properties of the atoms instance + results = {} + for name in properties: + self.get_atoms_property( + atoms=atoms, + name=name, + allow_calculation=allow_calculation, + **kwargs, + ) + results.update(atoms.calc.results) + # Copy the atoms instance with all the properties + atoms_new = copy_atoms(atoms, results=results) + # Broadcast the atoms instance to all processes + if is_parallel: + atoms_new = broadcast(atoms_new, root=root, comm=self.comm) + candidates_copy.append(atoms_new) + return candidates_copy + def reset_optimization(self): """ Reset the optimization. @@ -244,7 +298,7 @@ def get_fmax(self, per_candidate=False, **kwargs): The maximum force of the optimizable. """ force = self.get_forces(per_candidate=per_candidate, **kwargs) - fmax = np.linalg.norm(force, axis=-1).max(axis=-1) + fmax = norm(force, axis=-1).max(axis=-1) return fmax def get_uncertainty(self, per_candidate=False, **kwargs): @@ -277,7 +331,7 @@ def get_uncertainty(self, per_candidate=False, **kwargs): for atoms in self.get_candidates() ] if not per_candidate: - uncertainty = np.max(uncertainty) + uncertainty = max_(uncertainty) return uncertainty def get_uncertainty_parallel(self, **kwargs): @@ -328,33 +382,29 @@ def get_property( Returns: float or list: The requested property. """ + # Check if the parallelization is used + is_parallel = self.is_parallel_used() + # Check if the property is extracted for each candidate if per_candidate: - if self.is_parallel_used(): - return self.get_property_parallel( - name, - allow_calculation, - **kwargs, - ) output = [] - for atoms in self.get_candidates(): - if name == "energy": - result = atoms.get_potential_energy(**kwargs) - elif name == "forces": - result = atoms.get_forces(**kwargs) - elif name == "fmax": - force = atoms.get_forces(**kwargs) - result = np.linalg.norm(force, axis=-1).max() - elif name == "uncertainty" and isinstance(atoms, Structure): - result = atoms.get_uncertainty(**kwargs) - else: - result = atoms.calc.get_property( - name, + for i, atoms in enumerate(self.get_candidates()): + # Check the rank of the process + result = None + root = i % self.size + if self.rank == root: + # Get the properties of the atoms instance + result = self.get_atoms_property( atoms=atoms, + name=name, allow_calculation=allow_calculation, **kwargs, ) + # Broadcast the property to all processes + if is_parallel: + result = broadcast(result, root=root, comm=self.comm) output.append(result) else: + # Get the property of the optimizable instance if name == "energy": output = self.get_potential_energy( per_candidate=per_candidate, @@ -378,49 +428,6 @@ def get_property( ) return output - def get_property_parallel( - self, - name, - allow_calculation=True, - **kwargs, - ): - """ - Get the requested property of the candidates in parallel. - - Parameters: - name: str - The name of the requested property. - allow_calculation: bool - Whether the property is allowed to be calculated. - - Returns: - list: The list of requested property. - """ - output = [] - for i, atoms in enumerate(self.get_candidates()): - root = i % self.size - result = None - if self.rank == root: - if name == "energy": - result = atoms.get_potential_energy(**kwargs) - elif name == "forces": - result = atoms.get_forces(**kwargs) - elif name == "fmax": - force = atoms.get_forces(**kwargs) - result = np.linalg.norm(force, axis=-1).max() - elif name == "uncertainty" and isinstance(atoms, Structure): - result = atoms.get_uncertainty(**kwargs) - else: - result = atoms.calc.get_property( - name, - atoms=atoms, - allow_calculation=allow_calculation, - **kwargs, - ) - result = broadcast(result, root=root, comm=self.comm) - output.append(result) - return output - def get_properties( self, properties, @@ -443,36 +450,29 @@ def get_properties( Returns: dict: The requested properties. """ + # Check if the parallelization is used + is_parallel = self.is_parallel_used() if per_candidate: - if self.is_parallel_used(): - return self.get_properties_parallel( - properties, - allow_calculation, - **kwargs, - ) results = {name: [] for name in properties} - for atoms in self.get_candidates(): + for i, atoms in enumerate(self.get_candidates()): + # Check the rank of the process + root = i % self.size for name in properties: - if name == "energy": - output = atoms.get_potential_energy(**kwargs) - elif name == "forces": - output = atoms.get_forces(**kwargs) - elif name == "fmax": - force = atoms.get_forces(**kwargs) - output = np.linalg.norm(force, axis=-1).max() - elif name == "uncertainty" and isinstance( - atoms, Structure - ): - output = atoms.get_uncertainty(**kwargs) - else: - output = atoms.calc.get_property( - name, + result = None + if self.rank == root: + # Get the properties of the atoms instance + result = self.get_atoms_property( atoms=atoms, + name=name, allow_calculation=allow_calculation, **kwargs, ) - results[name].append(output) + # Broadcast the property to all processes + if is_parallel: + result = broadcast(result, root=root, comm=self.comm) + results[name].append(result) else: + # Get the properties of the optimizable instance results = {} for name in properties: results[name] = self.get_property( @@ -483,51 +483,45 @@ def get_properties( ) return results - def get_properties_parallel( + def get_atoms_property( self, - properties, + atoms, + name, allow_calculation=True, **kwargs, ): """ - Get the requested properties of the candidates in parallel. + Get the property of the given atoms instance. Parameters: - properties: list of str - The names of the requested properties. + name: str + The name of the requested property. allow_calculation: bool - Whether the properties are allowed to be calculated. + Whether the property is allowed to be calculated. Returns: - dict: The requested properties. - """ - results = {name: [] for name in properties} - for i, atoms in enumerate(self.get_candidates()): - root = i % self.size - for name in properties: - output = None - if self.rank == root: - if name == "energy": - output = atoms.get_potential_energy(**kwargs) - elif name == "forces": - output = atoms.get_forces(**kwargs) - elif name == "fmax": - force = atoms.get_forces(**kwargs) - output = np.linalg.norm(force, axis=-1).max() - elif name == "uncertainty" and isinstance( - atoms, Structure - ): - output = atoms.get_uncertainty(**kwargs) - else: - output = atoms.calc.get_property( - name, - atoms=atoms, - allow_calculation=allow_calculation, - **kwargs, - ) - output = broadcast(output, root=root, comm=self.comm) - results[name].append(output) - return results + float: The requested property. + """ + if name == "energy": + result = atoms.get_potential_energy(**kwargs) + elif name == "forces": + result = atoms.get_forces(**kwargs) + elif name == "fmax": + force = atoms.get_forces(**kwargs) + result = norm(force, axis=-1).max() + elif name == "uncertainty" and isinstance( + atoms, + Structure, + ): + result = atoms.get_uncertainty(**kwargs) + else: + result = atoms.calc.get_property( + name, + atoms=atoms, + allow_calculation=allow_calculation, + **kwargs, + ) + return result def is_within_dtrust(self, per_candidate=False, dtrust=2.0, **kwargs): """ @@ -687,6 +681,7 @@ def update_arguments( parallel_run=None, comm=None, verbose=None, + seed=None, **kwargs, ): """ @@ -705,12 +700,23 @@ def update_arguments( verbose: bool Whether to print the full output (True) or not (False). + seed: int (optional) + The random seed for the optimization. + The seed an also be a RandomState or Generator instance. + If not given, the default random number generator is used. """ # Set the communicator if comm is not None: self.comm = comm self.rank = comm.rank self.size = comm.size + elif not hasattr(self, "comm"): + self.comm = None + self.rank = 0 + self.size = 1 + # Set the seed + if seed is not None or not hasattr(self, "seed"): + self.set_seed(seed) # Set the verbose if verbose is not None: self.verbose = verbose @@ -721,13 +727,29 @@ def update_arguments( self.check_parallel() return self + def set_seed(self, seed=None): + "Set the random seed for the optimization." + if seed is not None: + self.seed = seed + if isinstance(seed, int): + self.rng = default_rng(self.seed) + elif isinstance(seed, Generator) or isinstance(seed, RandomState): + self.rng = seed + else: + self.seed = None + self.rng = default_rng() + return self + def copy_atoms(self, atoms): "Copy an atoms instance." # Enforce the correct results in the calculator if atoms.calc is not None: if hasattr(atoms.calc, "results"): if len(atoms.calc.results): - atoms.get_forces() + if "forces" in atoms.calc.results: + atoms.get_forces() + else: + atoms.get_potential_energy() # Save the structure with saved properties return copy_atoms(atoms) @@ -751,6 +773,7 @@ def get_arguments(self): parallel_run=self.parallel_run, comm=self.comm, verbose=self.verbose, + seed=self.seed, ) # Get the constants made within the class constant_kwargs = dict(steps=self.steps, _converged=self._converged) From 73a9890fd5570e04e2df5b628bb59d8064d2fd9e Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Fri, 4 Apr 2025 15:04:14 +0200 Subject: [PATCH 096/194] Use seed and give all structures --- catlearn/optimizer/parallelopt.py | 69 ++++++++++++++++++++++++------- 1 file changed, 53 insertions(+), 16 deletions(-) diff --git a/catlearn/optimizer/parallelopt.py b/catlearn/optimizer/parallelopt.py index 39b429cb..68371ce6 100644 --- a/catlearn/optimizer/parallelopt.py +++ b/catlearn/optimizer/parallelopt.py @@ -1,6 +1,6 @@ from .method import OptimizerMethod from ase.parallel import world, broadcast -import numpy as np +from numpy import argmin, inf, max as max_ class ParallelOptimizer(OptimizerMethod): @@ -11,6 +11,7 @@ def __init__( parallel_run=True, comm=world, verbose=False, + seed=None, **kwargs, ): """ @@ -30,13 +31,11 @@ def __init__( verbose: bool Whether to print the full output (True) or not (False). + seed: int (optional) + The random seed for the optimization. + The seed an also be a RandomState or Generator instance. + If not given, the default random number generator is used. """ - # Set the number of chains - if chains is None: - if parallel_run: - chains = comm.size - else: - chains = 1 # Set the parameters self.update_arguments( method=method, @@ -44,12 +43,17 @@ def __init__( parallel_run=parallel_run, comm=comm, verbose=verbose, + seed=seed, **kwargs, ) def update_optimizable(self, structures, **kwargs): - self.method.update_optimizable(structures, **kwargs) - self.methods = [self.method.copy() for _ in range(self.chains)] + if isinstance(structures, list) and len(structures) == self.chains: + for method, structure in zip(self.methods, structures): + method.update_optimizable(structure, **kwargs) + else: + self.method.update_optimizable(structures, **kwargs) + self.methods = [self.method.copy() for _ in range(self.chains)] self.reset_optimization() return self @@ -57,6 +61,11 @@ def get_optimizable(self, **kwargs): return self.method.get_optimizable(**kwargs) def get_structures(self, get_all=True, **kwargs): + if get_all: + return [ + method.get_structures(get_all=get_all) + for method in self.methods + ] return self.method.get_structures(get_all=get_all, **kwargs) def get_candidates(self, **kwargs): @@ -79,13 +88,14 @@ def run( candidates = [[]] * self.chains converged = [False] * self.chains used_steps = [self.steps] * self.chains - values = [np.inf] * self.chains + values = [inf] * self.chains # Run the optimizations for chain, method in enumerate(self.methods): root = chain % self.size if self.rank == root: # Set the random seed - np.random.RandomState(chain + 1) + self.change_seed(chain) + method.set_seed(self.seed) # Run the optimization converged[chain] = method.run( fmax=fmax, @@ -144,9 +154,9 @@ def run( for candidate in candidate_inner: self.candidates.append(candidate) # Check the minimum value - i_min = np.argmin(values) + i_min = argmin(values) self.method = self.method.update_optimizable(structures[i_min]) - self.steps = np.max(used_steps) + self.steps = max_(used_steps) # Check if the optimization is converged self._converged = self.check_convergence( converged=converged[i_min], @@ -175,6 +185,7 @@ def update_arguments( parallel_run=None, comm=None, verbose=None, + seed=None, **kwargs, ): """ @@ -193,24 +204,40 @@ def update_arguments( verbose: bool Whether to print the full output (True) or not (False). + seed: int (optional) + The random seed for the optimization. + The seed an also be a RandomState or Generator instance. + If not given, the default random number generator is used. """ # Set the communicator if comm is not None: self.comm = comm self.rank = comm.rank self.size = comm.size + elif not hasattr(self, "comm"): + self.comm = None + self.rank = 0 + self.size = 1 + # Set the seed + if seed is not None or not hasattr(self, "seed"): + self.set_seed(seed) # Set the verbose if verbose is not None: self.verbose = verbose + if parallel_run is not None: + self.parallel_run = parallel_run + self.check_parallel() if chains is not None: self.chains = chains + elif not hasattr(self, "chains"): + if self.parallel_run: + chains = comm.size + else: + chains = 1 if method is not None: self.method = method.copy() self.methods = [method.copy() for _ in range(self.chains)] self.setup_optimizable() - if parallel_run is not None: - self.parallel_run = parallel_run - self.check_parallel() if verbose is not None: self.verbose = verbose if self.chains % self.size != 0: @@ -227,6 +254,15 @@ def setup_optimizable(self, **kwargs): self.reset_optimization() return self + def change_seed(self, chain, **kwargs): + "Change the random seed for the given chain." + if isinstance(self.seed, int): + seed = self.seed + chain + self.set_seed(seed=seed) + else: + [self.rng.random() for _ in range(chain)] + return self + def get_arguments(self): "Get the arguments of the class itself." # Get the arguments given to the class in the initialization @@ -236,6 +272,7 @@ def get_arguments(self): parallel_run=self.parallel_run, comm=self.comm, verbose=self.verbose, + seed=self.seed, ) # Get the constants made within the class constant_kwargs = dict(steps=self.steps, _converged=self._converged) From cc2c175dca16dca6efb98d849848a71f6cd6dca4 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Fri, 4 Apr 2025 15:04:55 +0200 Subject: [PATCH 097/194] Use seed and write the full rotation matrix --- catlearn/optimizer/adsorption.py | 105 ++++++++++++++++++++----------- 1 file changed, 69 insertions(+), 36 deletions(-) diff --git a/catlearn/optimizer/adsorption.py b/catlearn/optimizer/adsorption.py index 02be63db..3633d79b 100644 --- a/catlearn/optimizer/adsorption.py +++ b/catlearn/optimizer/adsorption.py @@ -2,7 +2,9 @@ from ase.parallel import world from ase.constraints import FixAtoms, FixBondLengths import itertools -import numpy as np +from numpy import array, asarray, concatenate, cos, matmul, pi, sin +from numpy.linalg import norm +import scipy from scipy.optimize import dual_annealing @@ -18,6 +20,7 @@ def __init__( parallel_run=False, comm=world, verbose=False, + seed=None, **kwargs, ): """ @@ -53,6 +56,10 @@ def __init__( verbose: bool Whether to print the full output (True) or not (False). + seed: int (optional) + The random seed for the optimization. + The seed an also be a RandomState or Generator instance. + If not given, the default random number generator is used. """ # Create the atoms object from the slab and adsorbate self.create_slab_ads(slab, adsorbate, adsorbate2, bond_tol=bond_tol) @@ -64,6 +71,7 @@ def __init__( parallel_run=parallel_run, comm=comm, verbose=verbose, + seed=seed, **kwargs, ) @@ -135,7 +143,7 @@ def create_slab_ads( self.natoms = len(optimizable) # Store the positions and cell self.positions0 = optimizable.get_positions().copy() - self.cell = np.array(optimizable.get_cell()) + self.cell = array(optimizable.get_cell()) # Store the original constraints self.constraints_org = [c.copy() for c in optimizable.constraints] # Make constraints for optimization @@ -147,9 +155,9 @@ def create_slab_ads( range(self.n_slab, self.n_slab + self.n_ads), 2, ) - pairs = np.array(list(pairs)) + pairs = asarray(list(pairs)) # Get the bond lengths - bondlengths = np.linalg.norm( + bondlengths = norm( self.positions0[pairs[:, 0]] - self.positions0[pairs[:, 1]], axis=1, ) @@ -167,9 +175,9 @@ def create_slab_ads( range(self.n_slab + self.n_ads, self.natoms), 2, ) - pairs = np.array(list(pairs)) + pairs = asarray(list(pairs)) # Get the bond lengths - bondlengths = np.linalg.norm( + bondlengths = norm( self.positions0[pairs[:, 0]] - self.positions0[pairs[:, 1]], axis=1, ) @@ -206,14 +214,14 @@ def setup_bounds(self, bounds=None): # Check the bounds are given if bounds is None: # Make default bounds - self.bounds = np.array( + self.bounds = asarray( [ [0.0, 1.0], [0.0, 1.0], [0.0, 1.0], - [0.0, 2 * np.pi], - [0.0, 2 * np.pi], - [0.0, 2 * np.pi], + [0.0, 2.0 * pi], + [0.0, 2.0 * pi], + [0.0, 2.0 * pi], ] ) else: @@ -223,7 +231,7 @@ def setup_bounds(self, bounds=None): raise Exception("The bounds must have shape (6,2) or (12,2)!") # Check if the bounds are for two adsorbates if self.n_ads2 > 0 and self.bounds.shape[0] == 6: - self.bounds = np.concatenate([self.bounds, self.bounds], axis=0) + self.bounds = concatenate([self.bounds, self.bounds], axis=0) return self def run( @@ -277,6 +285,7 @@ def update_arguments( parallel_run=None, comm=None, verbose=None, + seed=None, **kwargs, ): """ @@ -308,12 +317,26 @@ def update_arguments( verbose: bool Whether to print the full output (True) or not (False). + seed: int (optional) + The random seed for the optimization. + The seed an also be a RandomState or Generator instance. + If not given, the default random number generator is used. """ # Set the communicator if comm is not None: self.comm = comm self.rank = comm.rank self.size = comm.size + elif not hasattr(self, "comm"): + self.comm = None + self.rank = 0 + self.size = 1 + # Set the optimizer kwargs + if opt_kwargs is not None: + self.opt_kwargs = opt_kwargs.copy() + # Set the seed + if seed is not None or not hasattr(self, "seed"): + self.set_seed(seed) # Set the verbose if verbose is not None: self.verbose = verbose @@ -330,40 +353,49 @@ def update_arguments( # Create the boundary conditions if bounds is not None: self.setup_bounds(bounds) - if opt_kwargs is not None: - self.opt_kwargs = opt_kwargs.copy() if parallel_run is not None: self.parallel_run = parallel_run self.check_parallel() return self + def set_seed(self, seed=None): + super().set_seed(seed) + # Set the seed for the random number generator + if scipy.__version__ < "1.15": + self.opt_kwargs["seed"] = self.seed + else: + self.opt_kwargs["rng"] = self.rng + return self + def rotation_matrix(self, angles, positions): "Rotate the adsorbate" + # Get the angles theta1, theta2, theta3 = angles - Rz = np.array( + # Calculate the trigonometric functions + cos1 = cos(theta1) + sin1 = sin(theta1) + cos2 = cos(theta2) + sin2 = sin(theta2) + cos3 = cos(theta3) + sin3 = sin(theta3) + # Calculate the full rotation matrix + R = asarray( [ - [np.cos(theta1), -np.sin(theta1), 0.0], - [np.sin(theta1), np.cos(theta1), 0.0], - [0.0, 0.0, 1.0], - ] - ) - Ry = np.array( - [ - [np.cos(theta2), 0.0, np.sin(theta2)], - [0.0, 1.0, 0.0], - [-np.sin(theta2), 0.0, np.cos(theta2)], - ] - ) - R = np.matmul(Ry, Rz) - Rz = np.array( - [ - [np.cos(theta3), -np.sin(theta3), 0.0], - [np.sin(theta3), np.cos(theta3), 0.0], - [0.0, 0.0, 1.0], + [cos2 * cos3, cos2 * sin3, -sin2], + [ + sin1 * sin2 * cos3 - cos1 * sin3, + sin1 * sin2 * sin3 + cos1 * cos3, + sin1 * cos2, + ], + [ + cos1 * sin2 * cos3 + sin1 * sin3, + cos1 * sin2 * sin3 - sin1 * cos3, + cos1 * cos2, + ], ] ) - R = np.matmul(Rz, R).T - positions = np.matmul(positions, R) + # Calculate the rotation of the positions + positions = matmul(positions, R) return positions def evaluate_value(self, x, **kwargs): @@ -373,13 +405,13 @@ def evaluate_value(self, x, **kwargs): # Calculate the positions of the adsorbate pos_ads = pos[self.n_slab : self.n_slab + self.n_ads] pos_ads = self.rotation_matrix(x[3:6], pos_ads) - pos_ads += np.sum(self.cell * x[:3].reshape(-1, 1), axis=0) + pos_ads += (self.cell * x[:3].reshape(-1, 1)).sum(axis=0) pos[self.n_slab : self.n_slab + self.n_ads] = pos_ads # Calculate the positions of the second adsorbate if self.n_ads2 > 0: pos_ads2 = pos[self.n_slab + self.n_ads :] pos_ads2 = self.rotation_matrix(x[9:12], pos_ads2) - pos_ads2 += np.sum(self.cell * x[6:9].reshape(-1, 1), axis=0) + pos_ads2 += (self.cell * x[6:9].reshape(-1, 1)).sum(axis=0) pos[self.n_slab + self.n_ads :] = pos_ads2 # Set the positions self.optimizable.set_positions(pos) @@ -398,6 +430,7 @@ def get_arguments(self): parallel_run=self.parallel_run, comm=self.comm, verbose=self.verbose, + seed=self.seed, ) # Get the constants made within the class constant_kwargs = dict(steps=self.steps, _converged=self._converged) From 30ebc0d614829f787eeab0ccfac205211a076987 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Mon, 7 Apr 2025 09:31:56 +0200 Subject: [PATCH 098/194] Inherit update arguments --- catlearn/optimizer/adsorption.py | 27 +++++++++---------------- catlearn/optimizer/local.py | 30 ++++++++++------------------ catlearn/optimizer/localneb.py | 30 ++++++++++------------------ catlearn/optimizer/method.py | 2 ++ catlearn/optimizer/parallelopt.py | 33 ++++++++++++------------------- catlearn/optimizer/sequential.py | 33 ++++++++++++------------------- 6 files changed, 57 insertions(+), 98 deletions(-) diff --git a/catlearn/optimizer/adsorption.py b/catlearn/optimizer/adsorption.py index 3633d79b..025b784d 100644 --- a/catlearn/optimizer/adsorption.py +++ b/catlearn/optimizer/adsorption.py @@ -322,24 +322,18 @@ def update_arguments( The seed an also be a RandomState or Generator instance. If not given, the default random number generator is used. """ - # Set the communicator - if comm is not None: - self.comm = comm - self.rank = comm.rank - self.size = comm.size - elif not hasattr(self, "comm"): - self.comm = None - self.rank = 0 - self.size = 1 + # Set the parameters in the parent class + super().update_arguments( + optimizable=None, + parallel_run=parallel_run, + comm=comm, + verbose=verbose, + seed=seed, + **kwargs, + ) # Set the optimizer kwargs if opt_kwargs is not None: self.opt_kwargs = opt_kwargs.copy() - # Set the seed - if seed is not None or not hasattr(self, "seed"): - self.set_seed(seed) - # Set the verbose - if verbose is not None: - self.verbose = verbose if bond_tol is not None: self.bond_tol = float(bond_tol) # Create the atoms object from the slab and adsorbate @@ -353,9 +347,6 @@ def update_arguments( # Create the boundary conditions if bounds is not None: self.setup_bounds(bounds) - if parallel_run is not None: - self.parallel_run = parallel_run - self.check_parallel() return self def set_seed(self, seed=None): diff --git a/catlearn/optimizer/local.py b/catlearn/optimizer/local.py index 4d814142..fb74d8fa 100644 --- a/catlearn/optimizer/local.py +++ b/catlearn/optimizer/local.py @@ -215,32 +215,22 @@ def update_arguments( The seed an also be a RandomState or Generator instance. If not given, the default random number generator is used. """ - # Set the communicator - if comm is not None: - self.comm = comm - self.rank = comm.rank - self.size = comm.size - elif not hasattr(self, "comm"): - self.comm = None - self.rank = 0 - self.size = 1 - # Set the seed - if seed is not None or not hasattr(self, "seed"): - self.set_seed(seed) - # Set the verbose - if verbose is not None: - self.verbose = verbose - if atoms is not None: - self.setup_optimizable(atoms) + # Set the parameters in the parent class + super().update_arguments( + optimizable=atoms, + parallel_run=parallel_run, + comm=comm, + verbose=verbose, + seed=seed, + **kwargs, + ) + # Set the local optimizer if local_opt is not None and local_opt_kwargs is not None: self.setup_local_optimizer(local_opt, local_opt_kwargs) elif local_opt is not None: self.setup_local_optimizer(self.local_opt) elif local_opt_kwargs is not None: self.setup_local_optimizer(self.local_opt, local_opt_kwargs) - if parallel_run is not None: - self.parallel_run = parallel_run - self.check_parallel() return self def run_max_unc_step(self, optimizer, fmax=0.05, **kwargs): diff --git a/catlearn/optimizer/localneb.py b/catlearn/optimizer/localneb.py index c9edd1e4..c7923020 100644 --- a/catlearn/optimizer/localneb.py +++ b/catlearn/optimizer/localneb.py @@ -150,32 +150,22 @@ def update_arguments( The seed an also be a RandomState or Generator instance. If not given, the default random number generator is used. """ - # Set the communicator - if comm is not None: - self.comm = comm - self.rank = comm.rank - self.size = comm.size - elif not hasattr(self, "comm"): - self.comm = None - self.rank = 0 - self.size = 1 - # Set the seed - if seed is not None or not hasattr(self, "seed"): - self.set_seed(seed) - # Set the verbose - if verbose is not None: - self.verbose = verbose - if neb is not None: - self.setup_optimizable(neb) + # Set the parameters in the parent class + super().update_arguments( + optimizable=neb, + parallel_run=parallel_run, + comm=comm, + verbose=verbose, + seed=seed, + **kwargs, + ) + # Set the local optimizer if local_opt is not None and local_opt_kwargs is not None: self.setup_local_optimizer(local_opt, local_opt_kwargs) elif local_opt is not None: self.setup_local_optimizer(self.local_opt) elif local_opt_kwargs is not None: self.setup_local_optimizer(self.local_opt, local_opt_kwargs) - if parallel_run is not None: - self.parallel_run = parallel_run - self.check_parallel() return self def get_arguments(self): diff --git a/catlearn/optimizer/method.py b/catlearn/optimizer/method.py index 32aca313..088a4ac9 100644 --- a/catlearn/optimizer/method.py +++ b/catlearn/optimizer/method.py @@ -720,8 +720,10 @@ def update_arguments( # Set the verbose if verbose is not None: self.verbose = verbose + # Set the optimizable if optimizable is not None: self.setup_optimizable(optimizable) + # Set and check the parallelization if parallel_run is not None: self.parallel_run = parallel_run self.check_parallel() diff --git a/catlearn/optimizer/parallelopt.py b/catlearn/optimizer/parallelopt.py index 68371ce6..23eae039 100644 --- a/catlearn/optimizer/parallelopt.py +++ b/catlearn/optimizer/parallelopt.py @@ -209,24 +209,16 @@ def update_arguments( The seed an also be a RandomState or Generator instance. If not given, the default random number generator is used. """ - # Set the communicator - if comm is not None: - self.comm = comm - self.rank = comm.rank - self.size = comm.size - elif not hasattr(self, "comm"): - self.comm = None - self.rank = 0 - self.size = 1 - # Set the seed - if seed is not None or not hasattr(self, "seed"): - self.set_seed(seed) - # Set the verbose - if verbose is not None: - self.verbose = verbose - if parallel_run is not None: - self.parallel_run = parallel_run - self.check_parallel() + # Set the parameters in the parent class + super().update_arguments( + optimizable=None, + parallel_run=parallel_run, + comm=comm, + verbose=verbose, + seed=seed, + **kwargs, + ) + # Set the chains if chains is not None: self.chains = chains elif not hasattr(self, "chains"): @@ -234,12 +226,13 @@ def update_arguments( chains = comm.size else: chains = 1 + self.chains = chains + # Set the method if method is not None: self.method = method.copy() self.methods = [method.copy() for _ in range(self.chains)] self.setup_optimizable() - if verbose is not None: - self.verbose = verbose + # Check if the number of chains is optimal if self.chains % self.size != 0: self.message( "The number of chains should be divisible by " diff --git a/catlearn/optimizer/sequential.py b/catlearn/optimizer/sequential.py index 0f4030f2..42866de5 100644 --- a/catlearn/optimizer/sequential.py +++ b/catlearn/optimizer/sequential.py @@ -172,29 +172,22 @@ def update_arguments( The seed an also be a RandomState or Generator instance. If not given, the default random number generator is used. """ - # Set the communicator - if comm is not None: - self.comm = comm - self.rank = comm.rank - self.size = comm.size - elif not hasattr(self, "comm"): - self.comm = None - self.rank = 0 - self.size = 1 - # Set the seed - if seed is not None or not hasattr(self, "seed"): - self.set_seed(seed) - # Set the verbose - if verbose is not None: - self.verbose = verbose - if remove_methods is not None: - self.remove_methods = remove_methods + # Set the methods if methods is not None: self.methods = methods self.setup_optimizable() - if parallel_run is not None: - self.parallel_run = parallel_run - self.check_parallel() + # Set the remove methods + if remove_methods is not None: + self.remove_methods = remove_methods + # Set the parameters in the parent class + super().update_arguments( + optimizable=None, + parallel_run=parallel_run, + comm=comm, + verbose=verbose, + seed=seed, + **kwargs, + ) return self def get_arguments(self): From 5a5501f9d4437655536484f7e8f6cfad852a7d00 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Mon, 7 Apr 2025 10:15:05 +0200 Subject: [PATCH 099/194] Use seed for acquisition --- catlearn/activelearning/acquisition.py | 183 +++++++++++++++++++------ 1 file changed, 142 insertions(+), 41 deletions(-) diff --git a/catlearn/activelearning/acquisition.py b/catlearn/activelearning/acquisition.py index 7b741187..98b7c83a 100644 --- a/catlearn/activelearning/acquisition.py +++ b/catlearn/activelearning/acquisition.py @@ -1,9 +1,10 @@ -import numpy as np +from numpy import argsort, max as max_ +from numpy.random import default_rng, Generator, RandomState from scipy.stats import norm class Acquisition: - def __init__(self, objective="min", **kwargs): + def __init__(self, objective="min", seed=None, **kwargs): """ Acquisition function class. @@ -14,8 +15,12 @@ def __init__(self, objective="min", **kwargs): - 'min': Sort after the smallest values. - 'max': Sort after the largest values. - 'random' : Sort randomly + seed: int (optional) + The random seed. + The seed an also be a RandomState or Generator instance. + If not given, the default random number generator is used. """ - self.update_arguments(objective=objective, **kwargs) + self.update_arguments(objective=objective, seed=seed, **kwargs) def calculate(self, energy, uncertainty=None, **kwargs): "Calculate the acqusition function value." @@ -24,10 +29,10 @@ def calculate(self, energy, uncertainty=None, **kwargs): def choose(self, candidates): "Sort a list of acquisition function values." if self.objective == "min": - return np.argsort(candidates) + return argsort(candidates) elif self.objective == "max": - return np.argsort(candidates)[::-1] - return np.random.permutation(list(range(len(candidates)))) + return argsort(candidates)[::-1] + return self.rng.permutation(list(range(len(candidates)))) def objective_value(self, value): "Return the objective value." @@ -35,16 +40,33 @@ def objective_value(self, value): return -value return value - def update_arguments(self, objective=None, **kwargs): + def update_arguments(self, objective=None, seed=None, **kwargs): "Set the parameters of the Acquisition function class." + # Set the seed + if seed is not None or not hasattr(self, "seed"): + self.set_seed(seed) + # Set the objective if objective is not None: self.objective = objective.lower() return self + def set_seed(self, seed=None): + "Set the random seed for the optimization." + if seed is not None: + self.seed = seed + if isinstance(seed, int): + self.rng = default_rng(self.seed) + elif isinstance(seed, Generator) or isinstance(seed, RandomState): + self.rng = seed + else: + self.seed = None + self.rng = default_rng() + return self + def get_arguments(self): "Get the arguments of the class itself." # Get the arguments given to the class in the initialization - arg_kwargs = dict(objective=self.objective) + arg_kwargs = dict(objective=self.objective, seed=self.seed) # Get the constants made within the class constant_kwargs = dict() # Get the objects made within the class @@ -76,9 +98,9 @@ def __repr__(self): class AcqEnergy(Acquisition): - def __init__(self, objective="min", **kwargs): + def __init__(self, objective="min", seed=None, **kwargs): "The predicted energy as the acqusition function." - super().__init__(objective) + super().__init__(objective=objective, seed=seed, **kwargs) def calculate(self, energy, uncertainty=None, **kwargs): "Calculate the acqusition function value as the predicted energy." @@ -86,9 +108,9 @@ def calculate(self, energy, uncertainty=None, **kwargs): class AcqUncertainty(Acquisition): - def __init__(self, objective="min", **kwargs): + def __init__(self, objective="min", seed=None, **kwargs): "The predicted uncertainty as the acqusition function." - super().__init__(objective) + super().__init__(objective=objective, seed=seed, **kwargs) def calculate(self, energy, uncertainty=None, **kwargs): "Calculate the acqusition function value as the predicted uncertainty." @@ -96,13 +118,21 @@ def calculate(self, energy, uncertainty=None, **kwargs): class AcqUCB(Acquisition): - def __init__(self, objective="max", kappa=2.0, kappamax=3.0, **kwargs): + def __init__( + self, + objective="max", + seed=None, + kappa=2.0, + kappamax=3.0, + **kwargs, + ): """ The predicted upper confidence interval (ucb) as the acqusition function. """ self.update_arguments( objective=objective, + seed=seed, kappa=kappa, kappamax=kappamax, **kwargs, @@ -116,23 +146,30 @@ def calculate(self, energy, uncertainty=None, **kwargs): def get_kappa(self): "Get the kappa value." if isinstance(self.kappa, str): - return np.random.uniform(0, self.kappamax) + return self.rng.uniform(0, self.kappamax) return self.kappa def update_arguments( self, objective=None, + seed=None, kappa=None, kappamax=None, **kwargs, ): "Set the parameters of the Acquisition function class." - if objective is not None: - self.objective = objective.lower() + # Set the parameters in the parent class + super().update_arguments( + objective=objective, + seed=seed, + **kwargs, + ) + # Set the kappa value if kappa is not None: if isinstance(kappa, (float, int)): kappa = abs(kappa) self.kappa = kappa + # Set the kappamax value if kappamax is not None: self.kappamax = abs(kappamax) return self @@ -142,8 +179,10 @@ def get_arguments(self): # Get the arguments given to the class in the initialization arg_kwargs = dict( objective=self.objective, + seed=self.seed, kappa=self.kappa, kappamax=self.kappamax, + seed=self.seed, ) # Get the constants made within the class constant_kwargs = dict() @@ -153,13 +192,21 @@ def get_arguments(self): class AcqLCB(AcqUCB): - def __init__(self, objective="min", kappa=2.0, kappamax=3.0, **kwargs): + def __init__( + self, + objective="min", + seed=None, + kappa=2.0, + kappamax=3.0, + **kwargs, + ): """ The predicted lower confidence interval (lcb) as the acqusition function. """ super().__init__( objective=objective, + seed=seed, kappa=kappa, kappamax=kappamax, **kwargs, @@ -172,12 +219,12 @@ def calculate(self, energy, uncertainty=None, **kwargs): class AcqIter(Acquisition): - def __init__(self, objective="max", niter=2, **kwargs): + def __init__(self, objective="max", seed=None, niter=2, **kwargs): """ The predicted energy or uncertainty dependent on the iteration as the acqusition function. """ - self.update_arguments(objective=objective, niter=niter, **kwargs) + super().__init__(objective=objective, seed=seed, niter=niter, **kwargs) self.iter = 0 def calculate(self, energy, uncertainty=None, **kwargs): @@ -190,10 +237,21 @@ def calculate(self, energy, uncertainty=None, **kwargs): return energy return uncertainty - def update_arguments(self, objective=None, niter=None, **kwargs): + def update_arguments( + self, + objective=None, + seed=None, + niter=None, + **kwargs, + ): "Set the parameters of the Acquisition function class." - if objective is not None: - self.objective = objective.lower() + # Set the parameters in the parent class + super().update_arguments( + objective=objective, + seed=seed, + **kwargs, + ) + # Set the number of iterations if niter is not None: self.niter = abs(niter) return self @@ -203,6 +261,7 @@ def get_arguments(self): # Get the arguments given to the class in the initialization arg_kwargs = dict( objective=self.objective, + seed=self.seed, niter=self.niter, ) # Get the constants made within the class @@ -213,13 +272,16 @@ def get_arguments(self): class AcqUME(Acquisition): - def __init__(self, objective="max", unc_convergence=0.05, **kwargs): + def __init__( + self, objective="max", seed=None, unc_convergence=0.05, **kwargs + ): """ The predicted uncertainty when it is larger than unc_convergence else predicted energy as the acqusition function. """ - self.update_arguments( + super().__init__( objective=objective, + seed=seed, unc_convergence=unc_convergence, **kwargs, ) @@ -229,14 +291,19 @@ def calculate(self, energy, uncertainty=None, **kwargs): Calculate the acqusition function value as the predicted uncertainty when it is is larger than unc_convergence else predicted energy. """ - if np.max([uncertainty]) < self.unc_convergence: + if max_([uncertainty]) < self.unc_convergence: return energy return self.objective_value(uncertainty) def update_arguments(self, objective=None, unc_convergence=None, **kwargs): "Set the parameters of the Acquisition function class." - if objective is not None: - self.objective = objective.lower() + # Set the parameters in the parent class + super().update_arguments( + objective=objective, + seed=None, + **kwargs, + ) + # Set the unc_convergence value if unc_convergence is not None: self.unc_convergence = abs(unc_convergence) return self @@ -246,6 +313,7 @@ def get_arguments(self): # Get the arguments given to the class in the initialization arg_kwargs = dict( objective=self.objective, + seed=self.seed, unc_convergence=self.unc_convergence, ) # Get the constants made within the class @@ -259,6 +327,7 @@ class AcqUUCB(AcqUCB): def __init__( self, objective="max", + seed=None, kappa=2.0, kappamax=3.0, unc_convergence=0.05, @@ -270,6 +339,7 @@ def __init__( """ self.update_arguments( objective=objective, + seed=seed, kappa=kappa, kappamax=kappamax, unc_convergence=unc_convergence, @@ -281,7 +351,7 @@ def calculate(self, energy, uncertainty=None, **kwargs): Calculate the acqusition function value as the predicted uncertainty when it is is larger than unc_convergence else ucb. """ - if np.max([uncertainty]) < self.unc_convergence: + if max_([uncertainty]) < self.unc_convergence: kappa = self.get_kappa() return energy + kappa * uncertainty return uncertainty @@ -289,20 +359,28 @@ def calculate(self, energy, uncertainty=None, **kwargs): def update_arguments( self, objective=None, + seed=None, kappa=None, kappamax=None, unc_convergence=None, **kwargs, ): "Set the parameters of the Acquisition function class." - if objective is not None: - self.objective = objective.lower() + # Set the parameters in the parent class + super().update_arguments( + objective=objective, + seed=seed, + **kwargs, + ) + # Set the kappa value if kappa is not None: if isinstance(kappa, (float, int)): kappa = abs(kappa) self.kappa = kappa + # Set the kappamax value if kappamax is not None: self.kappamax = abs(kappamax) + # Set the unc_convergence value if unc_convergence is not None: self.unc_convergence = abs(unc_convergence) return self @@ -312,6 +390,7 @@ def get_arguments(self): # Get the arguments given to the class in the initialization arg_kwargs = dict( objective=self.objective, + seed=self.seed, kappa=self.kappa, kappamax=self.kappamax, unc_convergence=self.unc_convergence, @@ -327,6 +406,7 @@ class AcqULCB(AcqUUCB): def __init__( self, objective="min", + seed=None, kappa=2.0, kappamax=3.0, unc_convergence=0.05, @@ -338,6 +418,7 @@ def __init__( """ self.update_arguments( objective=objective, + seed=seed, kappa=kappa, kappamax=kappamax, unc_convergence=unc_convergence, @@ -349,18 +430,23 @@ def calculate(self, energy, uncertainty=None, **kwargs): Calculate the acqusition function value as the predicted uncertainty when it is is larger than unc_convergence else lcb. """ - if np.max([uncertainty]) < self.unc_convergence: + if max_([uncertainty]) < self.unc_convergence: kappa = self.get_kappa() return energy - kappa * uncertainty return -uncertainty class AcqEI(Acquisition): - def __init__(self, objective="max", ebest=None, **kwargs): + def __init__(self, objective="max", seed=None, ebest=None, **kwargs): """ The predicted expected improvement as the acqusition function. """ - self.update_arguments(objective=objective, ebest=ebest, **kwargs) + self.update_arguments( + objective=objective, + seed=seed, + ebest=ebest, + **kwargs, + ) def calculate(self, energy, uncertainty=None, **kwargs): """ @@ -371,14 +457,23 @@ def calculate(self, energy, uncertainty=None, **kwargs): a = (energy - self.ebest) * norm.cdf(z) + uncertainty * norm.pdf(z) return self.objective_value(a) - def update_arguments(self, objective=None, ebest=None, **kwargs): + def update_arguments( + self, + objective=None, + seed=None, + ebest=None, + **kwargs, + ): "Set the parameters of the Acquisition function class." - if objective is not None: - self.objective = objective.lower() - if ebest is not None: + # Set the parameters in the parent class + super().update_arguments( + objective=objective, + seed=seed, + **kwargs, + ) + # Set the ebest value + if ebest is not None or not hasattr(self, "ebest"): self.ebest = ebest - elif not hasattr(self, "ebest"): - self.ebest = None return self def get_arguments(self): @@ -386,6 +481,7 @@ def get_arguments(self): # Get the arguments given to the class in the initialization arg_kwargs = dict( objective=self.objective, + seed=self.seed, ebest=self.ebest, ) # Get the constants made within the class @@ -396,11 +492,16 @@ def get_arguments(self): class AcqPI(AcqEI): - def __init__(self, objective="max", ebest=None, **kwargs): + def __init__(self, objective="max", seed=None, ebest=None, **kwargs): """ The predicted probability of improvement as the acqusition function. """ - self.update_arguments(objective=objective, ebest=ebest, **kwargs) + self.update_arguments( + objective=objective, + seed=seed, + ebest=ebest, + **kwargs, + ) def calculate(self, energy, uncertainty=None, **kwargs): """ From 1b7d3e912277d52ed3e96624876d65bce97077ee Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Mon, 7 Apr 2025 10:55:48 +0200 Subject: [PATCH 100/194] Use seed and make get_default_mlcalc --- catlearn/activelearning/activelearning.py | 131 +++++++++++++++++----- catlearn/activelearning/adsorption.py | 62 +++++----- catlearn/activelearning/local.py | 11 +- catlearn/activelearning/mlgo.py | 11 +- catlearn/activelearning/mlneb.py | 13 ++- 5 files changed, 159 insertions(+), 69 deletions(-) diff --git a/catlearn/activelearning/activelearning.py b/catlearn/activelearning/activelearning.py index 3a55479c..6a105661 100644 --- a/catlearn/activelearning/activelearning.py +++ b/catlearn/activelearning/activelearning.py @@ -1,4 +1,6 @@ -import numpy as np +from numpy import asarray, max as max_, mean as mean_, nan, nanmax, ndarray +from numpy.linalg import norm +from numpy.random import default_rng, Generator, RandomState from ase.io import read from ase.parallel import world, broadcast from ase.io.trajectory import TrajectoryWriter @@ -39,6 +41,7 @@ def __init__( tabletxt="ml_summary.txt", prev_calculations=None, restart=False, + seed=None, comm=world, **kwargs, ): @@ -142,6 +145,10 @@ def __init__( or Trajectory filename. restart: bool Whether to restart the active learning. + seed: int (optional) + The random seed for the optimization. + The seed an also be a RandomState or Generator instance. + If not given, the default random number generator is used. comm: MPI communicator. The MPI communicator. """ @@ -189,6 +196,7 @@ def __init__( converged_trajectory=converged_trajectory, initial_traj=initial_traj, tabletxt=tabletxt, + seed=seed, comm=comm, **kwargs, ) @@ -207,7 +215,6 @@ def run( ml_steps=1000, max_unc=0.3, dtrust=None, - seed=None, **kwargs, ): """ @@ -227,16 +234,11 @@ def run( without the maximum uncertainty. dtrust: float (optional) The trust distance for the optimization method. - seed: int (optional) - The random seed. Returns: converged: bool Whether the active learning is converged. """ - # Set the random seed - if seed is not None: - np.random.seed(seed) # Check if there are any training data self.extra_initial_data() # Run the active learning @@ -293,8 +295,8 @@ def reset(self, **kwargs): # Set initial parameters self.steps = 0 self._converged = False - self.unc = np.nan - self.energy_pred = np.nan + self.unc = nan + self.energy_pred = nan self.pred_energies = [] self.uncertainties = [] # Set the header for the summary table @@ -316,6 +318,10 @@ def setup_method(self, method, **kwargs): """ # Save the method self.method = method + # Set the seed for the method + if hasattr(self, "seed"): + self.method.set_seed(self.seed) + # Get the structures self.structures = self.get_structures() if isinstance(self.structures, list): self.n_structures = len(self.structures) @@ -340,6 +346,42 @@ def setup_method(self, method, **kwargs): def setup_mlcalc( self, mlcalc=None, + verbose=True, + **kwargs, + ): + """ + Setup the ML calculator. + + Parameters: + mlcalc: ML-calculator instance (optional) + The ML-calculator instance used as surrogate surface. + A default ML-model is used if mlcalc is None. + verbose: bool + Whether to print on screen the full output (True) or + not (False). + + Returns: + self: The object itself. + """ + # Check if the ML calculator is given + if mlcalc is not None: + self.mlcalc = mlcalc + # Set the verbose for the ML calculator + if verbose is not None: + self.mlcalc.mlmodel.update_arguments(verbose=verbose) + else: + self.mlcalc = self.setup_default_mlcalc( + verbose=verbose, + **kwargs, + ) + # Check if the seed is given + if hasattr(self, "seed"): + # Set the seed for the ML calculator + self.mlcalc.set_seed(self.seed) + return self + + def setup_default_mlcalc( + self, save_memory=False, fp=None, atoms=None, @@ -406,10 +448,6 @@ def setup_mlcalc( Returns: self: The object itself. """ - # Check if the ML calculator is given - if mlcalc is not None: - self.mlcalc = mlcalc - return self # Create the ML calculator from ..regression.gp.calculator.mlmodel import get_default_mlmodel from ..regression.gp.calculator.bocalc import BOCalculator @@ -467,7 +505,7 @@ def setup_mlcalc( data = [] # Setup the ML calculator if bayesian: - self.mlcalc = BOCalculator( + mlcalc = BOCalculator( mlmodel=mlmodel, calc_forces=calc_forces, kappa=kappa, @@ -481,7 +519,7 @@ def setup_mlcalc( "is not recommended!" ) else: - self.mlcalc = MLCalculator( + mlcalc = MLCalculator( mlmodel=mlmodel, calc_forces=calc_forces, **calc_kwargs, @@ -490,7 +528,7 @@ def setup_mlcalc( if reuse_mlcalc_data: if len(data): self.add_training(data) - return self + return mlcalc def setup_acq( self, @@ -543,6 +581,9 @@ def setup_acq( "The objective of the acquisition function " "does not match the active learner." ) + # Set the seed for the acquisition function + if hasattr(self, "seed"): + self.acq.set_seed(self.seed) return self def get_structures( @@ -658,6 +699,7 @@ def update_arguments( converged_trajectory=None, initial_traj=None, tabletxt=None, + seed=None, comm=None, **kwargs, ): @@ -761,6 +803,10 @@ def update_arguments( or Trajectory filename. restart: bool Whether to restart the active learning. + seed: int (optional) + The random seed for the optimization. + The seed an also be a RandomState or Generator instance. + If not given, the default random number generator is used. comm: MPI communicator. The MPI communicator. @@ -858,6 +904,9 @@ def update_arguments( is_minimization=self.is_minimization, unc_convergence=self.unc_convergence, ) + # Set the seed + if seed is not None or not hasattr(self, "seed"): + self.set_seed(seed) # Check if the method and BO is compatible self.check_attributes() return self @@ -947,7 +996,7 @@ def initiate_structure(self, step=1, **kwargs): # Check fmax is lower than previous structure if use_tmp and (self.check_fmax or self.check_energy): self.update_method(self.best_structures) - energy_best, fmax_best = self.get_predictions()[1:] + _, energy_best, fmax_best = self.get_predictions() if self.check_fmax: if fmax_tmp > fmax_best: self.message_system( @@ -983,7 +1032,7 @@ def get_predictions(self, **kwargs): if self.check_energy: energy = self.method.get_potential_energy() if self.check_fmax: - fmax = np.max(self.method.get_fmax()) + fmax = max_(self.method.get_fmax()) return uncmax, energy, fmax def get_candidate_predictions(self, **kwargs): @@ -998,9 +1047,9 @@ def get_candidate_predictions(self, **kwargs): per_candidate=True, **kwargs, ) - energies = np.array(results["energy"]).reshape(-1) - uncertainties = np.array(results["uncertainty"]).reshape(-1) - fmaxs = np.array(results["fmax"]).reshape(-1) + energies = asarray(results["energy"]).reshape(-1) + uncertainties = asarray(results["uncertainty"]).reshape(-1) + fmaxs = asarray(results["fmax"]).reshape(-1) return energies, uncertainties, fmaxs def parallel_setup(self, comm, **kwargs): @@ -1076,7 +1125,7 @@ def save_trajectory(self, trajectory, structures, mode="w", **kwargs): def evaluate_candidates(self, candidates, **kwargs): "Evaluate the candidates." # Check if the candidates are a list - if not isinstance(candidates, (list, np.ndarray)): + if not isinstance(candidates, (list, ndarray)): candidates = [candidates] # Evaluate the candidates for candidate in candidates: @@ -1105,7 +1154,7 @@ def evaluate(self, candidate, **kwargs): self.steps += 1 self.message_system("Single-point calculation finished.") # Store the data - self.true_fmax = np.nanmax(np.linalg.norm(forces, axis=1)) + self.true_fmax = nanmax(norm(forces, axis=1)) self.add_training([self.candidate]) self.save_data() # Make a reference energy @@ -1130,7 +1179,7 @@ def update_candidate(self, candidate, dtol=1e-8, **kwargs): # Set cell cell_old = self.candidate.get_cell() cell_new = candidate.get_cell() - if np.linalg.norm(cell_old - cell_new) > dtol: + if norm(cell_old - cell_new) > dtol: self.candidate.set_cell(cell_new) # Set pbc pbc_old = self.candidate.get_pbc() @@ -1140,22 +1189,22 @@ def update_candidate(self, candidate, dtol=1e-8, **kwargs): # Set initial charges ini_charge_old = self.candidate.get_initial_charges() ini_charge_new = candidate.get_initial_charges() - if np.linalg.norm(ini_charge_old - ini_charge_new) > dtol: + if norm(ini_charge_old - ini_charge_new) > dtol: self.candidate.set_initial_charges(ini_charge_new) # Set initial magmoms ini_magmom_old = self.candidate.get_initial_magnetic_moments() ini_magmom_new = candidate.get_initial_magnetic_moments() - if np.linalg.norm(ini_magmom_old - ini_magmom_new) > dtol: + if norm(ini_magmom_old - ini_magmom_new) > dtol: self.candidate.set_initial_magnetic_moments(ini_magmom_new) # Set momenta momenta_old = self.candidate.get_momenta() momenta_new = candidate.get_momenta() - if np.linalg.norm(momenta_old - momenta_new) > dtol: + if norm(momenta_old - momenta_new) > dtol: self.candidate.set_momenta(momenta_new) # Set velocities velocities_old = self.candidate.get_velocities() velocities_new = candidate.get_velocities() - if np.linalg.norm(velocities_old - velocities_new) > dtol: + if norm(velocities_old - velocities_new) > dtol: self.candidate.set_velocities(velocities_new) return candidate @@ -1214,7 +1263,7 @@ def ensure_not_in_database(self, atoms, perturb=0.01, **kwargs): # Get positions pos = atoms.get_positions() # Rattle the positions - pos += np.random.uniform( + pos += self.rng.uniform( low=-perturb, high=perturb, size=pos.shape, @@ -1250,8 +1299,8 @@ def choose_candidates(self, **kwargs): # Get the energies and uncertainties energies, uncertainties, fmaxs = self.get_candidate_predictions() # Store the uncertainty predictions - self.umax = np.max(uncertainties) - self.umean = np.mean(uncertainties) + self.umax = max_(uncertainties) + self.umean = mean_(uncertainties) # Calculate the acquisition function for each candidate acq_values = self.acq.calculate( energy=energies, @@ -1383,6 +1432,25 @@ def check_attributes(self, **kwargs): ) return self + def set_seed(self, seed=None): + "Set the random seed for the optimization." + if seed is not None: + self.seed = seed + if isinstance(seed, int): + self.rng = default_rng(self.seed) + elif isinstance(seed, Generator) or isinstance(seed, RandomState): + self.rng = seed + else: + self.seed = None + self.rng = default_rng() + # Set the random seed for the optimization method + self.method.set_seed(self.seed) + # Set the random seed for the acquisition function + self.acq.set_seed(self.seed) + # Set the random seed for the ML calculator + self.mlcalc.set_seed(self.seed) + return self + def message_system(self, message, obj=None, end="\n"): "Print output once." if self.verbose is True: @@ -1523,6 +1591,7 @@ def get_arguments(self): converged_trajectory=self.converged_trajectory, initial_traj=self.initial_traj, tabletxt=self.tabletxt, + seed=self.seed, comm=self.comm, ) # Get the constants made within the class diff --git a/catlearn/activelearning/adsorption.py b/catlearn/activelearning/adsorption.py index 4d66c179..49cd38c4 100644 --- a/catlearn/activelearning/adsorption.py +++ b/catlearn/activelearning/adsorption.py @@ -42,6 +42,7 @@ def __init__( tabletxt="ml_summary.txt", prev_calculations=None, restart=False, + seed=None, comm=world, **kwargs, ): @@ -161,6 +162,10 @@ def __init__( or Trajectory filename. restart: bool Whether to restart the active learning. + seed: int (optional) + The random seed for the optimization. + The seed an also be a RandomState or Generator instance. + If not given, the default random number generator is used. comm: MPI communicator. The MPI communicator. """ @@ -209,6 +214,7 @@ def __init__( tabletxt=tabletxt, prev_calculations=prev_calculations, restart=restart, + seed=seed, comm=comm, **kwargs, ) @@ -285,9 +291,8 @@ def extra_initial_data(self, **kwargs): self.extra_initial_data(**kwargs) return self - def setup_mlcalc( + def setup_default_mlcalc( self, - mlcalc=None, fp=None, atoms=None, baseline=RepulsionCalculator(), @@ -296,35 +301,31 @@ def setup_mlcalc( kappa=-2.0, **kwargs, ): - if mlcalc is None: - from ..regression.gp.fingerprint.sorteddistances import ( - SortedDistances, - ) + from ..regression.gp.fingerprint.sorteddistances import ( + SortedDistances, + ) - # Setup the fingerprint - if fp is None: - # Check if the Atoms object is given - if atoms is None: - try: - atoms = self.get_structures(get_all=False) - except Exception: - raise Exception( - "The Atoms object is not given or stored." - ) - # Can only use distances if there are more than one atom - if len(atoms) > 1: - if atoms.pbc.any(): - periodic_softmax = True - else: - periodic_softmax = False - fp = SortedDistances( - reduce_dimensions=True, - use_derivatives=True, - periodic_softmax=periodic_softmax, - wrap=False, - ) - return super().setup_mlcalc( - mlcalc=mlcalc, + # Setup the fingerprint + if fp is None: + # Check if the Atoms object is given + if atoms is None: + try: + atoms = self.get_structures(get_all=False) + except Exception: + raise Exception("The Atoms object is not given or stored.") + # Can only use distances if there are more than one atom + if len(atoms) > 1: + if atoms.pbc.any(): + periodic_softmax = True + else: + periodic_softmax = False + fp = SortedDistances( + reduce_dimensions=True, + use_derivatives=True, + periodic_softmax=periodic_softmax, + wrap=False, + ) + return super().setup_default_mlcalc( fp=fp, atoms=atoms, baseline=baseline, @@ -370,6 +371,7 @@ def get_arguments(self): converged_trajectory=self.converged_trajectory, initial_traj=self.initial_traj, tabletxt=self.tabletxt, + seed=self.seed, comm=self.comm, ) # Get the constants made within the class diff --git a/catlearn/activelearning/local.py b/catlearn/activelearning/local.py index aa7b6d68..9939386f 100644 --- a/catlearn/activelearning/local.py +++ b/catlearn/activelearning/local.py @@ -1,6 +1,6 @@ from ase.optimize import FIRE from ase.parallel import world -import numpy as np +from numpy.linalg import norm from .activelearning import ActiveLearning from ..optimizer import LocalOptimizer @@ -40,6 +40,7 @@ def __init__( tabletxt="ml_summary.txt", prev_calculations=None, restart=False, + seed=None, comm=world, **kwargs, ): @@ -147,6 +148,10 @@ def __init__( or Trajectory filename. restart: bool Whether to restart the active learning. + seed: int (optional) + The random seed for the optimization. + The seed an also be a RandomState or Generator instance. + If not given, the default random number generator is used. comm: MPI communicator. The MPI communicator. """ @@ -191,6 +196,7 @@ def __init__( tabletxt=tabletxt, prev_calculations=prev_calculations, restart=restart, + seed=seed, comm=comm, **kwargs, ) @@ -231,7 +237,7 @@ def extra_initial_data(self, **kwargs): if "energy" in results and "forces" in results: pos0 = self.atoms.get_positions() pos1 = self.atoms.calc.atoms.get_positions() - if np.linalg.norm(pos0 - pos1) < 1e-8: + if norm(pos0 - pos1) < 1e-8: self.use_prev_calculations([self.atoms]) return self # Calculate the initial structure @@ -274,6 +280,7 @@ def get_arguments(self): converged_trajectory=self.converged_trajectory, initial_traj=self.initial_traj, tabletxt=self.tabletxt, + seed=self.seed, comm=self.comm, ) # Get the constants made within the class diff --git a/catlearn/activelearning/mlgo.py b/catlearn/activelearning/mlgo.py index 26ea762c..c12f1f5c 100644 --- a/catlearn/activelearning/mlgo.py +++ b/catlearn/activelearning/mlgo.py @@ -46,6 +46,7 @@ def __init__( tabletxt="ml_summary.txt", prev_calculations=None, restart=False, + seed=None, comm=world, **kwargs, ): @@ -182,6 +183,10 @@ def __init__( or Trajectory filename. restart: bool Whether to restart the active learning. + seed: int (optional) + The random seed for the optimization. + The seed an also be a RandomState or Generator instance. + If not given, the default random number generator is used. comm: MPI communicator. The MPI communicator. """ @@ -221,6 +226,7 @@ def __init__( tabletxt=tabletxt, prev_calculations=prev_calculations, restart=restart, + seed=seed, comm=comm, **kwargs, ) @@ -262,6 +268,7 @@ def build_local_method( parallel_run=False, comm=self.comm, verbose=self.verbose, + seed=self.seed, ) return self.local_method @@ -280,7 +287,6 @@ def run( ml_steps_local=1000, max_unc=0.3, dtrust=None, - seed=None, **kwargs, ): """ @@ -316,7 +322,6 @@ def run( ml_steps=ml_steps, max_unc=max_unc, dtrust=dtrust, - seed=seed, **kwargs, ) # Check if the active learning is converged @@ -335,7 +340,6 @@ def run( ml_steps=ml_steps_local, max_unc=max_unc, dtrust=dtrust, - seed=seed, **kwargs, ) return self.converged() @@ -422,6 +426,7 @@ def get_arguments(self): converged_trajectory=self.converged_trajectory, initial_traj=self.initial_traj, tabletxt=self.tabletxt, + seed=self.seed, comm=self.comm, ) # Get the constants made within the class diff --git a/catlearn/activelearning/mlneb.py b/catlearn/activelearning/mlneb.py index 10a49038..908e003d 100644 --- a/catlearn/activelearning/mlneb.py +++ b/catlearn/activelearning/mlneb.py @@ -1,7 +1,7 @@ from ase.optimize import FIRE from ase.parallel import world from ase.io import read -import numpy as np +from numpy.linalg import norm from .activelearning import ActiveLearning from ..optimizer import LocalCINEB from ..structures.neb import ImprovedTangentNEB @@ -48,6 +48,7 @@ def __init__( tabletxt="ml_summary.txt", prev_calculations=None, restart=False, + seed=None, comm=world, **kwargs, ): @@ -186,6 +187,10 @@ def __init__( or Trajectory filename. restart: bool Whether to restart the active learning. + seed: int (optional) + The random seed for the optimization. + The seed an also be a RandomState or Generator instance. + If not given, the default random number generator is used. comm: MPI communicator. The MPI communicator. """ @@ -239,6 +244,7 @@ def __init__( tabletxt=tabletxt, prev_calculations=self.prev_calculations, restart=restart, + seed=seed, comm=comm, **kwargs, ) @@ -273,11 +279,11 @@ def setup_endpoints( # Check if end points are in the previous calculations if len(prev_calculations): pos = prev_calculations[0].get_positions() - if np.linalg.norm(pos - self.start.get_positions()) < eps: + if norm(pos - self.start.get_positions()) < eps: prev_calculations = prev_calculations[1:] if len(prev_calculations): pos = prev_calculations[0].get_positions() - if np.linalg.norm(pos - self.end.get_positions()) < eps: + if norm(pos - self.end.get_positions()) < eps: prev_calculations = prev_calculations[1:] # Save the previous calculations self.prev_calculations += list(prev_calculations) @@ -407,6 +413,7 @@ def get_arguments(self): converged_trajectory=self.converged_trajectory, initial_traj=self.initial_traj, tabletxt=self.tabletxt, + seed=self.seed, comm=self.comm, ) # Get the constants made within the class From b0895a0f43ef70d673fc0ba97b7006fa4a8806f0 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Mon, 7 Apr 2025 11:12:57 +0200 Subject: [PATCH 101/194] Change docstring --- catlearn/activelearning/acquisition.py | 2 +- catlearn/activelearning/activelearning.py | 2 +- catlearn/optimizer/method.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/catlearn/activelearning/acquisition.py b/catlearn/activelearning/acquisition.py index 98b7c83a..ed8c478d 100644 --- a/catlearn/activelearning/acquisition.py +++ b/catlearn/activelearning/acquisition.py @@ -51,7 +51,7 @@ def update_arguments(self, objective=None, seed=None, **kwargs): return self def set_seed(self, seed=None): - "Set the random seed for the optimization." + "Set the random seed." if seed is not None: self.seed = seed if isinstance(seed, int): diff --git a/catlearn/activelearning/activelearning.py b/catlearn/activelearning/activelearning.py index 6a105661..ae83d431 100644 --- a/catlearn/activelearning/activelearning.py +++ b/catlearn/activelearning/activelearning.py @@ -1433,7 +1433,7 @@ def check_attributes(self, **kwargs): return self def set_seed(self, seed=None): - "Set the random seed for the optimization." + "Set the random seed." if seed is not None: self.seed = seed if isinstance(seed, int): diff --git a/catlearn/optimizer/method.py b/catlearn/optimizer/method.py index 088a4ac9..55a693ed 100644 --- a/catlearn/optimizer/method.py +++ b/catlearn/optimizer/method.py @@ -730,7 +730,7 @@ def update_arguments( return self def set_seed(self, seed=None): - "Set the random seed for the optimization." + "Set the random seed." if seed is not None: self.seed = seed if isinstance(seed, int): From 9de03310358b287352601d43a1a94fecbf3c5d32 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Mon, 7 Apr 2025 14:30:43 +0200 Subject: [PATCH 102/194] Use seed and inherit update arguments --- catlearn/regression/gp/calculator/database.py | 62 +++- .../gp/calculator/database_reduction.py | 265 +++++++++--------- 2 files changed, 192 insertions(+), 135 deletions(-) diff --git a/catlearn/regression/gp/calculator/database.py b/catlearn/regression/gp/calculator/database.py index 666df246..161cb4fd 100644 --- a/catlearn/regression/gp/calculator/database.py +++ b/catlearn/regression/gp/calculator/database.py @@ -1,5 +1,6 @@ from numpy import array, asarray, concatenate from numpy import round as round_ +from numpy.random import default_rng, Generator, RandomState from scipy.spatial.distance import cdist from ase.constraints import FixAtoms from ase.io.trajectory import TrajectoryWriter @@ -14,6 +15,7 @@ def __init__( use_derivatives=True, use_fingerprint=True, round_targets=None, + seed=None, dtype=None, **kwargs, ): @@ -35,6 +37,10 @@ def __init__( round_targets: int (optional) The number of decimals to round the targets to. If None, the targets are not rounded. + seed: int (optional) + The random seed. + The seed an also be a RandomState or Generator instance. + If not given, the default random number generator is used. dtype: type The data type of the arrays. """ @@ -42,9 +48,7 @@ def __init__( self.use_negative_forces = True # Use default fingerprint if it is not given if fingerprint is None: - from ..fingerprint.cartesian import Cartesian - - fingerprint = Cartesian( + self.set_default_fp( reduce_dimensions=reduce_dimensions, use_derivatives=use_derivatives, ) @@ -55,6 +59,7 @@ def __init__( use_derivatives=use_derivatives, use_fingerprint=use_fingerprint, round_targets=round_targets, + seed=seed, dtype=dtype, **kwargs, ) @@ -355,6 +360,7 @@ def update_arguments( use_derivatives=None, use_fingerprint=None, round_targets=None, + seed=None, dtype=None, **kwargs, ): @@ -398,8 +404,12 @@ def update_arguments( reset_database = True if round_targets is not None or not hasattr(self, "round_targets"): self.round_targets = round_targets + # Set the seed + if seed is not None or not hasattr(self, "seed"): + self.set_seed(seed) + # Set the data type if dtype is not None or not hasattr(self, "dtype"): - self.dtype = dtype + self.set_dtype(dtype) # Check that the database and the fingerprint have the same attributes self.check_attributes() # Reset the database if an argument has been changed @@ -407,6 +417,49 @@ def update_arguments( self.reset_database() return self + def set_seed(self, seed=None): + "Set the random seed." + if seed is not None: + self.seed = seed + if isinstance(seed, int): + self.rng = default_rng(self.seed) + elif isinstance(seed, Generator) or isinstance(seed, RandomState): + self.rng = seed + else: + self.seed = None + self.rng = default_rng() + return self + + def set_default_fp( + self, + reduce_dimensions=True, + use_derivatives=True, + **kwargs, + ): + "Use default fingerprint if it is not given." + from ..fingerprint.cartesian import Cartesian + + return Cartesian( + reduce_dimensions=reduce_dimensions, + use_derivatives=use_derivatives, + **kwargs, + ) + + def set_dtype(self, dtype, **kwargs): + """ + Set the data type of the arrays. + + Parameters: + dtype: type + The data type of the arrays. + + Returns: + self: The updated object itself. + """ + # Set the data type + self.dtype = dtype + return self + def check_attributes(self): "Check if all attributes agree between the class and subclasses." if self.reduce_dimensions != self.fingerprint.get_reduce_dimensions(): @@ -429,6 +482,7 @@ def get_arguments(self): use_derivatives=self.use_derivatives, use_fingerprint=self.use_fingerprint, round_targets=self.round_targets, + seed=self.seed, dtype=self.dtype, ) # Get the constants made within the class diff --git a/catlearn/regression/gp/calculator/database_reduction.py b/catlearn/regression/gp/calculator/database_reduction.py index 42c9a69e..4396c13c 100644 --- a/catlearn/regression/gp/calculator/database_reduction.py +++ b/catlearn/regression/gp/calculator/database_reduction.py @@ -25,6 +25,7 @@ def __init__( use_derivatives=True, use_fingerprint=True, round_targets=None, + seed=None, dtype=None, npoints=25, initial_indicies=[0], @@ -51,6 +52,10 @@ def __init__( round_targets: int (optional) The number of decimals to round the targets to. If None, the targets are not rounded. + seed: int (optional) + The random seed. + The seed an also be a RandomState or Generator instance. + If not given, the default random number generator is used. dtype: type The data type of the arrays. npoints : int @@ -67,9 +72,7 @@ def __init__( self.indicies = [] # Use default fingerprint if it is not given if fingerprint is None: - from ..fingerprint.cartesian import Cartesian - - fingerprint = Cartesian( + self.set_default_fp( reduce_dimensions=reduce_dimensions, use_derivatives=use_derivatives, ) @@ -80,6 +83,7 @@ def __init__( use_derivatives=use_derivatives, use_fingerprint=use_fingerprint, round_targets=round_targets, + seed=seed, dtype=dtype, npoints=npoints, initial_indicies=initial_indicies, @@ -94,6 +98,7 @@ def update_arguments( use_derivatives=None, use_fingerprint=None, round_targets=None, + seed=None, dtype=None, npoints=None, initial_indicies=None, @@ -118,6 +123,10 @@ def update_arguments( round_targets: int (optional) The number of decimals to round the targets to. If None, the targets are not rounded. + seed: int (optional) + The random seed. + The seed an also be a RandomState or Generator instance. + If not given, the default random number generator is used. dtype: type The data type of the arrays. npoints : int @@ -131,39 +140,30 @@ def update_arguments( Returns: self: The updated object itself. """ - # Control if the database has to be reset - reset_database = False - if fingerprint is not None: - self.fingerprint = fingerprint.copy() - reset_database = True - if reduce_dimensions is not None: - self.reduce_dimensions = reduce_dimensions - reset_database = True - if use_derivatives is not None: - self.use_derivatives = use_derivatives - reset_database = True - if use_fingerprint is not None: - self.use_fingerprint = use_fingerprint - reset_database = True - if round_targets is not None or not hasattr(self, "round_targets"): - self.round_targets = round_targets - if dtype is not None or not hasattr(self, "dtype"): - self.dtype = dtype + # Set the parameters in the parent class + super().update_arguments( + fingerprint=fingerprint, + reduce_dimensions=reduce_dimensions, + use_derivatives=use_derivatives, + use_fingerprint=use_fingerprint, + round_targets=round_targets, + seed=seed, + dtype=dtype, + **kwargs, + ) + # Set the number of points to use if npoints is not None: self.npoints = int(npoints) + # Set the initial indicies to keep fixed if initial_indicies is not None: self.initial_indicies = array(initial_indicies, dtype=int) + # Set the number of last points to include if include_last is not None: self.include_last = int(abs(include_last)) # Check that too many last points are not included n_extra = self.npoints - len(self.initial_indicies) if self.include_last > n_extra: self.include_last = n_extra if n_extra >= 0 else 0 - # Check that the database and the fingerprint have the same attributes - self.check_attributes() - # Reset the database if an argument has been changed - if reset_database: - self.reset_database() # Store that the data base has changed self.update_indicies = True return self @@ -309,6 +309,7 @@ def get_arguments(self): use_derivatives=self.use_derivatives, use_fingerprint=self.use_fingerprint, round_targets=self.round_targets, + seed=self.seed, dtype=self.dtype, npoints=self.npoints, initial_indicies=self.initial_indicies, @@ -334,6 +335,7 @@ def __init__( use_derivatives=True, use_fingerprint=True, round_targets=None, + seed=None, dtype=None, npoints=25, initial_indicies=[0], @@ -360,6 +362,10 @@ def __init__( round_targets: int (optional) The number of decimals to round the targets to. If None, the targets are not rounded. + seed: int (optional) + The random seed. + The seed an also be a RandomState or Generator instance. + If not given, the default random number generator is used. dtype: type The data type of the arrays. npoints : int @@ -376,6 +382,7 @@ def __init__( use_derivatives=use_derivatives, use_fingerprint=use_fingerprint, round_targets=round_targets, + seed=seed, dtype=dtype, npoints=npoints, initial_indicies=initial_indicies, @@ -419,6 +426,7 @@ def __init__( use_derivatives=True, use_fingerprint=True, round_targets=None, + seed=None, dtype=None, npoints=25, initial_indicies=[0], @@ -445,6 +453,10 @@ def __init__( round_targets: int (optional) The number of decimals to round the targets to. If None, the targets are not rounded. + seed: int (optional) + The random seed. + The seed an also be a RandomState or Generator instance. + If not given, the default random number generator is used. dtype: type The data type of the arrays. npoints : int @@ -461,6 +473,7 @@ def __init__( use_derivatives=use_derivatives, use_fingerprint=use_fingerprint, round_targets=round_targets, + seed=seed, dtype=dtype, npoints=npoints, initial_indicies=initial_indicies, @@ -496,6 +509,7 @@ def __init__( use_derivatives=True, use_fingerprint=True, round_targets=None, + seed=None, dtype=None, npoints=25, initial_indicies=[0], @@ -524,6 +538,10 @@ def __init__( round_targets: int (optional) The number of decimals to round the targets to. If None, the targets are not rounded. + seed: int (optional) + The random seed. + The seed an also be a RandomState or Generator instance. + If not given, the default random number generator is used. dtype: type The data type of the arrays. npoints : int @@ -542,6 +560,7 @@ def __init__( use_derivatives=use_derivatives, use_fingerprint=use_fingerprint, round_targets=round_targets, + seed=seed, dtype=dtype, npoints=npoints, initial_indicies=initial_indicies, @@ -557,6 +576,7 @@ def update_arguments( use_derivatives=None, use_fingerprint=None, round_targets=None, + seed=None, dtype=None, npoints=None, initial_indicies=None, @@ -582,6 +602,10 @@ def update_arguments( round_targets: int (optional) The number of decimals to round the targets to. If None, the targets are not rounded. + seed: int (optional) + The random seed. + The seed an also be a RandomState or Generator instance. + If not given, the default random number generator is used. dtype: type The data type of the arrays. npoints : int @@ -597,45 +621,25 @@ def update_arguments( Returns: self: The updated object itself. """ - # Control if the database has to be reset - reset_database = False - if fingerprint is not None: - self.fingerprint = fingerprint.copy() - reset_database = True - if reduce_dimensions is not None: - self.reduce_dimensions = reduce_dimensions - reset_database = True - if use_derivatives is not None: - self.use_derivatives = use_derivatives - reset_database = True - if use_fingerprint is not None: - self.use_fingerprint = use_fingerprint - reset_database = True - if round_targets is not None or not hasattr(self, "round_targets"): - self.round_targets = round_targets - if dtype is not None or not hasattr(self, "dtype"): - self.dtype = dtype - if npoints is not None: - self.npoints = int(npoints) - if initial_indicies is not None: - self.initial_indicies = array(initial_indicies, dtype=int) - if include_last is not None: - self.include_last = int(abs(include_last)) + # Set the parameters in the parent class + super().update_arguments( + fingerprint=fingerprint, + reduce_dimensions=reduce_dimensions, + use_derivatives=use_derivatives, + use_fingerprint=use_fingerprint, + round_targets=round_targets, + seed=seed, + dtype=dtype, + npoints=npoints, + initial_indicies=initial_indicies, + include_last=include_last, + **kwargs, + ) + # Set the random fraction if random_fraction is not None: self.random_fraction = int(abs(random_fraction)) if self.random_fraction == 0: self.random_fraction = 1 - # Check that too many last points are not included - n_extra = self.npoints - len(self.initial_indicies) - if self.include_last > n_extra: - self.include_last = n_extra if n_extra >= 0 else 0 - # Check that the database and the fingerprint have the same attributes - self.check_attributes() - # Reset the database if an argument has been changed - if reset_database: - self.reset_database() - # Store that the data base has changed - self.update_indicies = True return self def make_reduction(self, all_indicies, **kwargs): @@ -684,6 +688,7 @@ def get_arguments(self): use_derivatives=self.use_derivatives, use_fingerprint=self.use_fingerprint, round_targets=self.round_targets, + seed=self.seed, dtype=self.dtype, npoints=self.npoints, initial_indicies=self.initial_indicies, @@ -710,6 +715,7 @@ def __init__( use_derivatives=True, use_fingerprint=True, round_targets=None, + seed=None, dtype=None, npoints=25, initial_indicies=[0], @@ -737,6 +743,10 @@ def __init__( round_targets: int (optional) The number of decimals to round the targets to. If None, the targets are not rounded. + seed: int (optional) + The random seed. + The seed an also be a RandomState or Generator instance. + If not given, the default random number generator is used. dtype: type The data type of the arrays. npoints : int @@ -771,6 +781,7 @@ def update_arguments( use_derivatives=None, use_fingerprint=None, round_targets=None, + seed=None, dtype=None, npoints=None, initial_indicies=None, @@ -796,6 +807,10 @@ def update_arguments( round_targets: int (optional) The number of decimals to round the targets to. If None, the targets are not rounded. + seed: int (optional) + The random seed. + The seed an also be a RandomState or Generator instance. + If not given, the default random number generator is used. dtype: type The data type of the arrays. npoints : int @@ -812,43 +827,23 @@ def update_arguments( Returns: self: The updated object itself. """ - # Control if the database has to be reset - reset_database = False - if fingerprint is not None: - self.fingerprint = fingerprint.copy() - reset_database = True - if reduce_dimensions is not None: - self.reduce_dimensions = reduce_dimensions - reset_database = True - if use_derivatives is not None: - self.use_derivatives = use_derivatives - reset_database = True - if use_fingerprint is not None: - self.use_fingerprint = use_fingerprint - reset_database = True - if round_targets is not None or not hasattr(self, "round_targets"): - self.round_targets = round_targets - if dtype is not None or not hasattr(self, "dtype"): - self.dtype = dtype - if npoints is not None: - self.npoints = int(npoints) - if initial_indicies is not None: - self.initial_indicies = array(initial_indicies, dtype=int) - if include_last is not None: - self.include_last = int(abs(include_last)) + # Set the parameters in the parent class + super().update_arguments( + fingerprint=fingerprint, + reduce_dimensions=reduce_dimensions, + use_derivatives=use_derivatives, + use_fingerprint=use_fingerprint, + round_targets=round_targets, + seed=seed, + dtype=dtype, + npoints=npoints, + initial_indicies=initial_indicies, + include_last=include_last, + **kwargs, + ) + # Set the force targets if force_targets is not None: self.force_targets = force_targets - # Check that too many last points are not included - n_extra = self.npoints - len(self.initial_indicies) - if self.include_last > n_extra: - self.include_last = n_extra if n_extra >= 0 else 0 - # Check that the database and the fingerprint have the same attributes - self.check_attributes() - # Reset the database if an argument has been changed - if reset_database: - self.reset_database() - # Store that the data base has changed - self.update_indicies = True return self def make_reduction(self, all_indicies, **kwargs): @@ -887,6 +882,7 @@ def get_arguments(self): use_derivatives=self.use_derivatives, use_fingerprint=self.use_fingerprint, round_targets=self.round_targets, + seed=self.seed, dtype=self.dtype, npoints=self.npoints, initial_indicies=self.initial_indicies, @@ -913,6 +909,7 @@ def __init__( use_derivatives=True, use_fingerprint=True, round_targets=None, + seed=None, dtype=None, npoints=25, initial_indicies=[0], @@ -939,6 +936,10 @@ def __init__( round_targets: int (optional) The number of decimals to round the targets to. If None, the targets are not rounded. + seed: int (optional) + The random seed. + The seed an also be a RandomState or Generator instance. + If not given, the default random number generator is used. dtype: type The data type of the arrays. npoints : int @@ -955,6 +956,7 @@ def __init__( use_derivatives=use_derivatives, use_fingerprint=use_fingerprint, round_targets=round_targets, + seed=seed, dtype=dtype, npoints=npoints, initial_indicies=initial_indicies, @@ -984,6 +986,7 @@ def __init__( use_derivatives=True, use_fingerprint=True, round_targets=None, + seed=None, dtype=None, npoints=25, initial_indicies=[0], @@ -1011,6 +1014,10 @@ def __init__( round_targets: int (optional) The number of decimals to round the targets to. If None, the targets are not rounded. + seed: int (optional) + The random seed. + The seed an also be a RandomState or Generator instance. + If not given, the default random number generator is used. dtype: type The data type of the arrays. npoints : int @@ -1027,6 +1034,7 @@ def __init__( use_derivatives=use_derivatives, use_fingerprint=use_fingerprint, round_targets=round_targets, + seed=seed, dtype=dtype, npoints=npoints, initial_indicies=initial_indicies, @@ -1069,6 +1077,7 @@ def __init__( use_derivatives=True, use_fingerprint=True, round_targets=None, + seed=None, dtype=None, npoints=25, initial_indicies=[0], @@ -1121,6 +1130,7 @@ def __init__( use_derivatives=use_derivatives, use_fingerprint=use_fingerprint, round_targets=round_targets, + seed=seed, dtype=dtype, npoints=npoints, initial_indicies=initial_indicies, @@ -1216,6 +1226,7 @@ def update_arguments( use_derivatives=None, use_fingerprint=None, round_targets=None, + seed=None, dtype=None, npoints=None, initial_indicies=None, @@ -1242,6 +1253,10 @@ def update_arguments( round_targets: int (optional) The number of decimals to round the targets to. If None, the targets are not rounded. + seed: int (optional) + The random seed. + The seed an also be a RandomState or Generator instance. + If not given, the default random number generator is used. dtype: type The data type of the arrays. npoints : int @@ -1260,48 +1275,29 @@ def update_arguments( Returns: self: The updated object itself. """ - # Control if the database has to be reset - reset_database = False - if fingerprint is not None: - self.fingerprint = fingerprint.copy() - reset_database = True - if reduce_dimensions is not None: - self.reduce_dimensions = reduce_dimensions - reset_database = True - if use_derivatives is not None: - self.use_derivatives = use_derivatives - reset_database = True - if use_fingerprint is not None: - self.use_fingerprint = use_fingerprint - reset_database = True - if round_targets is not None or not hasattr(self, "round_targets"): - self.round_targets = round_targets - if dtype is not None or not hasattr(self, "dtype"): - self.dtype = dtype - if npoints is not None: - self.npoints = int(npoints) - if initial_indicies is not None: - self.initial_indicies = array(initial_indicies, dtype=int) - if include_last is not None: - self.include_last = int(abs(include_last)) + # Set the parameters in the parent class + super().update_arguments( + fingerprint=fingerprint, + reduce_dimensions=reduce_dimensions, + use_derivatives=use_derivatives, + use_fingerprint=use_fingerprint, + round_targets=round_targets, + seed=seed, + dtype=dtype, + npoints=npoints, + initial_indicies=initial_indicies, + include_last=include_last, + **kwargs, + ) + # Set the feature distance if feature_distance is not None: self.feature_distance = feature_distance + # Set the points of interest if point_interest is not None: self.point_interest = [atoms.copy() for atoms in point_interest] self.fp_interest = [ self.make_atoms_feature(atoms) for atoms in self.point_interest ] - # Check that too many last points are not included - n_extra = self.npoints - len(self.initial_indicies) - if self.include_last > n_extra: - self.include_last = n_extra if n_extra >= 0 else 0 - # Check that the database and the fingerprint have the same attributes - self.check_attributes() - # Reset the database if an argument has been changed - if reset_database: - self.reset_database() - # Store that the data base has changed - self.update_indicies = True return self def make_reduction(self, all_indicies, **kwargs): @@ -1340,6 +1336,7 @@ def get_arguments(self): use_derivatives=self.use_derivatives, use_fingerprint=self.use_fingerprint, round_targets=self.round_targets, + seed=self.seed, dtype=self.dtype, npoints=self.npoints, initial_indicies=self.initial_indicies, @@ -1367,6 +1364,7 @@ def __init__( use_derivatives=True, use_fingerprint=True, round_targets=None, + seed=None, dtype=None, npoints=25, initial_indicies=[0], @@ -1398,6 +1396,10 @@ def __init__( round_targets: int (optional) The number of decimals to round the targets to. If None, the targets are not rounded. + seed: int (optional) + The random seed. + The seed an also be a RandomState or Generator instance. + If not given, the default random number generator is used. dtype: type The data type of the arrays. npoints : int @@ -1419,6 +1421,7 @@ def __init__( use_derivatives=use_derivatives, use_fingerprint=use_fingerprint, round_targets=round_targets, + seed=seed, dtype=dtype, npoints=npoints, initial_indicies=initial_indicies, From f930b7b116cf8847d728e1ac04e6ecd7d25ddca4 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Tue, 15 Apr 2025 11:06:39 +0200 Subject: [PATCH 103/194] Remove kwargs in update_arguments --- catlearn/activelearning/acquisition.py | 21 +++++++++++++-------- catlearn/optimizer/adsorption.py | 1 - catlearn/optimizer/local.py | 1 - catlearn/optimizer/localneb.py | 10 ++-------- catlearn/optimizer/parallelopt.py | 1 - catlearn/optimizer/sequential.py | 1 - 6 files changed, 15 insertions(+), 20 deletions(-) diff --git a/catlearn/activelearning/acquisition.py b/catlearn/activelearning/acquisition.py index ed8c478d..13ada4a4 100644 --- a/catlearn/activelearning/acquisition.py +++ b/catlearn/activelearning/acquisition.py @@ -162,7 +162,6 @@ def update_arguments( super().update_arguments( objective=objective, seed=seed, - **kwargs, ) # Set the kappa value if kappa is not None: @@ -249,7 +248,6 @@ def update_arguments( super().update_arguments( objective=objective, seed=seed, - **kwargs, ) # Set the number of iterations if niter is not None: @@ -273,7 +271,11 @@ def get_arguments(self): class AcqUME(Acquisition): def __init__( - self, objective="max", seed=None, unc_convergence=0.05, **kwargs + self, + objective="max", + seed=None, + unc_convergence=0.05, + **kwargs, ): """ The predicted uncertainty when it is larger than unc_convergence @@ -295,13 +297,18 @@ def calculate(self, energy, uncertainty=None, **kwargs): return energy return self.objective_value(uncertainty) - def update_arguments(self, objective=None, unc_convergence=None, **kwargs): + def update_arguments( + self, + objective=None, + seed=None, + unc_convergence=None, + **kwargs, + ): "Set the parameters of the Acquisition function class." # Set the parameters in the parent class super().update_arguments( objective=objective, - seed=None, - **kwargs, + seed=seed, ) # Set the unc_convergence value if unc_convergence is not None: @@ -370,7 +377,6 @@ def update_arguments( super().update_arguments( objective=objective, seed=seed, - **kwargs, ) # Set the kappa value if kappa is not None: @@ -469,7 +475,6 @@ def update_arguments( super().update_arguments( objective=objective, seed=seed, - **kwargs, ) # Set the ebest value if ebest is not None or not hasattr(self, "ebest"): diff --git a/catlearn/optimizer/adsorption.py b/catlearn/optimizer/adsorption.py index 025b784d..31fed7c5 100644 --- a/catlearn/optimizer/adsorption.py +++ b/catlearn/optimizer/adsorption.py @@ -329,7 +329,6 @@ def update_arguments( comm=comm, verbose=verbose, seed=seed, - **kwargs, ) # Set the optimizer kwargs if opt_kwargs is not None: diff --git a/catlearn/optimizer/local.py b/catlearn/optimizer/local.py index fb74d8fa..2adb2f0c 100644 --- a/catlearn/optimizer/local.py +++ b/catlearn/optimizer/local.py @@ -222,7 +222,6 @@ def update_arguments( comm=comm, verbose=verbose, seed=seed, - **kwargs, ) # Set the local optimizer if local_opt is not None and local_opt_kwargs is not None: diff --git a/catlearn/optimizer/localneb.py b/catlearn/optimizer/localneb.py index c7923020..e5a848d5 100644 --- a/catlearn/optimizer/localneb.py +++ b/catlearn/optimizer/localneb.py @@ -153,19 +153,13 @@ def update_arguments( # Set the parameters in the parent class super().update_arguments( optimizable=neb, + local_opt=local_opt, + local_opt_kwargs=local_opt_kwargs, parallel_run=parallel_run, comm=comm, verbose=verbose, seed=seed, - **kwargs, ) - # Set the local optimizer - if local_opt is not None and local_opt_kwargs is not None: - self.setup_local_optimizer(local_opt, local_opt_kwargs) - elif local_opt is not None: - self.setup_local_optimizer(self.local_opt) - elif local_opt_kwargs is not None: - self.setup_local_optimizer(self.local_opt, local_opt_kwargs) return self def get_arguments(self): diff --git a/catlearn/optimizer/parallelopt.py b/catlearn/optimizer/parallelopt.py index 23eae039..5ae81d2f 100644 --- a/catlearn/optimizer/parallelopt.py +++ b/catlearn/optimizer/parallelopt.py @@ -216,7 +216,6 @@ def update_arguments( comm=comm, verbose=verbose, seed=seed, - **kwargs, ) # Set the chains if chains is not None: diff --git a/catlearn/optimizer/sequential.py b/catlearn/optimizer/sequential.py index 42866de5..dc05d51e 100644 --- a/catlearn/optimizer/sequential.py +++ b/catlearn/optimizer/sequential.py @@ -186,7 +186,6 @@ def update_arguments( comm=comm, verbose=verbose, seed=seed, - **kwargs, ) return self From bf30edde85e799f4ecaeed1fa5436564101e9970 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Thu, 15 May 2025 11:41:16 +0200 Subject: [PATCH 104/194] Minor change --- catlearn/optimizer/adsorption.py | 14 ++++++++------ catlearn/optimizer/local.py | 4 ++-- catlearn/optimizer/localcineb.py | 6 ++---- catlearn/optimizer/method.py | 19 +++++++++++++++---- catlearn/structures/neb/orgneb.py | 14 +++++++------- 5 files changed, 34 insertions(+), 23 deletions(-) diff --git a/catlearn/optimizer/adsorption.py b/catlearn/optimizer/adsorption.py index 31fed7c5..5a5ca5f6 100644 --- a/catlearn/optimizer/adsorption.py +++ b/catlearn/optimizer/adsorption.py @@ -4,7 +4,7 @@ import itertools from numpy import array, asarray, concatenate, cos, matmul, pi, sin from numpy.linalg import norm -import scipy +from scipy import __version__ as scipy_version from scipy.optimize import dual_annealing @@ -351,7 +351,7 @@ def update_arguments( def set_seed(self, seed=None): super().set_seed(seed) # Set the seed for the random number generator - if scipy.__version__ < "1.15": + if scipy_version < "1.15": self.opt_kwargs["seed"] = self.seed else: self.opt_kwargs["rng"] = self.rng @@ -393,16 +393,18 @@ def evaluate_value(self, x, **kwargs): # Get the positions pos = self.positions0.copy() # Calculate the positions of the adsorbate - pos_ads = pos[self.n_slab : self.n_slab + self.n_ads] + n_slab = self.n_slab + n_all = self.n_slab + self.n_ads + pos_ads = pos[n_slab:n_all] pos_ads = self.rotation_matrix(x[3:6], pos_ads) pos_ads += (self.cell * x[:3].reshape(-1, 1)).sum(axis=0) - pos[self.n_slab : self.n_slab + self.n_ads] = pos_ads + pos[n_slab:n_all] = pos_ads # Calculate the positions of the second adsorbate if self.n_ads2 > 0: - pos_ads2 = pos[self.n_slab + self.n_ads :] + pos_ads2 = pos[n_all:] pos_ads2 = self.rotation_matrix(x[9:12], pos_ads2) pos_ads2 += (self.cell * x[6:9].reshape(-1, 1)).sum(axis=0) - pos[self.n_slab + self.n_ads :] = pos_ads2 + pos[n_all:] = pos_ads2 # Set the positions self.optimizable.set_positions(pos) # Get the potential energy diff --git a/catlearn/optimizer/local.py b/catlearn/optimizer/local.py index 2adb2f0c..5e6534e9 100644 --- a/catlearn/optimizer/local.py +++ b/catlearn/optimizer/local.py @@ -1,5 +1,5 @@ from .method import OptimizerMethod -import ase +from ase import __version__ as ase_version from ase.parallel import world from ase.optimize import FIRE from numpy import isnan @@ -237,7 +237,7 @@ def run_max_unc_step(self, optimizer, fmax=0.05, **kwargs): Run a local optimization step. The ASE optimizer is dependent on the ASE version. """ - if ase.__version__ >= "3.23": + if ase_version >= "3.23": optimizer.run(fmax=fmax, steps=1, **kwargs) else: optimizer.run(fmax=fmax, steps=self.steps + 1, **kwargs) diff --git a/catlearn/optimizer/localcineb.py b/catlearn/optimizer/localcineb.py index a4cf16e6..bfc167c8 100644 --- a/catlearn/optimizer/localcineb.py +++ b/catlearn/optimizer/localcineb.py @@ -3,7 +3,7 @@ from ase.optimize import FIRE from .localneb import LocalNEB from .sequential import SequentialOptimizer -from ..structures.neb import ImprovedTangentNEB, make_interpolation +from ..structures.neb import EWNEB, ImprovedTangentNEB, make_interpolation class LocalCINEB(SequentialOptimizer): @@ -156,11 +156,9 @@ def setup_neb( if neb_method.lower() == "improvedtangentneb": neb_method = ImprovedTangentNEB elif neb_method.lower() == "ewneb": - from ..structures.neb.ewneb import EWNEB - neb_method = EWNEB else: - raise Exception( + raise ValueError( "The NEB method {} is not implemented.".format(neb_method) ) self.neb_method = neb_method diff --git a/catlearn/optimizer/method.py b/catlearn/optimizer/method.py index 55a693ed..19901987 100644 --- a/catlearn/optimizer/method.py +++ b/catlearn/optimizer/method.py @@ -36,7 +36,7 @@ def __init__( not (False). seed: int (optional) The random seed for the optimization. - The seed an also be a RandomState or Generator instance. + The seed can also be a RandomState or Generator instance. If not given, the default random number generator is used. """ # Set the parameters @@ -702,7 +702,7 @@ def update_arguments( not (False). seed: int (optional) The random seed for the optimization. - The seed an also be a RandomState or Generator instance. + The seed can also be a RandomState or Generator instance. If not given, the default random number generator is used. """ # Set the communicator @@ -729,8 +729,19 @@ def update_arguments( self.check_parallel() return self - def set_seed(self, seed=None): - "Set the random seed." + def set_seed(self, seed=None, **kwargs): + """ + Set the random seed. + + Parameters: + seed: int (optional) + The random seed. + The seed can be an integer, RandomState, or Generator instance. + If not given, the default random number generator is used. + + Returns: + self: The instance itself. + """ if seed is not None: self.seed = seed if isinstance(seed, int): diff --git a/catlearn/structures/neb/orgneb.py b/catlearn/structures/neb/orgneb.py index 1346cb69..c8d76108 100644 --- a/catlearn/structures/neb/orgneb.py +++ b/catlearn/structures/neb/orgneb.py @@ -102,10 +102,10 @@ def interpolate(self, method="linear", mic=True, **kwargs): Make an interpolation between the start and end structure. Parameters: - method : str + method: str The method used for performing the interpolation. The optional methods is {linear, idpp, ends}. - mic : bool + mic: bool Whether to use the minimum-image convention. Returns: @@ -142,14 +142,14 @@ def set_positions(self, positions, **kwargs): Set the positions of all the images in one array. Parameters: - positions : ((Nimg-2)*Natoms,3) array + positions: ((Nimg-2)*Natoms,3) array Coordinates of all atoms in all the moving images. """ self.reset() for i, image in enumerate(self.images[1:-1]): - image.set_positions( - positions[i * self.natoms : (i + 1) * self.natoms] - ) + posi = i * self.natoms + posip = (i + 1) * self.natoms + image.set_positions(positions[posi:posip]) pass def get_potential_energy(self, **kwargs): @@ -335,7 +335,7 @@ def set_calculator(self, calculators, copy_calc=False, **kwargs): Set the calculators for all the images. Parameters: - calculators : List of ASE Calculators or ASE Calculator + calculators: List of ASE Calculators or ASE Calculator The calculator used for all the images if a list is given. If a single calculator is given, it is used for all images. """ From 39fe58ec99a357bb658ed473442c7663d3df088a Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Thu, 15 May 2025 11:45:16 +0200 Subject: [PATCH 105/194] New fingerprint and baseline --- catlearn/regression/gp/baseline/__init__.py | 3 + catlearn/regression/gp/baseline/baseline.py | 132 ++- .../regression/gp/baseline/bornrepulsive.py | 225 ++++ catlearn/regression/gp/baseline/idpp.py | 81 +- catlearn/regression/gp/baseline/mie.py | 292 ++++-- catlearn/regression/gp/baseline/repulsive.py | 450 ++++++-- .../regression/gp/fingerprint/__init__.py | 10 +- .../regression/gp/fingerprint/cartesian.py | 24 +- .../regression/gp/fingerprint/distances.py | 578 +++++++++++ .../regression/gp/fingerprint/fingerprint.py | 159 ++- .../gp/fingerprint/fingerprintobject.py | 13 +- .../regression/gp/fingerprint/fpwrapper.py | 79 +- .../regression/gp/fingerprint/geometry.py | 968 ++++++++++++++---- .../regression/gp/fingerprint/invdistances.py | 407 ++++---- .../gp/fingerprint/invdistances2.py | 124 ++- .../gp/fingerprint/meandistances.py | 248 +++-- .../gp/fingerprint/meandistancespower.py | 338 +++--- .../gp/fingerprint/sorteddistances.py | 403 ++++++-- .../regression/gp/fingerprint/sumdistances.py | 597 +++++++++-- .../gp/fingerprint/sumdistancespower.py | 501 ++++++--- 20 files changed, 4256 insertions(+), 1376 deletions(-) create mode 100644 catlearn/regression/gp/baseline/bornrepulsive.py create mode 100644 catlearn/regression/gp/fingerprint/distances.py diff --git a/catlearn/regression/gp/baseline/__init__.py b/catlearn/regression/gp/baseline/__init__.py index 162a6538..1e04836a 100644 --- a/catlearn/regression/gp/baseline/__init__.py +++ b/catlearn/regression/gp/baseline/__init__.py @@ -1,10 +1,13 @@ from .baseline import BaselineCalculator +from .bornrepulsive import BornRepulsionCalculator from .idpp import IDPP from .mie import MieCalculator from .repulsive import RepulsionCalculator + __all__ = [ "BaselineCalculator", + "BornRepulsionCalculator", "IDPP", "MieCalculator", "RepulsionCalculator", diff --git a/catlearn/regression/gp/baseline/baseline.py b/catlearn/regression/gp/baseline/baseline.py index c0f7ef2d..d149ff05 100644 --- a/catlearn/regression/gp/baseline/baseline.py +++ b/catlearn/regression/gp/baseline/baseline.py @@ -1,6 +1,5 @@ -import numpy as np +from numpy import finfo, zeros from ase.calculators.calculator import Calculator, all_changes -from ase.constraints import FixAtoms class BaselineCalculator(Calculator): @@ -10,6 +9,8 @@ class BaselineCalculator(Calculator): def __init__( self, reduce_dimensions=True, + use_forces=True, + dtype=float, **kwargs, ): """ @@ -20,9 +21,19 @@ def __init__( reduce_dimensions: bool Whether to reduce the dimensions to only moving atoms if constrains are used. + use_forces: bool + Calculate and store the forces. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. """ super().__init__() - self.update_arguments(reduce_dimensions=reduce_dimensions, **kwargs) + self.update_arguments( + reduce_dimensions=reduce_dimensions, + use_forces=use_forces, + dtype=dtype, + **kwargs, + ) def calculate( self, @@ -39,72 +50,111 @@ def calculate( # Atoms object. Calculator.calculate(self, atoms, properties, system_changes) # Obtain energy and forces for the given structure: - if "forces" in properties: + if "forces" in properties or self.use_forces: energy, forces = self.get_energy_forces( atoms, - get_derivatives=True, + use_forces=True, ) self.results["forces"] = forces else: - energy = self.get_energy_forces(atoms, get_derivatives=False) + energy, _ = self.get_energy_forces(atoms, use_forces=False) self.results["energy"] = energy pass - def update_arguments(self, reduce_dimensions=None, **kwargs): + def set_use_forces(self, use_forces, **kwargs): """ - Update the class with its arguments. - The existing arguments are used if they are not given. + Set whether to use forces or not. + + Parameters: + use_forces: bool + Whether to use forces or not. + + Returns: + self: The updated object itself. + """ + # Set the use_forces + self.use_forces = use_forces + return self + + def set_reduce_dimensions(self, reduce_dimensions, **kwargs): + """ + Set whether to reduce the dimensions or not. Parameters: reduce_dimensions: bool - Whether to reduce the dimensions to only moving atoms - if constrains are used. + Whether to reduce the dimensions or not. + Returns: self: The updated object itself. """ - if reduce_dimensions is not None: - self.reduce_dimensions = reduce_dimensions + # Set the reduce_dimensions + self.reduce_dimensions = reduce_dimensions return self - def get_energy_forces(self, atoms, get_derivatives=True, **kwargs): - "Get the energy and forces." - if get_derivatives: - return 0.0, np.zeros((len(atoms), 3)) - return 0.0 + def set_dtype(self, dtype, **kwargs): + """ + Set the data type of the arrays. + + Parameters: + dtype: type + The data type of the arrays. + If None, the default data type is used. - def get_constraints(self, atoms, **kwargs): + Returns: + self: The updated object itself. + """ + # Set the dtype + self.dtype = dtype + # Set a small number to avoid division by zero + self.eps = finfo(self.dtype).eps + return self + + def update_arguments( + self, + reduce_dimensions=None, + use_forces=None, + dtype=None, + **kwargs, + ): """ - Get the indicies of the atoms that does not have fixed constraints. + Update the class with its arguments. + The existing arguments are used if they are not given. Parameters: - atoms : ASE Atoms - The ASE Atoms object with a calculator. + reduce_dimensions: bool + Whether to reduce the dimensions to only moving atoms + if constrains are used. + use_forces: bool + Calculate and store the forces. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. Returns: - not_masked : list - A list of indicies for the moving atoms - if constraints are used. - masked : list - A list of indicies for the fixed atoms - if constraints are used. + self: The updated object itself. """ - not_masked = list(range(len(atoms))) - if not self.reduce_dimensions: - return not_masked, [] - constraints = atoms.constraints - if len(constraints): - masked = [ - c.get_indices() for c in constraints if isinstance(c, FixAtoms) - ] - if len(masked): - masked = set(np.concatenate(masked)) - return list(set(not_masked).difference(masked)), list(masked) - return not_masked, [] + if reduce_dimensions is not None: + self.set_reduce_dimensions(reduce_dimensions=reduce_dimensions) + if use_forces is not None: + self.set_use_forces(use_forces=use_forces) + if dtype is not None or not hasattr(self, "dtype"): + self.set_dtype(dtype=dtype) + return self + + def get_energy_forces(self, atoms, use_forces=True, **kwargs): + "Get the energy and forces." + if use_forces: + return 0.0, zeros((len(atoms), 3), dtype=self.dtype) + return 0.0, None def get_arguments(self): "Get the arguments of the class itself." # Get the arguments given to the class in the initialization - arg_kwargs = dict(reduce_dimensions=self.reduce_dimensions) + arg_kwargs = dict( + reduce_dimensions=self.reduce_dimensions, + use_forces=self.use_forces, + dtype=self.dtype, + ) # Get the constants made within the class constant_kwargs = dict() # Get the objects made within the class diff --git a/catlearn/regression/gp/baseline/bornrepulsive.py b/catlearn/regression/gp/baseline/bornrepulsive.py new file mode 100644 index 00000000..c3ff084d --- /dev/null +++ b/catlearn/regression/gp/baseline/bornrepulsive.py @@ -0,0 +1,225 @@ +from numpy import where +from .repulsive import RepulsionCalculator + + +class BornRepulsionCalculator(RepulsionCalculator): + implemented_properties = ["energy", "forces"] + nolabel = True + + def __init__( + self, + reduce_dimensions=True, + use_forces=True, + wrap=True, + include_ncells=True, + mic=False, + all_ncells=True, + cell_cutoff=2.0, + r_scale=0.8, + power=2, + rs1_cross=0.9, + k_scale=1.0, + dtype=float, + **kwargs, + ): + """ + A baseline calculator for ASE atoms object. + It uses a repulsive Lennard-Jones potential baseline. + The power and the scaling of the repulsive Lennard-Jones potential + can be selected. + + Parameters: + reduce_dimensions: bool + Whether to reduce the fingerprint space if constrains are used. + use_forces: bool + Calculate and store the forces. + wrap: bool + Whether to wrap the atoms to the unit cell or not. + include_ncells: bool + Include the neighboring cells when calculating the distances. + The distances will include the neighboring cells. + include_ncells will replace mic. + mic: bool + Minimum Image Convention (Shortest distances when + periodic boundary conditions are used). + Either use mic or include_ncells. + all_ncells: bool + Use all neighboring cells when calculating the distances. + cell_cutoff is used to check how many neighboring cells are + needed. + cell_cutoff: float + The cutoff distance for the neighboring cells. + It is the scaling of the maximum covalent distance. + r_scale: float + The scaling of the covalent radii. + A smaller value will move the repulsion to a lower distances. + All distances larger than r_scale is cutoff. + power: int + The power of the repulsion. + rs1_cross: float + The scaled value of the inverse distance with scaling (r_scale) + that crosses the energy of 1 eV. + k_scale: float + The scaling of the repulsion energy after a default scaling + of the energy is calculated. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. + """ + super().__init__( + reduce_dimensions=reduce_dimensions, + use_forces=use_forces, + wrap=wrap, + include_ncells=include_ncells, + mic=mic, + all_ncells=all_ncells, + cell_cutoff=cell_cutoff, + r_scale=r_scale, + power=power, + rs1_cross=rs1_cross, + k_scale=k_scale, + dtype=dtype, + **kwargs, + ) + + def update_arguments( + self, + reduce_dimensions=None, + use_forces=None, + wrap=None, + include_ncells=None, + mic=None, + all_ncells=None, + cell_cutoff=None, + r_scale=None, + power=None, + rs1_cross=None, + k_scale=None, + dtype=None, + **kwargs, + ): + """ + Update the class with its arguments. + The existing arguments are used if they are not given. + + Parameters: + reduce_dimensions: bool + Whether to reduce the fingerprint space if constrains are used. + use_forces: bool + Calculate and store the forces. + wrap: bool + Whether to wrap the atoms to the unit cell or not. + include_ncells: bool + Include the neighboring cells when calculating the distances. + The distances will include the neighboring cells. + include_ncells will replace mic. + mic: bool + Minimum Image Convention (Shortest distances when + periodic boundary conditions are used). + Either use mic or include_ncells. + all_ncells: bool + Use all neighboring cells when calculating the distances. + cell_cutoff is used to check how many neighboring cells are + needed. + cell_cutoff: float + The cutoff distance for the neighboring cells. + It is the scaling of the maximum covalent distance. + r_scale: float + The scaling of the covalent radii. + A smaller value will move the repulsion to a lower distances. + All distances larger than r_scale is cutoff. + power: int + The power of the repulsion. + rs1_cross: float + The scaled value of the inverse distance with scaling (r_scale) + that crosses the energy of 1 eV. + k_scale: float + The scaling of the repulsion energy after a default scaling + of the energy is calculated. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. + + Returns: + self: The updated object itself. + """ + # Set the arguments + if rs1_cross is not None: + self.rs1_cross = abs(float(rs1_cross)) + if k_scale is not None: + self.k_scale = abs(float(k_scale)) + # Update the arguments of the parent class + super().update_arguments( + reduce_dimensions=reduce_dimensions, + use_forces=use_forces, + wrap=wrap, + include_ncells=include_ncells, + mic=mic, + all_ncells=all_ncells, + cell_cutoff=cell_cutoff, + use_cutoff=False, + rs_cutoff=None, + re_cutoff=None, + r_scale=r_scale, + power=power, + dtype=dtype, + ) + return self + + def set_normalization_constant(self, **kwargs): + # Calculate the normalization + self.c0 = self.k_scale / ((1.0 / self.rs1_cross - 1.0) ** self.power) + self.c0p = -self.c0 * self.power * self.r_scale + return self + + def get_inv_dis( + self, + atoms, + not_masked, + i_nm, + use_forces, + use_vector, + use_include_ncells, + use_mic, + **kwargs, + ): + # Calculate the inverse distances + inv_dist, deriv = super().get_inv_dis( + atoms=atoms, + not_masked=not_masked, + i_nm=i_nm, + use_forces=use_forces, + use_vector=use_vector, + use_include_ncells=use_include_ncells, + use_mic=use_mic, + **kwargs, + ) + # Calculate the scaled inverse distances + inv_dist = self.r_scale * inv_dist - 1.0 + # Use only the repulsive part + inv_dist = where(inv_dist < 0.0, 0.0, inv_dist) + return inv_dist, deriv + + def get_arguments(self): + "Get the arguments of the class itself." + # Get the arguments given to the class in the initialization + arg_kwargs = dict( + reduce_dimensions=self.reduce_dimensions, + use_forces=self.use_forces, + wrap=self.wrap, + include_ncells=self.include_ncells, + mic=self.mic, + all_ncells=self.all_ncells, + cell_cutoff=self.cell_cutoff, + use_cutoff=self.use_cutoff, + r_scale=self.r_scale, + power=self.power, + dtype=self.dtype, + rs1_cross=self.rs1_cross, + k_scale=self.k_scale, + ) + # Get the constants made within the class + constant_kwargs = dict() + # Get the objects made within the class + object_kwargs = dict() + return arg_kwargs, constant_kwargs, object_kwargs diff --git a/catlearn/regression/gp/baseline/idpp.py b/catlearn/regression/gp/baseline/idpp.py index e9eb51d5..b1c9a1fe 100644 --- a/catlearn/regression/gp/baseline/idpp.py +++ b/catlearn/regression/gp/baseline/idpp.py @@ -1,4 +1,3 @@ -import numpy as np from .baseline import BaselineCalculator from ..fingerprint.geometry import get_full_distance_matrix @@ -8,7 +7,10 @@ class IDPP(BaselineCalculator): def __init__( self, target=[], + wrap=False, mic=False, + use_forces=True, + dtype=None, **kwargs, ): """ @@ -18,26 +20,38 @@ def __init__( Parameters: target: array The target distances for the IDPP. - mic : bool + wrap: bool + Whether to wrap the atoms to the unit cell or not. + mic: bool Minimum Image Convention (Shortest distances when periodic boundary conditions are used). + use_forces: bool + Calculate and store the forces. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. See: Improved initial guess for minimum energy path calculations. Søren Smidstrup, Andreas Pedersen, Kurt Stokbro and Hannes Jónsson Chem. Phys. 140, 214106 (2014) """ - super().__init__() - self.update_arguments( + super().__init__( + reduce_dimensions=False, target=target, + wrap=wrap, mic=mic, - **kwargs, + use_forces=use_forces, + dtype=dtype, ) def update_arguments( self, target=None, + wrap=None, mic=None, + use_forces=None, + dtype=None, **kwargs, ): """ @@ -47,28 +61,38 @@ def update_arguments( Parameters: target: array The target distances for the IDPP. - mic : bool + wrap: bool + Whether to wrap the atoms to the unit cell or not. + mic: bool Minimum Image Convention (Shortest distances when periodic boundary conditions are used). + use_forces: bool + Calculate and store the forces. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. Returns: self: The updated object itself. """ + super().update_arguments( + use_forces=use_forces, + dtype=dtype, + ) if target is not None: self.target = target.copy() + if wrap is not None: + self.wrap = wrap if mic is not None: self.mic = mic return self - def get_energy_forces(self, atoms, get_derivatives=True, **kwargs): + def get_energy_forces(self, atoms, use_forces=True, **kwargs): "Get the energy and forces." # Get all distances - dis, dis_vec = get_full_distance_matrix( - atoms, - not_masked=None, - mic=self.mic, - vector=get_derivatives, - wrap=False, + dis, dis_vec = self.get_distances( + atoms=atoms, + use_vector=use_forces, ) # Get the number of atoms n_atoms = len(atoms) @@ -79,22 +103,45 @@ def get_energy_forces(self, atoms, get_derivatives=True, **kwargs): # Calculate the energy dis_t = dis - self.target dis_t2 = dis_t**2 - e = 0.5 * np.sum(weights * dis_t2) - if get_derivatives: + e = 0.5 * (weights * dis_t2).sum() + if use_forces: # Calculate the forces finner = 2.0 * (weights / dis_non) * dis_t2 finner -= weights * dis_t finner = finner / dis_non - f = np.sum(dis_vec * finner[:, :, None], axis=0) + f = (dis_vec * finner[:, :, None]).sum(axis=0) return e, f - return e + return e, None + + def get_distances( + self, + atoms, + use_vector, + **kwargs, + ): + "Calculate the distances." + dist, dist_vec = get_full_distance_matrix( + atoms=atoms, + not_masked=None, + use_vector=use_vector, + wrap=self.wrap, + include_ncells=False, + all_ncells=False, + mic=self.mic, + dtype=self.dtype, + **kwargs, + ) + return dist, dist_vec def get_arguments(self): "Get the arguments of the class itself." # Get the arguments given to the class in the initialization arg_kwargs = dict( target=self.target, + wrap=self.wrap, mic=self.mic, + use_forces=self.use_forces, + dtype=self.dtype, ) # Get the constants made within the class constant_kwargs = dict() diff --git a/catlearn/regression/gp/baseline/mie.py b/catlearn/regression/gp/baseline/mie.py index b8e07c32..dee50f68 100644 --- a/catlearn/regression/gp/baseline/mie.py +++ b/catlearn/regression/gp/baseline/mie.py @@ -1,4 +1,4 @@ -import numpy as np +from numpy import einsum from .repulsive import RepulsionCalculator @@ -9,14 +9,20 @@ class MieCalculator(RepulsionCalculator): def __init__( self, reduce_dimensions=True, - r_scale=1.0, + use_forces=True, + wrap=True, + include_ncells=True, + mic=False, + all_ncells=True, + cell_cutoff=4.0, + use_cutoff=False, + rs_cutoff=3.0, + re_cutoff=4.0, + r_scale=0.7, denergy=0.1, power_r=8, power_a=6, - periodic_softmax=True, - mic=False, - wrap=True, - eps=1e-16, + dtype=None, **kwargs, ): """ @@ -26,55 +32,84 @@ def __init__( Parameters: reduce_dimensions: bool - Whether to reduce the dimensions to only moving atoms - if constrains are used. - r_scale : float + Whether to reduce the fingerprint space if constrains are used. + use_forces: bool + Calculate and store the forces. + wrap: bool + Whether to wrap the atoms to the unit cell or not. + include_ncells: bool + Include the neighboring cells when calculating the distances. + The distances will include the neighboring cells. + include_ncells will replace mic. + mic: bool + Minimum Image Convention (Shortest distances when + periodic boundary conditions are used). + Either use mic or include_ncells. + all_ncells: bool + Use all neighboring cells when calculating the distances. + cell_cutoff is used to check how many neighboring cells are + needed. + cell_cutoff: float + The cutoff distance for the neighboring cells. + It is the scaling of the maximum covalent distance. + use_cutoff: bool + Whether to use a cutoff function for the inverse distance + fingerprint. + The cutoff function is a cosine cutoff function. + rs_cutoff: float + The starting distance for the cutoff function being 1. + re_cutoff: float + The ending distance for the cutoff function being 0. + re_cutoff must be larger than rs_cutoff. + r_scale: float The scaling of the covalent radii. - A smaller value will move the potential to a lower distances. + A smaller value will move the repulsion to a lower distances. denergy : float The dispersion energy of the potential. power_r : int - The power of the potential part. + The power of the repulsive part. power_a : int - The power of the attraction part. - periodic_softmax : bool - Use a softmax weighting of the squared distances - when periodic boundary conditions are used. - mic : bool - Minimum Image Convention (Shortest distances - when periodic boundary conditions are used). - Either use mic or periodic_softmax, not both. - mic is faster than periodic_softmax, - but the derivatives are discontinuous. - wrap: bool - Whether to wrap the atoms to the unit cell or not. - eps : float - Small number to avoid division by zero. + The power of the attractive part. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. """ super().__init__( reduce_dimensions=reduce_dimensions, + use_forces=use_forces, + wrap=wrap, + include_ncells=include_ncells, + mic=mic, + all_ncells=all_ncells, + cell_cutoff=cell_cutoff, + use_cutoff=use_cutoff, + rs_cutoff=rs_cutoff, + re_cutoff=re_cutoff, r_scale=r_scale, denergy=denergy, power_a=power_a, power_r=power_r, - periodic_softmax=periodic_softmax, - mic=mic, - wrap=wrap, - eps=eps, + dtype=dtype, **kwargs, ) def update_arguments( self, reduce_dimensions=None, + use_forces=None, + wrap=None, + include_ncells=None, + mic=None, + all_ncells=None, + cell_cutoff=None, + use_cutoff=None, + rs_cutoff=None, + re_cutoff=None, r_scale=None, denergy=None, power_r=None, power_a=None, - periodic_softmax=None, - mic=None, - wrap=None, - eps=None, + dtype=None, **kwargs, ): """ @@ -83,106 +118,163 @@ def update_arguments( Parameters: reduce_dimensions: bool - Whether to reduce the dimensions to only moving atoms - if constrains are used. - r_scale : float + Whether to reduce the fingerprint space if constrains are used. + use_forces: bool + Calculate and store the forces. + wrap: bool + Whether to wrap the atoms to the unit cell or not. + include_ncells: bool + Include the neighboring cells when calculating the distances. + The distances will include the neighboring cells. + include_ncells will replace mic. + mic: bool + Minimum Image Convention (Shortest distances when + periodic boundary conditions are used). + Either use mic or include_ncells. + all_ncells: bool + Use all neighboring cells when calculating the distances. + cell_cutoff is used to check how many neighboring cells are + needed. + cell_cutoff: float + The cutoff distance for the neighboring cells. + It is the scaling of the maximum covalent distance. + use_cutoff: bool + Whether to use a cutoff function for the inverse distance + fingerprint. + The cutoff function is a cosine cutoff function. + rs_cutoff: float + The starting distance for the cutoff function being 1. + re_cutoff: float + The ending distance for the cutoff function being 0. + re_cutoff must be larger than rs_cutoff. + r_scale: float The scaling of the covalent radii. - A smaller value will move the potential to a lower distances. + A smaller value will move the repulsion to a lower distances. denergy : float The dispersion energy of the potential. power_r : int - The power of the potential part. + The power of the repulsive part. power_a : int - The power of the attraction part. - periodic_softmax : bool - Use a softmax weighting of the squared distances - when periodic boundary conditions are used. - mic : bool - Minimum Image Convention (Shortest distances - when periodic boundary conditions are used). - Either use mic or periodic_softmax, not both. - mic is faster than periodic_softmax, - but the derivatives are discontinuous. - wrap: bool - Whether to wrap the atoms to the unit cell or not. - eps : float - Small number to avoid division by zero. + The power of the attractive part. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. Returns: self: The updated object itself. """ - if reduce_dimensions is not None: - self.reduce_dimensions = reduce_dimensions - if r_scale is not None: - self.r_scale = float(r_scale) + # Update the arguments of the class if denergy is not None: self.denergy = float(denergy) if power_r is not None: self.power_r = int(power_r) if power_a is not None: self.power_a = int(power_a) - if periodic_softmax is not None: - self.periodic_softmax = periodic_softmax - if mic is not None: - self.mic = mic - if wrap is not None: - self.wrap = wrap - if eps is not None: - self.eps = abs(float(eps)) + # Update the arguments of the parent class + super().update_arguments( + reduce_dimensions=reduce_dimensions, + use_forces=use_forces, + wrap=wrap, + include_ncells=include_ncells, + mic=mic, + all_ncells=all_ncells, + cell_cutoff=cell_cutoff, + use_cutoff=use_cutoff, + rs_cutoff=rs_cutoff, + re_cutoff=re_cutoff, + r_scale=r_scale, + power=None, + dtype=dtype, + ) + return self + + def set_normalization_constant(self, **kwargs): # Calculate the normalization power_ar = self.power_a / (self.power_r - self.power_a) - c0 = self.denergy * ( - ((self.power_r / self.power_a) ** power_ar) - * (self.power_r / (self.power_r - self.power_a)) - ) + c0 = self.denergy * ((self.power_r / self.power_a) ** power_ar) + c0 = c0 * (self.power_r / (self.power_r - self.power_a)) # Calculate the r_scale powers self.r_scale_r = c0 * (self.r_scale**self.power_r) self.r_scale_a = c0 * (self.r_scale**self.power_a) + self.power_ar = -self.power_a * self.r_scale_a + self.power_rr = -self.power_r * self.r_scale_r return self - def get_energy_forces(self, atoms, get_derivatives=True, **kwargs): - "Get the energy and forces." - # Get the not fixed (not masked) atom indicies - not_masked, masked = self.get_constraints(atoms) - not_masked = np.array(not_masked, dtype=int) - masked = np.array(masked, dtype=int) - # Get the inverse distances - f, g = self.get_inv_distances( - atoms, - not_masked, - masked, - get_derivatives, - **kwargs, - ) - # Calculate energy - energy = (self.r_scale_r * np.sum(f**self.power_r)) - ( - self.r_scale_a * np.sum(f**self.power_a) - ) - if get_derivatives: - forces = np.zeros((len(atoms), 3)) - power_ar = self.power_a * self.r_scale_a - power_rr = self.power_r * self.r_scale_r - inner = (power_ar * (f ** (self.power_a - 1))) - ( - power_rr * (f ** (self.power_r - 1)) - ) - derivs = np.sum(inner.reshape(-1, 1) * g, axis=0) - forces[not_masked] = derivs.reshape(-1, 3) - return energy, forces + def calc_energy( + self, + inv_dist, + not_masked, + i_nm, + use_include_ncells, + **kwargs, + ): + "Calculate the energy." + # Get the repulsive part + if use_include_ncells: + inv_dist_p = (inv_dist**self.power_r).sum(axis=0) + else: + inv_dist_p = inv_dist**self.power_r + # Take double countings into account + inv_dist_p[i_nm, not_masked] *= 2.0 + inv_dist_p[:, not_masked] *= 0.5 + energy = self.r_scale_r * inv_dist_p.sum() + # Get the attractive part + if use_include_ncells: + inv_dist_p = (inv_dist**self.power_a).sum(axis=0) + else: + inv_dist_p = inv_dist**self.power_a + # Take double countings into account + inv_dist_p[i_nm, not_masked] *= 2.0 + inv_dist_p[:, not_masked] *= 0.5 + energy -= self.r_scale_a * inv_dist_p.sum() return energy + def calc_forces( + self, + inv_dist, + deriv, + not_masked, + i_nm, + use_include_ncells=False, + **kwargs, + ): + "Calculate the forces." + # Calculate the derivative of the repulsive energy + inv_dist_p = inv_dist ** (self.power_r - 1) + # Calculate the forces + if use_include_ncells: + forces = einsum("dijc,dij->ic", deriv, inv_dist_p) + else: + forces = einsum("ijc,ij->ic", deriv, inv_dist_p) + forces *= self.power_rr + # Calculate the derivative of the attractive energy + inv_dist_p = inv_dist ** (self.power_a - 1) + # Calculate the forces + if use_include_ncells: + forces -= einsum("dijc,dij->ic", deriv, inv_dist_p) + else: + forces -= self.power_ar * einsum("ijc,ij->ic", deriv, inv_dist_p) + return forces + def get_arguments(self): "Get the arguments of the class itself." # Get the arguments given to the class in the initialization arg_kwargs = dict( reduce_dimensions=self.reduce_dimensions, + use_forces=self.use_forces, + wrap=self.wrap, + include_ncells=self.include_ncells, + mic=self.mic, + all_ncells=self.all_ncells, + cell_cutoff=self.cell_cutoff, + use_cutoff=self.use_cutoff, + rs_cutoff=self.rs_cutoff, + re_cutoff=self.re_cutoff, r_scale=self.r_scale, denergy=self.denergy, power_a=self.power_a, power_r=self.power_r, - periodic_softmax=self.periodic_softmax, - mic=self.mic, - wrap=self.wrap, - eps=self.eps, + dtype=self.dtype, ) # Get the constants made within the class constant_kwargs = dict() diff --git a/catlearn/regression/gp/baseline/repulsive.py b/catlearn/regression/gp/baseline/repulsive.py index 0ac9cf4c..94dc669a 100644 --- a/catlearn/regression/gp/baseline/repulsive.py +++ b/catlearn/regression/gp/baseline/repulsive.py @@ -1,6 +1,11 @@ -import numpy as np +from numpy import arange, asarray, einsum, zeros +from ase.data import covalent_radii from .baseline import BaselineCalculator -from ..fingerprint.geometry import get_inverse_distances +from ..fingerprint.geometry import ( + get_constraints, + get_full_distance_matrix, + cosine_cutoff, +) class RepulsionCalculator(BaselineCalculator): @@ -10,12 +15,18 @@ class RepulsionCalculator(BaselineCalculator): def __init__( self, reduce_dimensions=True, - r_scale=0.7, - power=12, - periodic_softmax=True, - mic=False, + use_forces=True, wrap=True, - eps=1e-16, + include_ncells=True, + mic=False, + all_ncells=True, + cell_cutoff=4.0, + use_cutoff=False, + rs_cutoff=3.0, + re_cutoff=4.0, + r_scale=0.7, + power=10, + dtype=float, **kwargs, ): """ @@ -26,47 +37,76 @@ def __init__( Parameters: reduce_dimensions: bool - Whether to reduce the dimensions to only moving atoms - if constrains are used. - r_scale : float + Whether to reduce the fingerprint space if constrains are used. + use_forces: bool + Calculate and store the forces. + wrap: bool + Whether to wrap the atoms to the unit cell or not. + include_ncells: bool + Include the neighboring cells when calculating the distances. + The distances will include the neighboring cells. + include_ncells will replace mic. + mic: bool + Minimum Image Convention (Shortest distances when + periodic boundary conditions are used). + Either use mic or include_ncells. + all_ncells: bool + Use all neighboring cells when calculating the distances. + cell_cutoff is used to check how many neighboring cells are + needed. + cell_cutoff: float + The cutoff distance for the neighboring cells. + It is the scaling of the maximum covalent distance. + use_cutoff: bool + Whether to use a cutoff function for the inverse distance + fingerprint. + The cutoff function is a cosine cutoff function. + rs_cutoff: float + The starting distance for the cutoff function being 1. + re_cutoff: float + The ending distance for the cutoff function being 0. + re_cutoff must be larger than rs_cutoff. + r_scale: float The scaling of the covalent radii. A smaller value will move the repulsion to a lower distances. - power : int + power: int The power of the repulsion. - periodic_softmax : bool - Use a softmax weighting of the squared distances - when periodic boundary conditions are used. - mic : bool - Minimum Image Convention (Shortest distances - when periodic boundary conditions are used). - Either use mic or periodic_softmax, not both. - mic is faster than periodic_softmax, - but the derivatives are discontinuous. - wrap: bool - Whether to wrap the atoms to the unit cell or not. - eps : float - Small number to avoid division by zero. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. """ super().__init__( reduce_dimensions=reduce_dimensions, + use_forces=use_forces, + wrap=wrap, + include_ncells=include_ncells, + mic=mic, + all_ncells=all_ncells, + cell_cutoff=cell_cutoff, + use_cutoff=use_cutoff, + rs_cutoff=rs_cutoff, + re_cutoff=re_cutoff, r_scale=r_scale, power=power, - periodic_softmax=periodic_softmax, - mic=mic, - wrap=wrap, - eps=eps, + dtype=dtype, **kwargs, ) def update_arguments( self, reduce_dimensions=None, + use_forces=None, + wrap=None, + include_ncells=None, + mic=None, + all_ncells=None, + cell_cutoff=None, + use_cutoff=None, + rs_cutoff=None, + re_cutoff=None, r_scale=None, power=None, - periodic_softmax=None, - mic=None, - wrap=None, - eps=None, + dtype=None, **kwargs, ): """ @@ -75,115 +115,321 @@ def update_arguments( Parameters: reduce_dimensions: bool - Whether to reduce the dimensions to only moving atoms - if constrains are used. - r_scale : float + Whether to reduce the fingerprint space if constrains are used. + use_forces: bool + Calculate and store the forces. + wrap: bool + Whether to wrap the atoms to the unit cell or not. + include_ncells: bool + Include the neighboring cells when calculating the distances. + The distances will include the neighboring cells. + include_ncells will replace mic. + mic: bool + Minimum Image Convention (Shortest distances when + periodic boundary conditions are used). + Either use mic or include_ncells. + all_ncells: bool + Use all neighboring cells when calculating the distances. + cell_cutoff is used to check how many neighboring cells are + needed. + cell_cutoff: float + The cutoff distance for the neighboring cells. + It is the scaling of the maximum covalent distance. + use_cutoff: bool + Whether to use a cutoff function for the inverse distance + fingerprint. + The cutoff function is a cosine cutoff function. + rs_cutoff: float + The starting distance for the cutoff function being 1. + re_cutoff: float + The ending distance for the cutoff function being 0. + re_cutoff must be larger than rs_cutoff. + r_scale: float The scaling of the covalent radii. A smaller value will move the repulsion to a lower distances. - power : int + power: int The power of the repulsion. - periodic_softmax : bool - Use a softmax weighting of the squared distances - when periodic boundary conditions are used. - mic : bool - Minimum Image Convention (Shortest distances - when periodic boundary conditions are used). - Either use mic or periodic_softmax, not both. - mic is faster than periodic_softmax, - but the derivatives are discontinuous. - wrap: bool - Whether to wrap the atoms to the unit cell or not. - eps : float - Small number to avoid division by zero. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. Returns: self: The updated object itself. """ - if reduce_dimensions is not None: - self.reduce_dimensions = reduce_dimensions + super().update_arguments( + reduce_dimensions=reduce_dimensions, + use_forces=use_forces, + dtype=dtype, + ) + if wrap is not None: + self.wrap = wrap + if include_ncells is not None: + self.include_ncells = include_ncells + if mic is not None: + self.mic = mic + if all_ncells is not None: + self.all_ncells = all_ncells + if cell_cutoff is not None: + self.cell_cutoff = abs(float(cell_cutoff)) + if use_cutoff is not None: + self.use_cutoff = use_cutoff + if rs_cutoff is not None: + self.rs_cutoff = abs(float(rs_cutoff)) + if re_cutoff is not None: + self.re_cutoff = abs(float(re_cutoff)) if r_scale is not None: - self.r_scale = r_scale + self.r_scale = abs(float(r_scale)) if power is not None: self.power = int(power) - if periodic_softmax is not None: - self.periodic_softmax = periodic_softmax - if mic is not None: - self.mic = mic - if wrap is not None: - self.wrap = wrap - if eps is not None: - self.eps = abs(float(eps)) + # Calculate the normalization + self.set_normalization_constant() + return self + + def set_normalization_constant(self, **kwargs): + "Set the normalization constant." # Calculate the normalization self.c0 = self.r_scale**self.power + self.c0p = -self.c0 * self.power return self - def get_energy_forces(self, atoms, get_derivatives=True, **kwargs): + def get_energy_forces(self, atoms, use_forces=True, **kwargs): "Get the energy and forces." # Get the not fixed (not masked) atom indicies - not_masked, masked = self.get_constraints(atoms) - not_masked = np.array(not_masked, dtype=int) - masked = np.array(masked, dtype=int) - # Get the inverse distances - f, g = self.get_inv_distances( + not_masked, _ = get_constraints( atoms, - not_masked, - masked, - get_derivatives, + reduce_dimensions=self.reduce_dimensions, + ) + i_nm = arange(len(not_masked)) + # Check what distance method should be used + ( + use_vector, + use_include_ncells, + use_mic, + ) = self.use_dis_method(pbc=atoms.pbc, use_forces=use_forces, **kwargs) + # Calculate the inverse distances and their derivatives + inv_dist, deriv = self.get_inv_dis( + atoms=atoms, + not_masked=not_masked, + i_nm=i_nm, + use_forces=use_forces, + use_vector=use_vector, + use_include_ncells=use_include_ncells, + use_mic=use_mic, **kwargs, ) # Calculate energy - energy = self.c0 * np.sum(f**self.power) - if get_derivatives: - forces = np.zeros((len(atoms), 3), dtype=float) - c0p = -self.c0 * self.power - derivs = np.sum( - c0p * (f ** (self.power - 1)).reshape(-1, 1) * g, - axis=0, + energy = self.calc_energy( + inv_dist=inv_dist, + i_nm=i_nm, + not_masked=not_masked, + use_include_ncells=use_include_ncells, + ) + # Calculate forces + if use_forces: + forces = zeros((len(atoms), 3), dtype=self.dtype) + forces[not_masked] = self.calc_forces( + inv_dist=inv_dist, + deriv=deriv, + i_nm=i_nm, + not_masked=not_masked, + use_include_ncells=use_include_ncells, + **kwargs, ) - forces[not_masked] = derivs.reshape(-1, 3) return energy, forces + return energy, None + + def calc_energy( + self, + inv_dist, + not_masked, + i_nm, + use_include_ncells, + **kwargs, + ): + "Calculate the energy." + if use_include_ncells: + inv_dist_p = (inv_dist**self.power).sum(axis=0) + else: + inv_dist_p = inv_dist**self.power + # Take double countings into account + inv_dist_p[i_nm, not_masked] *= 2.0 + inv_dist_p[:, not_masked] *= 0.5 + energy = self.c0 * inv_dist_p.sum() return energy - def get_inv_distances( - self, atoms, not_masked, masked, get_derivatives, **kwargs + def calc_forces( + self, + inv_dist, + deriv, + not_masked, + i_nm, + use_include_ncells=False, + **kwargs, + ): + "Calculate the forces." + # Calculate the derivative of the energy + inv_dist_p = inv_dist ** (self.power - 1) + # Calculate the forces + if use_include_ncells: + forces = einsum("dijc,dij->ic", deriv, inv_dist_p) + else: + forces = einsum("ijc,ij->ic", deriv, inv_dist_p) + forces *= self.c0p + return forces + + def get_inv_dis( + self, + atoms, + not_masked, + i_nm, + use_forces, + use_vector, + use_include_ncells, + use_mic, + **kwargs, ): """ - Get the unique inverse distances scaled with the covalent radii - and its derivatives. + Get the inverse distances and their derivatives. + + Parameters: + atoms: ase.Atoms + The atoms object. + not_masked: list + The indices of the atoms that are not masked. + i_nm: list + The indices of the atoms that are not masked. + use_forces: bool + Whether to calculate the forces. + use_vector: bool + Whether to use the vector of the distances. + use_include_ncells: bool + Whether to include the neighboring cells when calculating + the distances. + use_mic: bool + Whether to use the minimum image convention. + + Returns: + inv_dist: array + The inverse distances. + deriv: array + The derivatives of the inverse distances. """ - # Get the indicies for not fixed and not fixed atoms interactions - nmi, nmj = np.triu_indices(len(not_masked), k=1, m=None) - nmi_ind = not_masked[nmi] - nmj_ind = not_masked[nmj] - f, g = get_inverse_distances( - atoms, + # Calculate the distances + dist, dist_vec = self.get_distances( + atoms=atoms, not_masked=not_masked, - masked=masked, - nmi=nmi, - nmj=nmj, - nmi_ind=nmi_ind, - nmj_ind=nmj_ind, - use_derivatives=get_derivatives, - use_covrad=True, - periodic_softmax=self.periodic_softmax, - mic=self.mic, + use_vector=use_vector, + use_include_ncells=use_include_ncells, + use_mic=use_mic, + **kwargs, + ) + # Get the covalent radii + cov_dis = self.get_covalent_distances( + atoms.get_atomic_numbers(), + not_masked, + ) + # Add a small number to avoid division by zero + dist += self.eps + # Check if the distances should be included in the neighboring cells + if use_include_ncells: + # Calculate the inverse distances + inv_dist = cov_dis[None, ...] / dist + # Remove self interaction + inv_dist[0, i_nm, not_masked] = 0.0 + else: + # Calculate the inverse distances + inv_dist = cov_dis / dist + # Remove self interaction + inv_dist[i_nm, not_masked] = 0.0 + # Calculate the derivatives + if use_forces: + deriv = dist_vec * (inv_dist / (dist**2))[..., None] + else: + deriv = None + # Calculate the cutoff function + if self.use_cutoff: + inv_dist, deriv = cosine_cutoff( + inv_dist, + deriv, + rs_cutoff=self.rs_cutoff, + re_cutoff=self.re_cutoff, + eps=self.eps, + ) + return inv_dist, deriv + + def get_distances( + self, + atoms, + not_masked, + use_vector, + use_include_ncells, + use_mic, + **kwargs, + ): + "Calculate the distances." + dist, dist_vec = get_full_distance_matrix( + atoms=atoms, + not_masked=not_masked, + use_vector=use_vector, wrap=self.wrap, - eps=self.eps, + include_ncells=use_include_ncells, + all_ncells=self.all_ncells, + mic=use_mic, + cell_cutoff=self.cell_cutoff, + dtype=self.dtype, **kwargs, ) - return f, g + return dist, dist_vec + + def get_covalent_distances(self, atomic_numbers, not_masked): + "Get the covalent distances of the atoms." + cov_dis = covalent_radii[atomic_numbers] + return asarray(cov_dis + cov_dis[not_masked, None], dtype=self.dtype) + + def use_dis_method(self, pbc, use_forces, **kwargs): + """ + Check what distance method should be used. + + Parameters: + pbc: bool + The periodic boundary conditions. + use_forces: bool + Whether to calculate the forces. + + Returns: + use_vector: bool + Whether to use the vector of the distances. + use_include_ncells: bool + Whether to include the neighboring cells when calculating + the distances. + use_mic: bool + Whether to use the minimum image convention. + """ + if not pbc.any(): + return use_forces, False, False + if self.include_ncells: + return True, True, False + if self.mic: + return True, False, True + return use_forces, False, False def get_arguments(self): "Get the arguments of the class itself." # Get the arguments given to the class in the initialization arg_kwargs = dict( reduce_dimensions=self.reduce_dimensions, + use_forces=self.use_forces, + wrap=self.wrap, + include_ncells=self.include_ncells, + mic=self.mic, + all_ncells=self.all_ncells, + cell_cutoff=self.cell_cutoff, + use_cutoff=self.use_cutoff, + rs_cutoff=self.rs_cutoff, + re_cutoff=self.re_cutoff, r_scale=self.r_scale, power=self.power, - periodic_softmax=self.periodic_softmax, - mic=self.mic, - wrap=self.wrap, - eps=self.eps, + dtype=self.dtype, ) # Get the constants made within the class constant_kwargs = dict() diff --git a/catlearn/regression/gp/fingerprint/__init__.py b/catlearn/regression/gp/fingerprint/__init__.py index bf916eb6..3b81bdc5 100644 --- a/catlearn/regression/gp/fingerprint/__init__.py +++ b/catlearn/regression/gp/fingerprint/__init__.py @@ -1,10 +1,10 @@ from .fingerprint import Fingerprint from .fingerprintobject import FingerprintObject -from .geometry import get_all_distances, get_inverse_distances, mic_distance from .cartesian import Cartesian +from .distances import Distances from .invdistances import InvDistances from .invdistances2 import InvDistances2 -from .sorteddistances import SortedDistances +from .sorteddistances import SortedInvDistances from .sumdistances import SumDistances from .sumdistancespower import SumDistancesPower from .meandistances import MeanDistances @@ -14,13 +14,11 @@ __all__ = [ "Fingerprint", "FingerprintObject", - "get_all_distances", - "get_inverse_distances", - "mic_distance", "Cartesian", + "Distances", "InvDistances", "InvDistances2", - "SortedDistances", + "SortedInvDistances", "SumDistances", "SumDistancesPower", "MeanDistances", diff --git a/catlearn/regression/gp/fingerprint/cartesian.py b/catlearn/regression/gp/fingerprint/cartesian.py index 4c398491..9cc4deec 100644 --- a/catlearn/regression/gp/fingerprint/cartesian.py +++ b/catlearn/regression/gp/fingerprint/cartesian.py @@ -1,5 +1,6 @@ -import numpy as np +from numpy import asarray, identity from .fingerprint import Fingerprint +from .geometry import get_constraints class Cartesian(Fingerprint): @@ -7,6 +8,7 @@ def __init__( self, reduce_dimensions=True, use_derivatives=True, + dtype=None, **kwargs, ): """ @@ -20,19 +22,33 @@ def __init__( use_derivatives : bool Calculate and store derivatives of the fingerprint wrt. the cartesian coordinates. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. """ # Set the arguments super().__init__( reduce_dimensions=reduce_dimensions, use_derivatives=use_derivatives, + dtype=dtype, **kwargs, ) - def make_fingerprint(self, atoms, not_masked, masked, **kwargs): + def make_fingerprint(self, atoms, **kwargs): "The calculation of the cartesian coordinates fingerprint" - vector = (atoms.get_positions()[not_masked]).reshape(-1) + # Get the masked and not masked atoms + not_masked, _ = get_constraints( + atoms, + reduce_dimensions=self.reduce_dimensions, + ) + # Get the cartesian coordinates of the moved atoms + vector = asarray( + atoms.get_positions()[not_masked], + dtype=self.dtype, + ).reshape(-1) + # Get the derivatives if requested if self.use_derivatives: - derivative = np.identity(len(vector)) + derivative = identity(len(vector)) else: derivative = None return vector, derivative diff --git a/catlearn/regression/gp/fingerprint/distances.py b/catlearn/regression/gp/fingerprint/distances.py new file mode 100644 index 00000000..45937890 --- /dev/null +++ b/catlearn/regression/gp/fingerprint/distances.py @@ -0,0 +1,578 @@ +from numpy import arange, asarray, full, repeat, sqrt, zeros +from .geometry import ( + check_atoms, + get_all_distances, + get_constraints, + get_covalent_distances, + get_mask_indicies, + get_periodic_softmax, + get_periodic_sum, +) +from .fingerprint import Fingerprint + + +class Distances(Fingerprint): + def __init__( + self, + reduce_dimensions=True, + use_derivatives=True, + wrap=True, + include_ncells=False, + periodic_sum=False, + periodic_softmax=True, + mic=False, + all_ncells=True, + cell_cutoff=4.0, + dtype=float, + **kwargs, + ): + """ + Fingerprint constructer class that convert atoms object into + a fingerprint object with vector and derivatives. + The distance fingerprint constructer class. + The distances are scaled with covalent radii. + + Parameters: + reduce_dimensions: bool + Whether to reduce the fingerprint space if constrains are used. + use_derivatives: bool + Calculate and store derivatives of the fingerprint wrt. + the cartesian coordinates. + wrap: bool + Whether to wrap the atoms to the unit cell or not. + include_ncells: bool + Include the neighboring cells when calculating the distances. + The fingerprint will include the neighboring cells. + include_ncells will replace periodic_softmax and mic. + Either use mic, periodic_sum, periodic_softmax, or + include_ncells. + periodic_sum: bool + Use a sum of the distances to neighboring cells + when periodic boundary conditions are used. + Either use mic, periodic_sum, periodic_softmax, or + include_ncells. + periodic_softmax: bool + Use a softmax weighting on the distances to neighboring cells + from the squared distances when periodic boundary conditions + are used. + Either use mic, periodic_sum, periodic_softmax, or + include_ncells. + mic: bool + Minimum Image Convention (Shortest distances when + periodic boundary conditions are used). + Either use mic, periodic_sum, periodic_softmax, or + include_ncells. + mic is faster than periodic_softmax, + but the derivatives are discontinuous. + all_ncells: bool + Use all neighboring cells when calculating the distances. + cell_cutoff is used to check how many neighboring cells are + needed. + cell_cutoff: float + The cutoff distance for the neighboring cells. + It is the scaling of the maximum covalent distance. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. + """ + # Set the arguments + self.update_arguments( + reduce_dimensions=reduce_dimensions, + use_derivatives=use_derivatives, + wrap=wrap, + include_ncells=include_ncells, + periodic_sum=periodic_sum, + periodic_softmax=periodic_softmax, + mic=mic, + all_ncells=all_ncells, + cell_cutoff=cell_cutoff, + dtype=dtype, + **kwargs, + ) + + def make_fingerprint(self, atoms, **kwargs): + # Get the masked and not masked atoms + not_masked, masked = get_constraints( + atoms, + reduce_dimensions=self.reduce_dimensions, + ) + # Initialize the masking and indicies + ( + not_masked, + masked, + nmi, + nmj, + nmi_ind, + nmj_ind, + ) = get_mask_indicies(atoms, not_masked=not_masked, masked=masked) + # Get the periodicity + pbc = atoms.pbc + # Check what distance method should be used + ( + use_vector, + use_include_ncells, + use_periodic_softmax, + use_periodic_sum, + use_mic, + ) = self.use_dis_method(pbc=pbc, **kwargs) + # Check whether to calculate neighboring cells + use_ncells = ( + use_include_ncells or use_periodic_softmax or use_periodic_sum + ) + # Get all the distances and their vectors + dist, dist_vec = self.get_distances( + atoms=atoms, + not_masked=not_masked, + masked=masked, + nmi=nmi, + nmj=nmj, + nmi_ind=nmi_ind, + nmj_ind=nmj_ind, + use_vector=use_vector, + include_ncells=use_ncells, + mic=use_mic, + ) + # Calculate the fingerprint and its derivatives + fp, g = self.calc_fp( + dist=dist, + dist_vec=dist_vec, + not_masked=not_masked, + masked=masked, + nmi=nmi, + nmj=nmj, + nmi_ind=nmi_ind, + nmj_ind=nmj_ind, + atomic_numbers=atoms.get_atomic_numbers(), + tags=atoms.get_tags(), + use_include_ncells=use_include_ncells, + use_periodic_sum=use_periodic_sum, + use_periodic_softmax=use_periodic_softmax, + ) + return fp, g + + def calc_fp( + self, + dist, + dist_vec, + not_masked, + masked, + nmi, + nmj, + nmi_ind, + nmj_ind, + atomic_numbers, + tags=None, + use_include_ncells=False, + use_periodic_sum=False, + use_periodic_softmax=False, + **kwargs, + ): + "Calculate the fingerprint." + # Add small number to avoid division by zero to the distances + dist = sqrt(dist**2 + self.eps) + # Get the covalent distances + covdis = get_covalent_distances( + atomic_numbers=atomic_numbers, + not_masked=not_masked, + masked=masked, + nmi_ind=nmi_ind, + nmj_ind=nmj_ind, + dtype=self.dtype, + ) + # Set the correct shape of the covalent distances + if use_include_ncells or use_periodic_sum or use_periodic_softmax: + covdis = covdis[None, ...] + # Calculate the fingerprint + fp = dist / covdis + # Check what distance method should be used + if use_periodic_softmax: + # Calculate the fingerprint with the periodic softmax + fp, g = get_periodic_softmax( + dist_eps=dist, + dist_vec=dist_vec, + fpinner=fp, + covdis=covdis, + use_inv_dis=False, + use_derivatives=self.use_derivatives, + eps=self.eps, + **kwargs, + ) + elif use_periodic_sum: + # Calculate the fingerprint with the periodic sum + fp, g = get_periodic_sum( + dist_eps=dist, + dist_vec=dist_vec, + fpinner=fp, + use_inv_dis=False, + use_derivatives=self.use_derivatives, + **kwargs, + ) + else: + # Get the derivative of the fingerprint + if self.use_derivatives: + g = dist_vec * (-fp / (dist**2))[..., None] + else: + g = None + # Update the fingerprint with the modification + fp, g = self.modify_fp( + fp=fp, + g=g, + atomic_numbers=atomic_numbers, + tags=tags, + not_masked=not_masked, + masked=masked, + nmi=nmi, + nmj=nmj, + nmi_ind=nmi_ind, + nmj_ind=nmj_ind, + use_include_ncells=use_include_ncells, + **kwargs, + ) + return fp, g + + def insert_to_deriv_matrix( + self, + g, + not_masked, + masked, + nmi, + nmj, + use_include_ncells=False, + **kwargs, + ): + """ + Insert the distance vectors into the derivative matrix. + """ + # Get the length of the distance vector parts + len_nm_m, len_nm, _ = self.get_length_dist( + not_masked, + masked, + nmi, + ) + # Get the indices for the distances + i_m = arange(len_nm_m) + i_nm_r = len_nm_m // len(not_masked) + i_nm = repeat(arange(len(not_masked)), i_nm_r) + i_nm_nm = arange(len_nm) + len_nm_m + # Check if neighboring cells should be used + if use_include_ncells: + # Get the number of neighboring cells + c_dim = len(g) + # Make the derivative matrix + deriv_matrix = zeros( + (c_dim, len(g[0]), len(not_masked), 3), + dtype=self.dtype, + ) + else: + # Make the derivative matrix + deriv_matrix = zeros( + (len(g), len(not_masked), 3), + dtype=self.dtype, + ) + # Fill the derivative matrix for masked with not masked + deriv_matrix[..., i_m, i_nm, :] = g[..., i_m, :] + # Fill the derivative matrix for not masked with not masked + g_nm = g[..., i_nm_nm, :] + deriv_matrix[..., i_nm_nm, nmi, :] = g_nm + deriv_matrix[..., i_nm_nm, nmj, :] = -g_nm + # Reshape the derivative matrix + deriv_matrix = deriv_matrix.reshape(-1, len(not_masked) * 3) + return deriv_matrix + + def use_dis_method(self, pbc, **kwargs): + """ + Check what distance method should be used." + + Parameters: + pbc: bool + The periodic boundary conditions. + + Returns: + use_vector: bool + Whether to use the vector of the distances. + use_include_ncells: bool + Whether to include the neighboring cells when calculating + the distances. + use_periodic_softmax: bool + Whether to use the periodic softmax. + use_periodic_sum: bool + Whether to use the periodic sum. + use_mic: bool + Whether to use the minimum image convention. + """ + if not pbc.any(): + return self.use_derivatives, False, False, False, False + if self.include_ncells: + return True, True, False, False, False + if self.periodic_softmax: + return True, False, True, False, False + if self.periodic_sum: + return True, False, False, True, False + if self.mic: + return True, False, False, False, True + return self.use_derivatives, False, False, False, False + + def modify_fp( + self, + fp, + g, + atomic_numbers, + tags, + not_masked, + masked, + nmi, + nmj, + nmi_ind, + nmj_ind, + use_include_ncells=False, + **kwargs, + ): + "Modify the fingerprint." + # Reshape the fingerprint + if use_include_ncells: + fp = fp.reshape(-1) + # Insert the derivatives into the derivative matrix + if g is not None: + g = self.insert_to_deriv_matrix( + g=g, + not_masked=not_masked, + masked=masked, + nmi=nmi, + nmj=nmj, + use_include_ncells=use_include_ncells, + ) + return fp, g + + def element_setup( + self, + atomic_numbers, + tags, + not_masked, + masked, + use_include_ncells=False, + c_dim=None, + **kwargs, + ): + """ + Get all informations of the atom combinations and split them + into types. + """ + # Check if the atomic setup is the same + if self.reuse_combinations: + if ( + self.atomic_numbers is not None + or self.not_masked is not None + or self.tags is not None + or self.split_indicies is not None + ): + atoms_equal = check_atoms( + atomic_numbers=self.atomic_numbers, + atomic_numbers_test=atomic_numbers, + tags=self.tags, + tags_test=tags, + not_masked=self.not_masked, + not_masked_test=not_masked, + **kwargs, + ) + if atoms_equal: + return self.split_indicies + # Save the atomic setup + self.atomic_numbers = atomic_numbers + self.not_masked = not_masked + self.tags = tags + # Merge element type and their tags + if not self.use_tags: + tags = zeros((len(atomic_numbers)), dtype=int) + if len(not_masked): + combis_nm = list(zip(atomic_numbers[not_masked], tags[not_masked])) + else: + combis_nm = [] + if len(masked): + combis_m = list(zip(atomic_numbers[masked], tags[masked])) + else: + combis_m = [] + split_indicies = {} + t = 0 + for i, i_nm in enumerate(combis_nm): + i1 = i + 1 + for j_m in combis_m: + split_indicies.setdefault(i_nm + j_m, []).append(t) + t += 1 + for j_nm in combis_nm[i1:]: + split_indicies.setdefault(i_nm + j_nm, []).append(t) + t += 1 + # Include the neighboring cells + if use_include_ncells and c_dim is not None: + n_combi = full((c_dim, 1), t, dtype=int) + split_indicies = { + k: (asarray(v) + n_combi).reshape(-1) + for k, v in split_indicies.items() + } + else: + split_indicies = {k: asarray(v) for k, v in split_indicies.items()} + # Save the split indicies + self.split_indicies = split_indicies + return split_indicies + + def update_arguments( + self, + reduce_dimensions=None, + use_derivatives=None, + wrap=None, + include_ncells=None, + periodic_sum=None, + periodic_softmax=None, + mic=None, + all_ncells=None, + cell_cutoff=None, + dtype=None, + **kwargs, + ): + """ + Update the class with its arguments. + The existing arguments are used if they are not given. + + Parameters: + reduce_dimensions: bool + Whether to reduce the fingerprint space if constrains are used. + use_derivatives: bool + Calculate and store derivatives of the fingerprint wrt. + the cartesian coordinates. + wrap: bool + Whether to wrap the atoms to the unit cell or not. + include_ncells: bool + Include the neighboring cells when calculating the distances. + The fingerprint will include the neighboring cells. + include_ncells will replace periodic_softmax and mic. + Either use mic, periodic_sum, periodic_softmax, or + include_ncells. + periodic_sum: bool + Use a sum of the distances to neighboring cells + when periodic boundary conditions are used. + Either use mic, periodic_sum, periodic_softmax, or + include_ncells. + periodic_softmax: bool + Use a softmax weighting on the distances to neighboring cells + from the squared distances when periodic boundary conditions + are used. + Either use mic, periodic_sum, periodic_softmax, or + include_ncells. + mic: bool + Minimum Image Convention (Shortest distances when + periodic boundary conditions are used). + Either use mic, periodic_sum, periodic_softmax, or + include_ncells. + mic is faster than periodic_softmax, + but the derivatives are discontinuous. + all_ncells: bool + Use all neighboring cells when calculating the distances. + cell_cutoff is used to check how many neighboring cells are + needed. + cell_cutoff: float + The cutoff distance for the neighboring cells. + It is the scaling of the maximum covalent distance. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. + + Returns: + self: The updated instance itself. + """ + super().update_arguments( + reduce_dimensions=reduce_dimensions, + use_derivatives=use_derivatives, + dtype=dtype, + ) + if wrap is not None: + self.wrap = wrap + if include_ncells is not None: + self.include_ncells = include_ncells + if periodic_sum is not None: + self.periodic_sum = periodic_sum + if periodic_softmax is not None: + self.periodic_softmax = periodic_softmax + if mic is not None: + self.mic = mic + if all_ncells is not None: + self.all_ncells = all_ncells + if cell_cutoff is not None: + self.cell_cutoff = abs(float(cell_cutoff)) + if not hasattr(self, "not_masked"): + self.not_masked = None + if not hasattr(self, "masked"): + self.masked = None + if not hasattr(self, "atomic_numbers"): + self.atomic_numbers = None + if not hasattr(self, "tags"): + self.tags = None + if not hasattr(self, "split_indicies"): + self.split_indicies = None + # Tags is not implemented + self.use_tags = False + self.reuse_combinations = False + return self + + def get_distances( + self, + atoms, + not_masked=None, + masked=None, + nmi=None, + nmj=None, + nmi_ind=None, + nmj_ind=None, + use_vector=False, + include_ncells=False, + mic=False, + **kwargs, + ): + """ + Get the distances and their vectors. + """ + return get_all_distances( + atoms=atoms, + not_masked=not_masked, + masked=masked, + nmi=nmi, + nmj=nmj, + nmi_ind=nmi_ind, + nmj_ind=nmj_ind, + use_vector=use_vector, + wrap=self.wrap, + include_ncells=include_ncells, + mic=mic, + all_ncells=self.all_ncells, + cell_cutoff=self.cell_cutoff, + dtype=self.dtype, + **kwargs, + ) + + def get_length_dist(self, not_masked, masked, nmi, **kwargs): + "Get the length of the distance vector parts." + # Get the length of the distance vector parts + len_nm_m = len(not_masked) * len(masked) + len_nm = len(nmi) + # Get the full length of the distance vector + len_all = len_nm_m + len_nm + return len_nm_m, len_nm, len_all + + def get_arguments(self): + "Get the arguments of the class itself." + # Get the arguments given to the class in the initialization + arg_kwargs = dict( + reduce_dimensions=self.reduce_dimensions, + use_derivatives=self.use_derivatives, + wrap=self.wrap, + include_ncells=self.include_ncells, + periodic_sum=self.periodic_sum, + periodic_softmax=self.periodic_softmax, + mic=self.mic, + all_ncells=self.all_ncells, + cell_cutoff=self.cell_cutoff, + dtype=self.dtype, + ) + # Get the constants made within the class + constant_kwargs = dict() + # Get the objects made within the class + object_kwargs = dict() + return arg_kwargs, constant_kwargs, object_kwargs diff --git a/catlearn/regression/gp/fingerprint/fingerprint.py b/catlearn/regression/gp/fingerprint/fingerprint.py index a65fae8b..7fb11242 100644 --- a/catlearn/regression/gp/fingerprint/fingerprint.py +++ b/catlearn/regression/gp/fingerprint/fingerprint.py @@ -1,5 +1,5 @@ -import numpy as np -from ase.constraints import FixAtoms +from numpy import array, finfo +from .geometry import get_constraints from .fingerprintobject import FingerprintObject @@ -8,6 +8,7 @@ def __init__( self, reduce_dimensions=True, use_derivatives=True, + dtype=None, **kwargs, ): """ @@ -15,16 +16,20 @@ def __init__( a fingerprint object with vector and derivatives. Parameters: - reduce_dimensions : bool + reduce_dimensions: bool Whether to reduce the fingerprint space if constrains are used. - use_derivatives : bool + use_derivatives: bool Calculate and store derivatives of the fingerprint wrt. the cartesian coordinates. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. """ # Set the arguments self.update_arguments( reduce_dimensions=reduce_dimensions, use_derivatives=use_derivatives, + dtype=dtype, **kwargs, ) @@ -33,20 +38,16 @@ def __call__(self, atoms, **kwargs): Convert atoms to fingerprint and return the fingerprint object. Parameters: - atoms : ASE Atoms + atoms: ASE Atoms The ASE Atoms object that are converted to a fingerprint. Returns: FingerprintObject: Object with the fingerprint array and its derivatives if requested. """ - # Get the constraints from ASE Atoms - not_masked, masked = self.get_constraints(atoms) # Calculate the fingerprint and its derivatives if requested vector, derivative = self.make_fingerprint( atoms, - not_masked=not_masked, - masked=masked, **kwargs, ) # Make the fingerprint object and store the arrays within @@ -65,10 +66,58 @@ def get_reduce_dimensions(self): """ return self.reduce_dimensions + def set_use_derivatives(self, use_derivatives, **kwargs): + """ + Set whether to use derivatives/forces in the targets. + + Parameters: + use_derivatives: bool + Whether to use derivatives/forces in the targets. + + Returns: + self: The updated object itself. + """ + # Set the use derivatives + self.use_derivatives = use_derivatives + return self + + def set_reduce_dimensions(self, reduce_dimensions, **kwargs): + """ + Set whether to reduce the fingerprint space if constrains are used. + + Parameters: + reduce_dimensions: bool + Whether to reduce the fingerprint space if constrains are used. + + Returns: + self: The updated object itself. + """ + # Set the reduce dimensions + self.reduce_dimensions = reduce_dimensions + return self + + def set_dtype(self, dtype, **kwargs): + """ + Set the data type of the arrays. + + Parameters: + dtype: type + The data type of the arrays. + + Returns: + self: The updated object itself. + """ + # Set the data type + self.dtype = dtype + # Set a small number to avoid division by zero + self.eps = 1.1 * finfo(self.dtype).eps + return self + def update_arguments( self, reduce_dimensions=None, use_derivatives=None, + dtype=None, **kwargs, ): """ @@ -76,53 +125,78 @@ def update_arguments( The existing arguments are used if they are not given. Parameters: - reduce_dimensions : bool + reduce_dimensions: bool Whether to reduce the fingerprint space if constrains are used. - use_derivatives : bool + use_derivatives: bool Calculate and store derivatives of the fingerprint wrt. the cartesian coordinates. + dtype: type + The data type of the arrays. + If None, the default data type is used. Returns: self: The updated instance itself. """ if reduce_dimensions is not None: - self.reduce_dimensions = reduce_dimensions + self.set_reduce_dimensions(reduce_dimensions) if use_derivatives is not None: - self.use_derivatives = use_derivatives + self.set_use_derivatives(use_derivatives) + if dtype is not None or not hasattr(self, "dtype"): + self.set_dtype(dtype=dtype) + if not hasattr(self, "not_masked"): + self.not_masked = None + if not hasattr(self, "masked"): + self.masked = None return self - def make_fingerprint(self, atoms, not_masked, masked, **kwargs): + def make_fingerprint(self, atoms, **kwargs): "The calculation of the fingerprint" raise NotImplementedError() - def get_constraints(self, atoms, **kwargs): - """ - Get the indicies of the atoms that does not have fixed constraints. - - Parameters: - atoms : ASE Atoms - The ASE Atoms object with a calculator. - - Returns: - not_masked : list - A list of indicies for the moving atoms - if constraints are used. - masked : list - A list of indicies for the fixed atoms if constraints are used. - - """ - not_masked = list(range(len(atoms))) - if not self.reduce_dimensions: - return not_masked, [] - constraints = atoms.constraints - if len(constraints): - masked = [ - c.get_indices() for c in constraints if isinstance(c, FixAtoms) - ] - if len(masked): - masked = set(np.concatenate(masked)) - return list(set(not_masked).difference(masked)), list(masked) - return not_masked, [] + def get_not_masked(self, atoms, masked=None, recalc=False, **kwargs): + "Get the not masked atoms." + # Use the stored values if recalculation is not requested + if not recalc and self.not_masked is not None: + return self.not_masked + # Recalculate the not masked atoms + if masked is None: + not_masked, masked = get_constraints( + atoms, + reduce_dimensions=self.reduce_dimensions, + **kwargs, + ) + self.masked = array(masked, dtype=int) + else: + i_all = set(range(len(atoms))) + not_masked = list(i_all.difference(set(masked))) + not_masked = sorted(not_masked) + self.not_masked = array(not_masked, dtype=int) + return self.not_masked + + def get_masked(self, atoms, not_masked=None, recalc=False, **kwargs): + "Get the masked atoms." + # Use the stored values if recalculation is not requested + if not recalc and self.masked is not None: + return self.masked + if not_masked is None: + not_masked, masked = get_constraints( + atoms, + reduce_dimensions=self.reduce_dimensions, + **kwargs, + ) + self.not_masked = array(not_masked, dtype=int) + else: + i_all = set(range(len(atoms))) + masked = list(i_all.difference(set(not_masked))) + masked = sorted(masked) + self.masked = array(masked, dtype=int) + return self.masked + + def reset_masked(self): + "Reset the masked atoms." + self.masked = None + self.not_masked = None + return self def get_arguments(self): "Get the arguments of the class itself." @@ -130,6 +204,7 @@ def get_arguments(self): arg_kwargs = dict( reduce_dimensions=self.reduce_dimensions, use_derivatives=self.use_derivatives, + dtype=self.dtype, ) # Get the constants made within the class constant_kwargs = dict() diff --git a/catlearn/regression/gp/fingerprint/fingerprintobject.py b/catlearn/regression/gp/fingerprint/fingerprintobject.py index d03a2548..00ef9df5 100644 --- a/catlearn/regression/gp/fingerprint/fingerprintobject.py +++ b/catlearn/regression/gp/fingerprint/fingerprintobject.py @@ -1,3 +1,6 @@ +from numpy import asarray + + class FingerprintObject: def __init__(self, vector, derivative=None, **kwargs): """ @@ -11,23 +14,23 @@ def __init__(self, vector, derivative=None, **kwargs): derivative: (N,D) array (optional) Fingerprint derivative wrt. atoms cartesian coordinates. """ - self.vector = vector.copy() + self.vector = asarray(vector) if derivative is None: self.derivative = None else: - self.derivative = derivative.copy() + self.derivative = asarray(derivative) def get_vector(self, **kwargs): "Get the fingerprint vector." - return self.vector.copy() + return self.vector def get_derivatives(self, d=None, **kwargs): "Get the derivative of the fingerprint wrt. the cartesian coordinates." if self.derivative is None: return None if d is None: - return self.derivative.copy() - return self.derivative[:, d].copy() + return self.derivative + return self.derivative[:, d] def get_derivative_dimension(self, **kwargs): """ diff --git a/catlearn/regression/gp/fingerprint/fpwrapper.py b/catlearn/regression/gp/fingerprint/fpwrapper.py index 6e962c57..208c370e 100644 --- a/catlearn/regression/gp/fingerprint/fpwrapper.py +++ b/catlearn/regression/gp/fingerprint/fpwrapper.py @@ -1,5 +1,6 @@ from .fingerprint import Fingerprint -import numpy as np +from .geometry import get_constraints +from numpy import asarray, concatenate, transpose class FingerprintWrapperGPAtom(Fingerprint): @@ -8,6 +9,7 @@ def __init__( fingerprint, reduce_dimensions=True, use_derivatives=True, + dtype=float, **kwargs, ): """ @@ -24,11 +26,15 @@ def __init__( use_derivatives: bool Calculate and store derivatives of the fingerprint wrt. the cartesian coordinates. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. """ super().__init__( fingerprint=fingerprint, reduce_dimensions=reduce_dimensions, use_derivatives=use_derivatives, + dtype=dtype, **kwargs, ) @@ -37,6 +43,7 @@ def update_arguments( fingerprint=None, reduce_dimensions=None, use_derivatives=None, + dtype=None, **kwargs, ): """ @@ -51,31 +58,46 @@ def update_arguments( use_derivatives: bool Calculate and store derivatives of the fingerprint wrt. the cartesian coordinates. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. Returns: self: The updated instance itself. """ + super().update_arguments( + reduce_dimensions=reduce_dimensions, + use_derivatives=use_derivatives, + dtype=dtype, + ) if fingerprint is not None: self.fingerprint = fingerprint - if reduce_dimensions is not None: - self.reduce_dimensions = reduce_dimensions - if use_derivatives is not None: - self.use_derivatives = use_derivatives return self - def make_fingerprint(self, atoms, not_masked, masked, **kwargs): + def make_fingerprint(self, atoms, **kwargs): "The calculation of the gp-atom fingerprint" + # Get the masked and not masked atoms + not_masked, _ = get_constraints( + atoms, + reduce_dimensions=self.reduce_dimensions, + ) + # Get the fingerprint fp = self.fingerprint( - atoms, calc_gradients=self.use_derivatives, **kwargs + atoms, + calc_gradients=self.use_derivatives, + **kwargs, ) - vector = fp.vector.copy() if self.use_derivatives: - derivative = fp.reduce_coord_gradients().copy() + derivative = fp.reduce_coord_gradients() # enforced not_masked since it is not possible in ASE-GPATOM - derivative = np.concatenate(derivative[not_masked], axis=1) + derivative = concatenate( + derivative[not_masked], + axis=1, + dtype=self.dtype, + ) else: derivative = None - return vector, derivative + return asarray(fp.vector, dtype=self.dtype), derivative def get_arguments(self): "Get the arguments of the class itself." @@ -84,6 +106,7 @@ def get_arguments(self): fingerprint=self.fingerprint, reduce_dimensions=self.reduce_dimensions, use_derivatives=self.use_derivatives, + dtype=self.dtype, ) # Get the constants made within the class constant_kwargs = dict() @@ -99,6 +122,7 @@ def __init__( reduce_dimensions=True, use_derivatives=True, fingerprint_kwargs={}, + dtype=float, **kwargs, ): """ @@ -117,12 +141,16 @@ def __init__( the cartesian coordinates. fingerprint_kwargs: dict Kwargs for the fingerprint function call. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. """ super().__init__( fingerprint=fingerprint, reduce_dimensions=reduce_dimensions, use_derivatives=use_derivatives, fingerprint_kwargs=fingerprint_kwargs, + dtype=dtype, **kwargs, ) @@ -132,6 +160,7 @@ def update_arguments( reduce_dimensions=None, use_derivatives=None, fingerprint_kwargs=None, + dtype=None, **kwargs, ): """ @@ -148,22 +177,32 @@ def update_arguments( the cartesian coordinates. fingerprint_kwargs: dict Kwargs for the fingerprint function call. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. Returns: self: The updated instance itself. """ + super().update_arguments( + reduce_dimensions=reduce_dimensions, + use_derivatives=use_derivatives, + dtype=dtype, + ) if fingerprint is not None: self.fingerprint = fingerprint - if reduce_dimensions is not None: - self.reduce_dimensions = reduce_dimensions - if use_derivatives is not None: - self.use_derivatives = use_derivatives if fingerprint_kwargs is not None: self.fingerprint_kwargs = fingerprint_kwargs.copy() return self - def make_fingerprint(self, atoms, not_masked, masked, **kwargs): + def make_fingerprint(self, atoms, **kwargs): "The calculation of the dscribe fingerprint" + # Get the masked and not masked atoms + not_masked, _ = get_constraints( + atoms, + reduce_dimensions=self.reduce_dimensions, + ) + # Get the fingerprint if self.use_derivatives: derivative, vector = self.fingerprint.derivatives( atoms, @@ -171,15 +210,16 @@ def make_fingerprint(self, atoms, not_masked, masked, **kwargs): return_descriptor=True, **self.fingerprint_kwargs, ) + derivative = asarray(derivative, dtype=self.dtype) if len(derivative.shape) == 4: - derivative = np.transpose(derivative, (0, 3, 1, 2)) + derivative = transpose(derivative, (0, 3, 1, 2)) else: - derivative = np.transpose(derivative, (2, 0, 1)) + derivative = transpose(derivative, (2, 0, 1)) derivative = derivative.reshape(-1, len(not_masked) * 3) else: vector = self.fingerprint.create(atoms, **self.fingerprint_kwargs) derivative = None - return vector.reshape(-1), derivative + return asarray(vector.reshape(-1), dtype=self.dtype), derivative def get_arguments(self): "Get the arguments of the class itself." @@ -189,6 +229,7 @@ def get_arguments(self): reduce_dimensions=self.reduce_dimensions, use_derivatives=self.use_derivatives, fingerprint_kwargs=self.fingerprint_kwargs, + dtype=self.dtype, ) # Get the constants made within the class constant_kwargs = dict() diff --git a/catlearn/regression/gp/fingerprint/geometry.py b/catlearn/regression/gp/fingerprint/geometry.py index f6a05656..45a057c3 100644 --- a/catlearn/regression/gp/fingerprint/geometry.py +++ b/catlearn/regression/gp/fingerprint/geometry.py @@ -1,41 +1,336 @@ -import numpy as np +from numpy import ( + arange, + asarray, + ceil, + concatenate, + cos, + einsum, + exp, + matmul, + pi, + sin, + sqrt, + triu_indices, + where, +) +from numpy.linalg import pinv import itertools from scipy.spatial.distance import cdist from ase.data import covalent_radii +from ase.constraints import FixAtoms + + +def get_constraints(atoms, reduce_dimensions=True, **kwargs): + """ + Get the indicies of the atoms that does not have fixed constraints. + + Parameters: + atoms: ASE Atoms + The ASE Atoms instance. + reduce_dimensions: bool + Whether to fix or mask some of the atoms. + + Returns: + not_masked: (Nnm) list + A list of indicies for the moving atoms if constraints are used. + masked: (Nm) list + A list of indicies for the fixed atoms if constraints are used. + + """ + not_masked = list(range(len(atoms))) + if reduce_dimensions and len(atoms.constraints): + masked = [ + c.get_indices() + for c in atoms.constraints + if isinstance(c, FixAtoms) + ] + if len(masked): + masked = set(concatenate(masked)) + not_masked = list(set(not_masked).difference(masked)) + not_masked = sorted(not_masked) + masked = list(masked) + else: + masked = [] + return asarray(not_masked), asarray(masked) + + +def get_mask_indicies( + atoms, + not_masked=None, + masked=None, + nmi=None, + nmj=None, + nmi_ind=None, + nmj_ind=None, + **kwargs, +): + """ + Get the indicies of the atoms that are masked and not masked. + + Parameters: + atoms: ASE Atoms + The ASE Atoms instance. + not_masked: (Nnm) list (optional) + A list of indicies for the moving atoms if constraints are used. + Else all atoms are treated to be moving. + masked: (Nn) list (optional) + A list of indicies for the fixed atoms if constraints are used. + nmi: list (optional) + The upper triangle indicies of the not masked atoms. + nmj: list (optional) + The upper triangle indicies of the not masked atoms. + nmi_ind: list (optional) + The indicies of the not masked atoms. + nmj_ind: list (optional) + The indicies of the not masked atoms. + + Returns: + not_masked: (Nnm) list + A list of indicies for the moving atoms if constraints are used. + masked: (Nm) list + A list of indicies for the fixed atoms if constraints are used. + nmi: list + The upper triangle indicies of the not masked atoms. + nmi_ind: list + The indicies of the not masked atoms. + nmj_ind: list + The indicies of the not masked atoms. + """ + # If a not masked list is given, all atoms is treated to be not masked + if not_masked is None: + not_masked = arange(len(atoms)) + # If a masked list is not given, it is calculated from the not masked + if masked is None: + masked = asarray( + list(set(range(len(atoms))).difference(set(not_masked))) + ) + # Make indicies of not masked atoms with itself + if nmi is None or nmj is None or nmi_ind is None or nmj_ind is None: + nmi, nmj = triu_indices(len(not_masked), k=1, m=None) + nmi_ind = not_masked[nmi] + nmj_ind = not_masked[nmj] + return not_masked, masked, nmi, nmj, nmi_ind, nmj_ind + + +def check_atoms( + atomic_numbers, + atomic_numbers_test, + tags=None, + tags_test=None, + cell=None, + cell_test=None, + pbc=None, + pbc_test=None, + not_masked=None, + not_masked_test=None, + **kwargs, +): + """ + Check if the atoms instance is the same as the input. + + Parameters: + atomic_numbers: (N) list + The atomic numbers of the atoms. + atomic_numbers_test: (N) list + The atomic numbers of the tested atoms. + tags: (N) list (optional) + The tags of the atoms. + tags_test: (N) list (optional) + The tags of the tested atoms. + cell: (3, 3) array (optional) + The cell vectors. + cell_test: (3, 3) array (optional) + The cell vectors of the tested atoms. + pbc: (3) list (optional) + The periodic boundary conditions. + pbc_test: (3) list (optional) + The periodic boundary conditions of the tested atoms. + not_masked: (Nnm) list (optional) + A list of indicies for the moving atoms if constraints are used. + not_masked_test: (Nnm) list (optional) + A list of indicies for the moving atoms if constraints + are used in the tested atoms. + + Returns: + bool: If the atoms are the same. + """ + if len(atomic_numbers_test) != len(atomic_numbers): + return False + if not_masked is not None and not_masked_test is not None: + if (not_masked_test != not_masked).any(): + return False + if (atomic_numbers_test != atomic_numbers).any(): + return False + if tags is not None and tags_test is not None: + if (tags_test != tags).any(): + return False + if cell is not None and cell_test is not None: + if (cell_test != cell).any(): + return False + if pbc is not None and pbc_test is not None: + if (pbc_test != pbc).any(): + return False + return True + + +def get_ncells( + cell, + pbc, + all_ncells=False, + cell_cutoff=4.0, + atomic_numbers=None, + remove0=False, + dtype=None, + **kwargs, +): + """ + Get all neighboring cells within the cutoff. + + Parameters: + cell: (3, 3) array + The cell vectors. + pbc: (3) list + The periodic boundary conditions. + all_ncells: bool + If all neighboring cells within a cutoff should be used. + cell_cutoff: float + The distance cutoff for neighboring cells. + atomic_numbers: list + The atomic numbers of the atoms. + It is only used when all_ncells is True. + remove0: bool + If the zero vector should + be removed from the neighboring cells. + dtype: type + The data type of the arrays + + Returns: + cells_p: (Nc, 3) array + The displacements from all combinations of the neighboring cells. + """ + # Check if all neighboring cells should be used + if all_ncells: + # Get the inverse of the cell + cinv = pinv(cell) + # Get the maximum covalent distance + atomic_numbers_set = list(set(atomic_numbers)) + covrad = covalent_radii[atomic_numbers_set] + max_cov = 2.0 * covrad.max() + # Get the cutoff distance from the maximum covalent distance + cutoff = max_cov * cell_cutoff + # Get the coordinates to cutoff in lattice coordinates + ccut = cutoff * cinv + # Get the number of neighboring cells in each direction + ncells = ceil(abs(ccut).max(axis=0)).astype(int) + # Only use neighboring cells if the dimension is periodic + ncells = where(pbc, ncells, 0) + else: + # Only use neighboring cells if the dimension is periodic + ncells = where(pbc, 1, 0) + # Get all neighboring cells + b = [list(range(-i, i + 1)) for i in ncells] + # Make all periodic combinations + p_arrays = list(itertools.product(*b)) + # Remove the initial combination + p_arrays.remove((0, 0, 0)) + # Add the zero vector in the beginning + if not remove0: + p_arrays = [(0, 0, 0)] + p_arrays + # Calculate all displacement vector from the cell vectors + p_arrays = asarray(p_arrays, dtype=dtype) + cells_p = matmul(p_arrays, cell, dtype=dtype) + return cells_p def get_full_distance_matrix( atoms, not_masked=None, + use_vector=False, + wrap=True, + include_ncells=False, mic=False, - vector=False, - wrap=False, + all_ncells=False, + cell_cutoff=4.0, + dtype=None, **kwargs, ): """ Get the full cartesian distance matrix between the atomes and including - the vectors if vector=True. + the vectors if requested. + + Parameters: + atoms: ASE Atoms + The ASE Atoms instance. + not_masked: Nnm list (optional) + A list of indicies for the moving atoms if constraints are used. + Else all atoms are treated to be moving. + use_vector: bool + If the distance vectors should be returned. + wrap: bool + If the atoms should be wrapped to the cell. + include_ncells: bool + If neighboring cells should be included. + all_ncells: bool + If all neighboring cells within a cutoff should be used. + mic: bool + If the minimum image convention should be used. + cell_cutoff: float + The distance cutoff for neighboring cells. + dtype: type + The data type of the arrays + + Returns: + dist: (N, Nnm) or (Nc, N, Nnm) array + The full distance matrix. + dist_vec: (N, Nnm, 3) or (Nc, N, Nnm, 3) array + The full distance matrix with directions if use_vector=True. """ - # If a not masked list is given all atoms is treated to be not masked + # If a not masked list is not given all atoms is treated to be not masked if not_masked is None: - not_masked = np.arange(len(atoms)) + not_masked = arange(len(atoms)) # Get the atomic positions - pos = atoms.get_positions(wrap=wrap) - # Get distance vectors - if vector or mic: - dist_vec = pos - pos[not_masked, None] + pos = asarray(atoms.get_positions(wrap=wrap), dtype=dtype) # Get the periodic boundary conditions pbc = atoms.pbc.copy() - # Check if the minimum image convention is used and if there is any pbc - if not mic or sum(pbc) == 0: - # Get only the distances - if not vector: - return cdist(pos[not_masked], pos), None - return np.linalg.norm(dist_vec, axis=-1), dist_vec - # Get the cell vectors - cell = np.array(atoms.cell) - # Get the minimum image convention distances and distance vectors - return mic_distance(dist_vec, cell, pbc, vector=vector, **kwargs) + is_pbc = pbc.any() + # Check whether to calculate distance vectors + if use_vector or (is_pbc and (include_ncells or mic)): + # Get distance vectors + dist_vec = pos - pos[not_masked, None] + else: + dist_vec = None + # Return the distances + D = cdist(pos[not_masked], pos) + D = asarray(D, dtype=dtype) + return D, None + # Check if neighboring cells should be included + if include_ncells and is_pbc: + cells_p = get_ncells( + cell=atoms.get_cell(), + pbc=pbc, + atomic_numbers=atoms.get_atomic_numbers(), + all_ncells=all_ncells, + cell_cutoff=cell_cutoff, + dtype=dtype, + ) + # Calculate the distances to the atoms in all unit cell + dist_vec = dist_vec + cells_p[:, None, None, :] + dist = sqrt(einsum("ijlk,ijlk->ijl", dist_vec, dist_vec)) + return dist, dist_vec + elif mic and is_pbc: + # Get the distances with minimum image convention + dist, dist_vec = mic_distance( + dist_vec=dist_vec, + cell=atoms.get_cell(), + pbc=pbc, + use_vector=use_vector, + dtype=dtype, + **kwargs, + ) + return dist, dist_vec + # Calculate the distances and return + dist = sqrt(einsum("ijl,ijl->ij", dist_vec, dist_vec)) + return dist, dist_vec def get_all_distances( @@ -43,72 +338,280 @@ def get_all_distances( not_masked=None, masked=None, nmi=None, + nmj=None, nmi_ind=None, nmj_ind=None, + use_vector=False, + wrap=True, + include_ncells=False, mic=False, - vector=False, - wrap=False, + all_ncells=False, + cell_cutoff=4.0, + dtype=None, **kwargs, ): """ Get the unique cartesian distances between the atomes and including - the vectors if vector=True. + the vectors if use_vector=True. + + Parameters: + atoms: ASE Atoms + The ASE Atoms instance. + not_masked: Nnm list (optional) + A list of indicies for the moving atoms if constraints are used. + Else all atoms are treated to be moving. + masked: Nm list (optional) + A list of indicies for the fixed atoms if constraints are used. + nmi: list (optional) + The upper triangle indicies of the not masked atoms. + nmi_ind: list (optional) + The indicies of the not masked atoms. + nmj_ind: list (optional) + The indicies of the not masked atoms. + use_vector: bool + If the distance vectors should be returned. + wrap: bool + If the atoms should be wrapped to the cell. + mic: bool + If the minimum image convention should be used. + include_ncells: bool + If neighboring cells should be included. + all_ncells: bool + If all neighboring cells within a cutoff should be used. + cell_cutoff: float + The distance cutoff for neighboring cells. + dtype: type + The data type of the arrays + + Returns: + dist: (Nnm*Nm+(Nnm*(Nnm-1)/2)) or (Nc, Nnm*N+(Nnm*(Nnm-1)/2)) array + The unique distances. + dist_vec: (Nnm*Nm+(Nnm*(Nnm-1)/2), 3) or + (Nc, Nnm*N+(Nnm*(Nnm-1)/2), 3) array + The unique distances with directions if use_vector=True. """ - # If a not masked list is given all atoms is treated to be not masked - if not_masked is None: - not_masked = np.arange(len(atoms)) - if masked is None: - masked = np.array( - list(set(np.arange(len(atoms))).difference(set(not_masked))) - ) # Make indicies - if nmi is None or nmi_ind is None or nmj_ind is None: - nmi, nmj = np.triu_indices(len(not_masked), k=1, m=None) - nmi_ind = not_masked[nmi] - nmj_ind = not_masked[nmj] + not_masked, masked, nmi, _, nmi_ind, nmj_ind = get_mask_indicies( + atoms, + not_masked=not_masked, + masked=masked, + nmi=nmi, + nmj=nmj, + nmi_ind=nmi_ind, + nmj_ind=nmj_ind, + ) # Get the atomic positions - pos = atoms.get_positions(wrap=wrap) - # Get distance vectors - if vector or mic: - if len(masked): - dist_vec = np.concatenate( - [ - (pos[masked] - pos[not_masked, None]).reshape(-1, 3), - pos[nmj_ind] - pos[nmi_ind], - ], - axis=0, - ) - else: - dist_vec = pos[nmj_ind] - pos[nmi_ind] + pos = asarray(atoms.get_positions(wrap=wrap), dtype=dtype) # Get the periodic boundary conditions pbc = atoms.pbc.copy() - # Check if the minimum image convention is used and if there is any pbc - if not mic or sum(pbc) == 0: - if not vector: - d = cdist(pos[not_masked], pos) - if len(masked): - return ( - np.concatenate( - [d[:, masked].reshape(-1), d[nmi, nmj_ind]], - axis=0, - ), - None, - ) - return d[nmi, nmj_ind], None - return np.linalg.norm(dist_vec, axis=-1), dist_vec - # Get the cell vectors - cell = np.array(atoms.cell) - # Get the minimum image convention distances and distance vectors - return mic_distance(dist_vec, cell, pbc, vector=vector, **kwargs) - - -def mic_distance(dist_vec, cell, pbc, vector=False, **kwargs): - "Get the minimum image convention of the distances." + is_pbc = pbc.any() + # Check whether to calculate distance vectors + if use_vector or (is_pbc and (include_ncells or mic)): + # Get distance vectors + dist_vec = get_distance_vectors( + pos, + not_masked, + masked, + nmi_ind, + nmj_ind, + ) + else: + # Get the distances + dist = get_distances( + pos, + not_masked, + masked, + nmi, + nmj_ind, + ) + return dist, None + # Check if neighboring cells should be included + if include_ncells and is_pbc: + cells_p = get_ncells( + cell=atoms.get_cell(), + pbc=pbc, + atomic_numbers=atoms.get_atomic_numbers(), + all_ncells=all_ncells, + cell_cutoff=cell_cutoff, + dtype=dtype, + ) + # Calculate the distances to the atoms in all unit cell + dist_vec = dist_vec + cells_p[:, None, :] + dist = sqrt(einsum("ijl,ijl->ij", dist_vec, dist_vec)) + return dist, dist_vec + elif mic and is_pbc: + # Get the distances with minimum image convention + dist, dist_vec = mic_distance( + dist_vec=dist_vec, + cell=atoms.get_cell(), + pbc=pbc, + use_vector=use_vector, + dtype=dtype, + **kwargs, + ) + return dist, dist_vec + # Calculate the distances and return + dist = sqrt(einsum("ij,ij->i", dist_vec, dist_vec)) + return dist, dist_vec + + +def get_distances( + pos, + not_masked, + masked, + nmi, + nmj_ind, + **kwargs, +): + """ + Get the unique distances. + + Parameters: + pos: (N, 3) array + The atomic positions. + not_masked: Nnm list + A list of indicies for the moving atoms if constraints are used. + masked: Nm list + A list of indicies for the fixed atoms if constraints are used. + nmi: list + The upper triangle indicies of the not masked atoms. + nmj_ind: list + The indicies of the not masked atoms. + + Returns: + dist: (Nnm*Nm+(Nnm*(Nnm-1)/2)) array + The unique distances. + """ + # Get the distances matrix + d = cdist(pos[not_masked], pos) + d = asarray(d, dtype=pos.dtype) + # Get the distances of the not masked atoms + dist = d[nmi, nmj_ind] + if len(masked): + # Get the distances of the masked atoms + dist = concatenate( + [d[:, masked].reshape(-1), dist], + axis=0, + ) + return dist + + +def get_distance_vectors( + pos, + not_masked, + masked, + nmi_ind, + nmj_ind, + **kwargs, +): + """ + Get the unique distance vectors. + + Parameters: + pos: (N, 3) array + The atomic positions. + not_masked: Nnm list + A list of indicies for the moving atoms if constraints are used. + masked: Nm list + A list of indicies for the fixed atoms if constraints are used. + nmi_ind: list + The indicies of the not masked atoms. + nmj_ind: list + The indicies of the not masked atoms. + + Returns: + dist_vec: (Nnm*Nm+(Nnm*(Nnm-1)/2), 3) array + The unique distance vectors. + """ + # Calculate the distance vectors for the not masked atoms + dist_vec = pos[nmj_ind] - pos[nmi_ind] + # Check if masked atoms are used + if len(masked): + # Calculate the distance vectors for the masked atoms + dist_vec = concatenate( + [ + (pos[masked] - pos[not_masked, None]).reshape(-1, 3), + dist_vec, + ], + axis=0, + ) + return dist_vec + + +def get_covalent_distances( + atomic_numbers, + not_masked, + masked, + nmi_ind, + nmj_ind, + dtype=None, + **kwargs, +): + """ + Get the covalent distances. + + Parameters: + atomic_numbers: (N) list + The atomic numbers of the atoms. + not_masked: Nnm list + A list of indicies for the moving atoms if constraints are used. + masked: Nm list + A list of indicies for the fixed atoms if constraints are used. + nmi_ind: list + The indicies of the not masked atoms. + nmj_ind: list + The indicies of the not masked atoms. + dtype: type + The data type of the arrays. + + Returns: + covdis: (Nnm*Nm+(Nnm*(Nnm-1)/2)) array + The covalent distances. + """ + # Get the covalent radii + covrad = asarray(covalent_radii[atomic_numbers], dtype=dtype) + # Calculate the covalent distances for the not masked atoms + covdis = covrad[nmj_ind] + covrad[nmi_ind] + # Check if masked atoms are used + if len(masked): + # Calculate the covalent distances for the masked atoms + covdis = concatenate( + [ + (covrad[masked] + covrad[not_masked, None]).reshape(-1), + covdis, + ], + axis=0, + ) + return covdis + + +def mic_distance(dist_vec, cell, pbc, use_vector=False, dtype=None, **kwargs): + """ + Get the minimum image convention of the distances. + + Parameters: + dist_vec: (N, Nnm, 3) or (Nnm*Nm+(Nnm*(Nnm-1)/2) , 3) array + The distance vectors. + cell: (3, 3) array + The cell vectors. + pbc: (3) list + The periodic boundary conditions. + use_vector: bool + If the distance vectors should be returned. + dtype: type + The data type of the arrays + + Returns: + dist: (N, Nnm) or ((Nnm*Nm+(Nnm*(Nnm-1)/2)) array + The shortest distances. + dist_vec: (N, Nnm, 3) or ((Nnm*Nm+(Nnm*(Nnm-1)/2), 3) array + The shortest distance vectors if requested. + """ # Get the squared cell vectors cell2 = cell**2 # Save the shortest distances v2min = dist_vec**2 - if vector: + if use_vector: vmin = dist_vec.copy() else: vmin = None @@ -138,11 +641,11 @@ def mic_distance(dist_vec, cell, pbc, vector=False, **kwargs): vmin, d_c, cell, - vector=vector, + use_vector=use_vector, **kwargs, ) else: - v2min = np.sum(v2min, axis=-1) + v2min = v2min.sum(axis=-1) if sum(pbc_nc): # Do an extensive mic for the dimension that is not cubic v2min, vmin = mic_general_distance( @@ -151,10 +654,11 @@ def mic_distance(dist_vec, cell, pbc, vector=False, **kwargs): vmin, cell, pbc_nc, - vector=vector, + use_vector=use_vector, + dtype=dtype, **kwargs, ) - return np.sqrt(v2min), vmin + return sqrt(v2min), vmin def mic_cubic_distance( @@ -163,7 +667,7 @@ def mic_cubic_distance( vmin, d_c, cell, - vector=False, + use_vector=False, **kwargs, ): """ @@ -176,31 +680,33 @@ def mic_cubic_distance( dv_new = dist_vec[..., d] + cell[d, d] dv2_new = dv_new**2 # Save the new distances if they are shorter - i = np.where(dv2_new < v2min[..., d]) + i = where(dv2_new < v2min[..., d]) v2min[(*i, d)] = dv2_new[(*i,)] - if vector: + if use_vector: vmin[(*i, d)] = dv_new[(*i,)] # Calculate the distances to the atoms in the previous unit cell dv_new = dist_vec[..., d] - cell[d, d] dv2_new = dv_new**2 # Save the new distances if they are shorter - i = np.where(dv2_new < v2min[..., d]) + i = where(dv2_new < v2min[..., d]) v2min[(*i, d)] = dv2_new[(*i,)] - if vector: + if use_vector: vmin[(*i, d)] = dv_new[(*i,)] # Calculate the distances - if vector: - return np.sum(v2min, axis=-1), vmin - return np.sum(v2min, axis=-1), None + v2min = v2min.sum(axis=-1) + if use_vector: + return v2min, vmin + return v2min, None def mic_general_distance( dist_vec, - Dmin, + v2min, vmin, cell, pbc_nc, - vector=False, + use_vector=False, + dtype=None, **kwargs, ): """ @@ -208,154 +714,184 @@ def mic_general_distance( an extensive mic search. """ # Calculate all displacement vectors from the cell vectors - cells_p = get_periodicities(cell, pbc_nc) + cells_p = get_ncells( + cell=cell, + pbc=pbc_nc, + all_ncells=False, + remove0=True, + dtype=dtype, + ) # Iterate over all combinations for p_array in cells_p: # Calculate the distances to the atoms in the next unit cell dv_new = dist_vec + p_array - D_new = np.sum(dv_new**2, axis=-1) + D_new = (dv_new**2).sum(axis=-1) # Save the new distances if they are shorter - i = np.where(D_new < Dmin) - Dmin[(*i,)] = D_new[(*i,)] - if vector: + i = where(D_new < v2min) + v2min[(*i,)] = D_new[(*i,)] + if use_vector: vmin[(*i,)] = dv_new[(*i,)] # Calculate the distances - if vector: - return Dmin, vmin - return Dmin, None + if use_vector: + return v2min, vmin + return v2min, None -def get_periodicities(cell, pbc, remove0=True, **kwargs): - "Get all displacement vectors from the periodicity and cell vectors." - # Make all periodic combinations - b = [[-1, 0, 1] if p else [0] for p in pbc] - p_arrays = list(itertools.product(*b)) - # Remove the initial combination - if remove0: - p_arrays.remove((0, 0, 0)) - # Calculate all displacement vector from the cell vectors - p_arrays = np.array(p_arrays) - cells_p = np.matmul(p_arrays, cell) - return cells_p +def get_periodic_sum( + dist_eps, + dist_vec, + fpinner, + use_inv_dis=True, + use_derivatives=True, + **kwargs, +): + """ + Get the periodic sum of the distances. + Parameters: + dist_eps: (Nc, N, Nnm) or (Nc, Nnm*N+(Nnm*(Nnm-1)/2)) array + The distances with a small number added. + dist_vec: (Nc, N, Nnm, 3) or (Nc, Nnm*N+(Nnm*(Nnm-1)/2), 3) array + The distance vectors. + fpinner: (Nc, N, Nnm) or (Nc, Nnm*N+(Nnm*(Nnm-1)/2)) array + The inner fingerprint. + use_inv_dis: bool + Whether the inverse distance is used. + use_derivatives: bool + If the derivatives of the fingerprint should be returned. -def get_inverse_distances( - atoms, - not_masked=None, - masked=None, - nmi=None, - nmj=None, - nmi_ind=None, - nmj_ind=None, + Returns: + fp: (N, Nnm) or (Nnm*Nm+(Nnm*(Nnm-1)/2)) array + The fingerprint. + g: (N, Nnm, 3) or (Nnm*Nm+(Nnm*(Nnm-1)/2), 3) array + The derivatives of the fingerprint if requested. + """ + # Calculate the fingerprint + fp = fpinner.sum(axis=0) + # Calculate the derivatives of the fingerprint + if use_derivatives: + # Calculate the derivatives of the distances + if use_inv_dis: + inner_deriv = fpinner / (dist_eps**2) + else: + inner_deriv = -fpinner / (dist_eps**2) + # Calculate the derivatives of the fingerprint + g = einsum("c...d,c...->...d", dist_vec, inner_deriv) + else: + g = None + return fp, g + + +def get_periodic_softmax( + dist_eps, + dist_vec, + fpinner, + covdis, + use_inv_dis=True, use_derivatives=True, - use_covrad=True, - periodic_softmax=True, - mic=False, - wrap=True, eps=1e-16, **kwargs, ): """ - Get the inverse cartesian distances between the atomes. - The derivatives can also be obtained. + Get the periodic softmax of the distances. + + Parameters: + dist_eps: (Nc, N, Nnm) or (Nc, Nnm*N+(Nnm*(Nnm-1)/2)) array + The distances with a small number added. + dist_vec: (Nc, N, Nnm, 3) or (Nc, Nnm*N+(Nnm*(Nnm-1)/2), 3) array + The distance vectors. + fpinner: (Nc, N, Nnm) or (Nc, Nnm*N+(Nnm*(Nnm-1)/2)) array + The inner fingerprint. + If use_inv_dis is True, the fingerprint is the covalent distances + divided by distances. + Else, the fingerprint is distances divided by the covalent + distances. + covdis: (N, Nnm) or (Nnm*Nm+(Nnm*(Nnm-1)/2)) array + The covalent distances. + use_inv_dis: bool + Whether the inverse distance is used. + use_derivatives: bool + If the derivatives of the fingerprint should be returned. + eps: float + A small number to avoid division by zero. + + Returns: + fp: (N, Nnm) or (Nnm*Nm+(Nnm*(Nnm-1)/2)) array + The fingerprint. + g: (N, Nnm, 3) or (Nnm*Nm+(Nnm*(Nnm-1)/2), 3) array + The derivatives of the fingerprint if requested. """ - # If a not masked list is given all atoms is treated to be not masked - if not_masked is None: - not_masked = np.arange(len(atoms)) - if masked is None: - masked = np.array( - list(set(np.arange(len(atoms))).difference(set(not_masked))) - ) - # Make indicies - if nmi is None or nmj is None or nmi_ind is None or nmj_ind is None: - nmi, nmj = np.triu_indices(len(not_masked), k=1, m=None) - nmi_ind = not_masked[nmi] - nmj_ind = not_masked[nmj] - # Get the covalent radii - if use_covrad: - covrad = covalent_radii[atoms.get_atomic_numbers()] - if len(masked): - covrad = np.concatenate( - [ - (covrad[masked] + covrad[not_masked, None]).reshape(-1), - covrad[nmj_ind] + covrad[nmi_ind], - ], - axis=0, - ) - else: - covrad = covrad[nmj_ind] + covrad[nmi_ind] - else: - covrad = 1.0 - # Get inverse distances - if periodic_softmax and atoms.pbc.any(): - # Use a softmax function to weight the inverse distances - distances, vec_distances = get_all_distances( - atoms, - not_masked=not_masked, - masked=masked, - nmi=nmi, - nmj_ind=nmj_ind, - mic=False, - vector=True, - wrap=wrap, - **kwargs, - ) - # Calculate all displacement vectors from the cell vectors - cells_p = get_periodicities(atoms.get_cell(), atoms.pbc, remove0=False) - c_dim = len(cells_p) - # Calculate the distances to the atoms in all unit cell - d = vec_distances + cells_p.reshape(c_dim, 1, 3) - # Add small number to avoid division by zero to the distances - dnorm = np.linalg.norm(d, axis=-1) + eps - # Calculate weights - dcov = dnorm / covrad - w = np.exp(-(dcov**2)) - w = w / np.sum(w, axis=0) - # Calculate inverse distances - finner = w / dcov - f = np.sum(finner, axis=0) - # Calculate derivatives of inverse distances - if use_derivatives: - inner = (2.0 * (1.0 - (dcov * f))) / (covrad**2) - inner = inner + (1.0 / (dnorm**2)) - gij = np.sum(d * (finner * inner).reshape(c_dim, -1, 1), axis=0) + # Calculate weights + if use_inv_dis: + w = exp(-((1.0 / fpinner) ** 2)) else: - distances, vec_distances = get_all_distances( - atoms, - not_masked=not_masked, - masked=masked, - nmi=nmi, - nmj_ind=nmj_ind, - mic=mic, - vector=use_derivatives, - wrap=wrap, - **kwargs, - ) - # Add small number to avoid division by zero to the distances - distances = distances + eps - # Calculate inverse distances - f = covrad / distances - # Calculate derivatives of inverse distances - if use_derivatives: - gij = vec_distances * (covrad / (distances**3)).reshape(-1, 1) + w = exp(-(fpinner**2)) + w = w / (w.sum(axis=0) + eps) + # Calculate the all the fingerprint elements with their weights + fp_w = fpinner * w + # Calculate the fingerprint + fp = fp_w.sum(axis=0) + # Calculate the derivatives of the fingerprint if use_derivatives: - # Convert derivatives to the right matrix form - n_total = len(f) - g = np.zeros((n_total, len(not_masked) * 3)) - # The derivative of not fixed (not masked) and fixed atoms - n_nm_m = len(not_masked) * len(masked) - if n_nm_m: - i_g = np.repeat(np.arange(n_nm_m), 3) - j_g = 3 * np.arange(len(not_masked)).reshape(-1, 1) - j_g = j_g + np.array([0, 1, 2]) - j_g = np.tile(j_g, (1, len(masked))).reshape(-1) - g[i_g, j_g] = gij[:n_nm_m].reshape(-1) - # The derivative of not fixed (not masked) and not fixed atoms - if len(nmi): - i_g = np.repeat(np.arange(n_nm_m, n_total), 3) - j_gi = (3 * nmi.reshape(-1, 1) + np.array([0, 1, 2])).reshape(-1) - j_gj = (3 * nmj.reshape(-1, 1) + np.array([0, 1, 2])).reshape(-1) - g[i_g, j_gi] = gij[n_nm_m:].reshape(-1) - g[i_g, j_gj] = -g[i_g, j_gi] - return f, g - return f, None + # Calculate the derivatives of the distances + if use_inv_dis: + inner_deriv = 1.0 / (dist_eps**2) + else: + inner_deriv = -1.0 / (dist_eps**2) + # Calculate the derivatives of the weights + inner_deriv += (2.0 / (covdis**2)) * (1.0 - (fp / fpinner)) + # Calculate the inner derivative + inner_deriv = fp_w * inner_deriv + # Calculate the derivatives of the fingerprint + g = einsum("c...d,c...->...d", dist_vec, inner_deriv) + else: + g = None + return fp, g + + +def cosine_cutoff(fp, g, rs_cutoff=3.0, re_cutoff=4.0, eps=1e-16, **kwargs): + """ + Cosine cutoff function. + Modification of eq. 24 in https://doi.org/10.1002/qua.24927. + A small value has been added to the inverse distance to avoid division + by zero. + + Parameters: + fp: (N, Nnm) or (Nnm*Nm+(Nnm*(Nnm-1)/2)) array + The fingerprint. + g: (N, Nnm, 3) or (Nnm*Nm+(Nnm*(Nnm-1)/2), 3) array + The derivatives of the fingerprint. + rs_cutoff: float + The start of the cutoff function. + re_cutoff: float + The end of the cutoff function. + eps: float + A small number to avoid division by zero. + + Returns: + fp: (N, Nnm) or (Nnm*Nm+(Nnm*(Nnm-1)/2)) array + The fingerprint. + g: (N, Nnm, 3) or (Nnm*Nm+(Nnm*(Nnm-1)/2), 3) array + The derivatives of the fingerprint. + """ + # Find the scale of the cutoff function + rscale = re_cutoff - rs_cutoff + # Calculate the inverse fingerprint with small number added + fp_inv = 1.0 / (fp + eps) + # Calculate the cutoff function + fc_inner = pi * (fp_inv - rs_cutoff) / rscale + fc = 0.5 * (cos(fc_inner) + 1.0) + # Crop the cutoff function + fp_rs = fp_inv < rs_cutoff + fp_re = fp_inv > re_cutoff + fc = where(fp_rs, 1.0, fc) + fc = where(fp_re, 0.0, fc) + # Calculate the derivative of the cutoff function + if g is not None: + gc = (0.5 * pi / rscale) * sin(fc_inner) * (fp_inv**2) + gc = where(fp_rs, 0.0, gc) + gc = where(fp_re, 0.0, gc) + g = g * (fc + fp * gc)[..., None] + # Multiply the fingerprint with the cutoff function + fp = fp * fc + return fp, g diff --git a/catlearn/regression/gp/fingerprint/invdistances.py b/catlearn/regression/gp/fingerprint/invdistances.py index 3ca6ee2e..7c03fae0 100644 --- a/catlearn/regression/gp/fingerprint/invdistances.py +++ b/catlearn/regression/gp/fingerprint/invdistances.py @@ -1,18 +1,28 @@ -import numpy as np -import itertools -from .fingerprint import Fingerprint -from .geometry import get_inverse_distances +from .geometry import ( + cosine_cutoff, + get_covalent_distances, + get_periodic_softmax, + get_periodic_sum, +) +from .distances import Distances -class InvDistances(Fingerprint): +class InvDistances(Distances): def __init__( self, reduce_dimensions=True, use_derivatives=True, + wrap=True, + include_ncells=False, + periodic_sum=False, periodic_softmax=True, mic=False, - wrap=True, - eps=1e-16, + all_ncells=True, + cell_cutoff=4.0, + use_cutoff=False, + rs_cutoff=3.0, + re_cutoff=4.0, + dtype=float, **kwargs, ): """ @@ -22,33 +32,72 @@ def __init__( The inverse distances are scaled with covalent radii. Parameters: - reduce_dimensions : bool + reduce_dimensions: bool Whether to reduce the fingerprint space if constrains are used. - use_derivatives : bool + use_derivatives: bool Calculate and store derivatives of the fingerprint wrt. the cartesian coordinates. - periodic_softmax : bool - Use a softmax weighting of the squared distances + wrap: bool + Whether to wrap the atoms to the unit cell or not. + include_ncells: bool + Include the neighboring cells when calculating the distances. + The fingerprint will include the neighboring cells. + include_ncells will replace periodic_softmax and mic. + Either use mic, periodic_sum, periodic_softmax, or + include_ncells. + periodic_sum: bool + Use a sum of the distances to neighboring cells when periodic boundary conditions are used. - mic : bool + Either use mic, periodic_sum, periodic_softmax, or + include_ncells. + periodic_softmax: bool + Use a softmax weighting on the distances to neighboring cells + from the squared distances when periodic boundary conditions + are used. + Either use mic, periodic_sum, periodic_softmax, or + include_ncells. + mic: bool Minimum Image Convention (Shortest distances when periodic boundary conditions are used). - Either use mic or periodic_softmax, not both. + Either use mic, periodic_sum, periodic_softmax, or + include_ncells. mic is faster than periodic_softmax, but the derivatives are discontinuous. - wrap: bool - Whether to wrap the atoms to the unit cell or not. - eps : float - Small number to avoid division by zero. + all_ncells: bool + Use all neighboring cells when calculating the distances. + cell_cutoff is used to check how many neighboring cells are + needed. + cell_cutoff: float + The cutoff distance for the neighboring cells. + It is the scaling of the maximum covalent distance. + use_cutoff: bool + Whether to use a cutoff function for the inverse distance + fingerprint. + The cutoff function is a cosine cutoff function. + rs_cutoff: float + The starting distance for the cutoff function being 1. + re_cutoff: float + The ending distance for the cutoff function being 0. + re_cutoff must be larger than rs_cutoff. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. """ # Set the arguments super().__init__( reduce_dimensions=reduce_dimensions, use_derivatives=use_derivatives, + wrap=wrap, + include_ncells=include_ncells, + periodic_sum=periodic_sum, periodic_softmax=periodic_softmax, mic=mic, - wrap=wrap, - eps=eps, + all_ncells=all_ncells, + cell_cutoff=cell_cutoff, + use_cutoff=use_cutoff, + rs_cutoff=rs_cutoff, + re_cutoff=re_cutoff, + dtype=dtype, **kwargs, ) @@ -56,10 +105,17 @@ def update_arguments( self, reduce_dimensions=None, use_derivatives=None, + wrap=None, + include_ncells=None, + periodic_sum=None, periodic_softmax=None, mic=None, - wrap=None, - eps=None, + all_ncells=None, + cell_cutoff=None, + use_cutoff=None, + rs_cutoff=None, + re_cutoff=None, + dtype=None, **kwargs, ): """ @@ -67,195 +123,173 @@ def update_arguments( The existing arguments are used if they are not given. Parameters: - reduce_dimensions : bool + reduce_dimensions: bool Whether to reduce the fingerprint space if constrains are used. - use_derivatives : bool + use_derivatives: bool Calculate and store derivatives of the fingerprint wrt. the cartesian coordinates. - periodic_softmax : bool - Use a softmax weighting of the squared distances + wrap: bool + Whether to wrap the atoms to the unit cell or not. + include_ncells: bool + Include the neighboring cells when calculating the distances. + The fingerprint will include the neighboring cells. + include_ncells will replace periodic_softmax and mic. + Either use mic, periodic_sum, periodic_softmax, or + include_ncells. + periodic_sum: bool + Use a sum of the distances to neighboring cells when periodic boundary conditions are used. - mic : bool + Either use mic, periodic_sum, periodic_softmax, or + include_ncells. + periodic_softmax: bool + Use a softmax weighting on the distances to neighboring cells + from the squared distances when periodic boundary conditions + are used. + Either use mic, periodic_sum, periodic_softmax, or + include_ncells. + mic: bool Minimum Image Convention (Shortest distances when periodic boundary conditions are used). - Either use mic or periodic_softmax, not both. + Either use mic, periodic_sum, periodic_softmax, or + include_ncells. mic is faster than periodic_softmax, but the derivatives are discontinuous. - wrap: bool - Whether to wrap the atoms to the unit cell or not. - eps : float - Small number to avoid division by zero. + all_ncells: bool + Use all neighboring cells when calculating the distances. + cell_cutoff is used to check how many neighboring cells are + needed. + cell_cutoff: float + The cutoff distance for the neighboring cells. + It is the scaling of the maximum covalent distance. + use_cutoff: bool + Whether to use a cutoff function for the inverse distance + fingerprint. + The cutoff function is a cosine cutoff function. + rs_cutoff: float + The starting distance for the cutoff function being 1. + re_cutoff: float + The ending distance for the cutoff function being 0. + re_cutoff must be larger than rs_cutoff. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. Returns: self: The updated instance itself. """ - if reduce_dimensions is not None: - self.reduce_dimensions = reduce_dimensions - if use_derivatives is not None: - self.use_derivatives = use_derivatives - if periodic_softmax is not None: - self.periodic_softmax = periodic_softmax - if mic is not None: - self.mic = mic - if wrap is not None: - self.wrap = wrap - if eps is not None: - self.eps = abs(float(eps)) - return self - - def make_fingerprint(self, atoms, not_masked, masked, **kwargs): - "Calculate the fingerprint and its derivative." - # Set parameters of array sizes - n_atoms = len(atoms) - n_nmasked = len(not_masked) - n_masked = n_atoms - n_nmasked - n_nm_m = n_nmasked * n_masked - n_nm_nm = int(0.5 * n_nmasked * (n_nmasked - 1)) - n_total = n_nm_m + n_nm_nm - # Make indicies arrays - not_masked = np.array(not_masked, dtype=int) - masked = np.array(masked, dtype=int) - i_nm = np.arange(n_nmasked) - # Calculate all the fingerprints and their derivatives - fij, gij, nmi, nmj = self.get_contributions( - atoms, - not_masked, - masked, - i_nm, - n_total, - n_nmasked, - n_masked, - n_nm_m, - ) - # Return the fingerprints and their derivatives - return fij, gij - - def element_setup( - self, - atoms, - indicies, - not_masked=None, - masked=None, - i_nm=None, - i_m=None, - nm_bool=True, - **kwargs, - ): - "Get all informations of the atoms and split them into types." - # Merge element type and their tags - combis = list(zip(atoms.get_atomic_numbers(), atoms.get_tags())) - # Find all unique combinations - unique_combis = np.array(list(set(combis))) - n_unique = len(unique_combis) - # Get the Booleans for what combination it belongs to - bools = np.all( - np.array(combis).reshape(-1, 1, 2) == unique_combis, axis=2 + super().update_arguments( + reduce_dimensions=reduce_dimensions, + use_derivatives=use_derivatives, + wrap=wrap, + include_ncells=include_ncells, + periodic_sum=periodic_sum, + periodic_softmax=periodic_softmax, + mic=mic, + all_ncells=all_ncells, + cell_cutoff=cell_cutoff, + dtype=dtype, ) - if not nm_bool: - split_indicies = [indicies[ind] for ind in bools.T] - return split_indicies, split_indicies.copy(), n_unique - # Classify all non-fixed atoms in their unique combination - nmasked_indicies = [i_nm[ind] for ind in bools[not_masked].T] - # Classify all fixed atoms in their unique combination - masked_indicies = [i_m[ind] for ind in bools[masked].T] - return nmasked_indicies, masked_indicies, n_unique + if use_cutoff is not None: + self.use_cutoff = use_cutoff + if rs_cutoff is not None: + self.rs_cutoff = abs(float(rs_cutoff)) + if re_cutoff is not None: + self.re_cutoff = abs(float(re_cutoff)) + return self - def get_contributions( + def calc_fp( self, - atoms, + dist, + dist_vec, not_masked, masked, - i_nm, - n_total, - n_nmasked, - n_masked, - n_nm_m, + nmi, + nmj, + nmi_ind, + nmj_ind, + atomic_numbers, + tags=None, + use_include_ncells=False, + use_periodic_sum=False, + use_periodic_softmax=False, **kwargs, ): - # Get the indicies for not fixed and not fixed atoms interactions - nmi, nmj = np.triu_indices(n_nmasked, k=1, m=None) - nmi_ind = not_masked[nmi] - nmj_ind = not_masked[nmj] - f, g = get_inverse_distances( - atoms, + "Calculate the fingerprint." + # Add small number to avoid division by zero to the distances + dist += self.eps + # Get the covalent distances + covdis = get_covalent_distances( + atomic_numbers=atomic_numbers, + not_masked=not_masked, + masked=masked, + nmi_ind=nmi_ind, + nmj_ind=nmj_ind, + dtype=self.dtype, + ) + # Set the correct shape of the covalent distances + if use_include_ncells or use_periodic_sum or use_periodic_softmax: + covdis = covdis[None, ...] + # Calculate the fingerprint + fp = covdis / dist + # Check what distance method should be used + if use_periodic_softmax: + # Calculate the fingerprint with the periodic softmax + fp, g = get_periodic_softmax( + dist_eps=dist, + dist_vec=dist_vec, + fpinner=fp, + covdis=covdis, + use_inv_dis=True, + use_derivatives=self.use_derivatives, + eps=self.eps, + **kwargs, + ) + elif use_periodic_sum: + # Calculate the fingerprint with the periodic sum + fp, g = get_periodic_sum( + dist_eps=dist, + dist_vec=dist_vec, + fpinner=fp, + use_inv_dis=True, + use_derivatives=self.use_derivatives, + **kwargs, + ) + else: + # Get the derivative of the fingerprint + if self.use_derivatives: + g = dist_vec * (fp / (dist**2))[..., None] + else: + g = None + # Apply the cutoff function + if self.use_cutoff: + fp, g = self.apply_cutoff(fp, g, **kwargs) + # Update the fingerprint with the modification + fp, g = self.modify_fp( + fp=fp, + g=g, + atomic_numbers=atomic_numbers, + tags=tags, not_masked=not_masked, masked=masked, nmi=nmi, nmj=nmj, nmi_ind=nmi_ind, nmj_ind=nmj_ind, - use_derivatives=self.use_derivatives, - use_covrad=True, - periodic_softmax=self.periodic_softmax, - mic=self.mic, - wrap=self.wrap, - eps=self.eps, + use_include_ncells=use_include_ncells, **kwargs, ) - return f, g, nmi, nmj + return fp, g - def get_indicies( - self, - n_nmasked, - n_masked, - n_total, - n_nm_m, - nmi, - nmj, - **kwargs, - ): - "Get all the indicies of the interactions." - # Make the indicies of not fixed and fixed atoms interactions - indicies_nm_m = np.arange(n_nm_m, dtype=int).reshape( - n_nmasked, n_masked - ) - # Make the indicies of not fixed and fixed atoms interactions - indicies_nm_nm = np.zeros((n_nmasked, n_nmasked), dtype=int) - indicies_nm_nm[nmi, nmj] = indicies_nm_nm[nmj, nmi] = np.arange( - n_nm_m, n_total, dtype=int + def apply_cutoff(self, fp, g, **kwargs): + "Get the cutoff function." + return cosine_cutoff( + fp, + g, + rs_cutoff=self.rs_cutoff, + re_cutoff=self.re_cutoff, + eps=self.eps, + **kwargs, ) - return indicies_nm_m, indicies_nm_nm - - def get_indicies_combination( - self, - ci, - cj, - nmasked_indicies, - masked_indicies, - indicies_nm_m, - indicies_nm_nm, - **kwargs, - ): - """ - Get all the indicies in the fingerprint for - the specific combination of atom types. - """ - indicies_comb = [] - i_nm_ci = nmasked_indicies[ci].reshape(-1, 1) - if ci == cj: - indicies_comb = list( - indicies_nm_m[i_nm_ci, masked_indicies[cj]].reshape(-1) - ) - ind_prod = np.array( - list(itertools.combinations(nmasked_indicies[ci], 2)) - ) - if len(ind_prod): - indicies_comb = indicies_comb + list( - indicies_nm_nm[ind_prod[:, 0], ind_prod[:, 1]] - ) - else: - indicies_comb = list( - indicies_nm_m[i_nm_ci, masked_indicies[cj]].reshape(-1) - ) - indicies_comb = indicies_comb + list( - indicies_nm_m[ - nmasked_indicies[cj].reshape(-1, 1), masked_indicies[ci] - ].reshape(-1) - ) - indicies_comb = indicies_comb + list( - indicies_nm_nm[i_nm_ci, nmasked_indicies[cj]].reshape(-1) - ) - return indicies_comb, len(indicies_comb) def get_arguments(self): "Get the arguments of the class itself." @@ -263,10 +297,17 @@ def get_arguments(self): arg_kwargs = dict( reduce_dimensions=self.reduce_dimensions, use_derivatives=self.use_derivatives, + wrap=self.wrap, + include_ncells=self.include_ncells, + periodic_sum=self.periodic_sum, periodic_softmax=self.periodic_softmax, mic=self.mic, - wrap=self.wrap, - eps=self.eps, + all_ncells=self.all_ncells, + cell_cutoff=self.cell_cutoff, + use_cutoff=self.use_cutoff, + rs_cutoff=self.rs_cutoff, + re_cutoff=self.re_cutoff, + dtype=self.dtype, ) # Get the constants made within the class constant_kwargs = dict() diff --git a/catlearn/regression/gp/fingerprint/invdistances2.py b/catlearn/regression/gp/fingerprint/invdistances2.py index 7197bf1c..58d08852 100644 --- a/catlearn/regression/gp/fingerprint/invdistances2.py +++ b/catlearn/regression/gp/fingerprint/invdistances2.py @@ -6,10 +6,17 @@ def __init__( self, reduce_dimensions=True, use_derivatives=True, + wrap=True, + include_ncells=False, + periodic_sum=False, periodic_softmax=True, mic=False, - wrap=True, - eps=1e-16, + all_ncells=True, + cell_cutoff=4.0, + use_cutoff=False, + rs_cutoff=3.0, + re_cutoff=4.0, + dtype=float, **kwargs, ): """ @@ -19,62 +26,105 @@ def __init__( The inverse squared distances are scaled with covalent radii. Parameters: - reduce_dimensions : bool + reduce_dimensions: bool Whether to reduce the fingerprint space if constrains are used. - use_derivatives : bool + use_derivatives: bool Calculate and store derivatives of the fingerprint wrt. the cartesian coordinates. - periodic_softmax : bool - Use a softmax weighting of the squared distances + wrap: bool + Whether to wrap the atoms to the unit cell or not. + include_ncells: bool + Include the neighboring cells when calculating the distances. + The fingerprint will include the neighboring cells. + include_ncells will replace periodic_softmax and mic. + Either use mic, periodic_sum, periodic_softmax, or + include_ncells. + periodic_sum: bool + Use a sum of the distances to neighboring cells when periodic boundary conditions are used. - mic : bool + Either use mic, periodic_sum, periodic_softmax, or + include_ncells. + periodic_softmax: bool + Use a softmax weighting on the distances to neighboring cells + from the squared distances when periodic boundary conditions + are used. + Either use mic, periodic_sum, periodic_softmax, or + include_ncells. + mic: bool Minimum Image Convention (Shortest distances when periodic boundary conditions are used). - Either use mic or periodic_softmax, not both. + Either use mic, periodic_sum, periodic_softmax, or + include_ncells. mic is faster than periodic_softmax, but the derivatives are discontinuous. - wrap: bool - Whether to wrap the atoms to the unit cell or not. - eps : float - Small number to avoid division by zero. + all_ncells: bool + Use all neighboring cells when calculating the distances. + cell_cutoff is used to check how many neighboring cells are + needed. + cell_cutoff: float + The cutoff distance for the neighboring cells. + It is the scaling of the maximum covalent distance. + use_cutoff: bool + Whether to use a cutoff function for the inverse distance + fingerprint. + The cutoff function is a cosine cutoff function. + rs_cutoff: float + The starting distance for the cutoff function being 1. + re_cutoff: float + The ending distance for the cutoff function being 0. + re_cutoff must be larger than rs_cutoff. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. """ # Set the arguments super().__init__( reduce_dimensions=reduce_dimensions, use_derivatives=use_derivatives, + wrap=wrap, + include_ncells=include_ncells, + periodic_sum=periodic_sum, periodic_softmax=periodic_softmax, mic=mic, - wrap=wrap, - eps=eps, + all_ncells=all_ncells, + cell_cutoff=cell_cutoff, + use_cutoff=use_cutoff, + rs_cutoff=rs_cutoff, + re_cutoff=re_cutoff, + dtype=dtype, **kwargs, ) - def get_contributions( + def modify_fp( self, - atoms, + fp, + g, + atomic_numbers, + tags, not_masked, masked, - i_nm, - n_total, - n_nmasked, - n_masked, - n_nm_m, + nmi, + nmj, + nmi_ind, + nmj_ind, + use_include_ncells=False, **kwargs, ): - # Get the fingerprint and indicies from InvDistances - f, g, nmi, nmj = super().get_contributions( - atoms, - not_masked, - masked, - i_nm, - n_total, - n_nmasked, - n_masked, - n_nm_m, - **kwargs, - ) + "Modify the fingerprint." + # Adjust the derivatives so they are squared + if g is not None: + g = (2.0 * fp)[..., None] * g + g = self.insert_to_deriv_matrix( + g=g, + not_masked=not_masked, + masked=masked, + nmi=nmi, + nmj=nmj, + use_include_ncells=use_include_ncells, + ) + # Reshape the fingerprint + if use_include_ncells: + fp = fp.reshape(-1) # Adjust the fingerprint so it is squared - if self.use_derivatives: - g = (2.0 * f).reshape(-1, 1) * g - f = f**2 - return f, g, nmi, nmj + fp = fp**2 + return fp, g diff --git a/catlearn/regression/gp/fingerprint/meandistances.py b/catlearn/regression/gp/fingerprint/meandistances.py index f7207117..a3fc3be6 100644 --- a/catlearn/regression/gp/fingerprint/meandistances.py +++ b/catlearn/regression/gp/fingerprint/meandistances.py @@ -1,16 +1,26 @@ -import numpy as np -from .invdistances import InvDistances +from numpy import asarray, zeros +from .sumdistances import SumDistances -class MeanDistances(InvDistances): +class MeanDistances(SumDistances): def __init__( self, reduce_dimensions=True, use_derivatives=True, + wrap=True, + include_ncells=False, + periodic_sum=False, periodic_softmax=True, mic=False, - wrap=True, - eps=1e-16, + all_ncells=True, + cell_cutoff=4.0, + use_cutoff=False, + rs_cutoff=3.0, + re_cutoff=4.0, + dtype=float, + use_tags=False, + use_pairs=True, + reuse_combinations=True, **kwargs, ): """ @@ -20,105 +30,171 @@ def __init__( The inverse distances are scaled with covalent radii. Parameters: - reduce_dimensions : bool + reduce_dimensions: bool Whether to reduce the fingerprint space if constrains are used. - use_derivatives : bool + use_derivatives: bool Calculate and store derivatives of the fingerprint wrt. the cartesian coordinates. - periodic_softmax : bool - Use a softmax weighting of the squared distances + wrap: bool + Whether to wrap the atoms to the unit cell or not. + include_ncells: bool + Include the neighboring cells when calculating the distances. + The fingerprint will include the neighboring cells. + include_ncells will replace periodic_softmax and mic. + Either use mic, periodic_sum, periodic_softmax, or + include_ncells. + periodic_sum: bool + Use a sum of the distances to neighboring cells when periodic boundary conditions are used. - mic : bool + Either use mic, periodic_sum, periodic_softmax, or + include_ncells. + periodic_softmax: bool + Use a softmax weighting on the distances to neighboring cells + from the squared distances when periodic boundary conditions + are used. + Either use mic, periodic_sum, periodic_softmax, or + include_ncells. + mic: bool Minimum Image Convention (Shortest distances when periodic boundary conditions are used). - Either use mic or periodic_softmax, not both. + Either use mic, periodic_sum, periodic_softmax, or + include_ncells. mic is faster than periodic_softmax, but the derivatives are discontinuous. - wrap: bool - Whether to wrap the atoms to the unit cell or not. - eps : float - Small number to avoid division by zero. + all_ncells: bool + Use all neighboring cells when calculating the distances. + cell_cutoff is used to check how many neighboring cells are + needed. + cell_cutoff: float + The cutoff distance for the neighboring cells. + It is the scaling of the maximum covalent distance. + use_cutoff: bool + Whether to use a cutoff function for the inverse distance + fingerprint. + The cutoff function is a cosine cutoff function. + rs_cutoff: float + The starting distance for the cutoff function being 1. + re_cutoff: float + The ending distance for the cutoff function being 0. + re_cutoff must be larger than rs_cutoff. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. + use_tags: bool + Use the tags of the atoms to identify the atoms as + another type. + use_pairs: bool + Use the pairs of the atoms to identify the atoms as + another type. + use_pairs: bool + Whether to use pairs of elements or use all elements. + reuse_combinations: bool + Whether to reuse the combinations of the elements. + The change in the atomic numbers and tags will be checked + to see if they are unchanged. + If False, the combinations are calculated each time. """ # Set the arguments super().__init__( reduce_dimensions=reduce_dimensions, use_derivatives=use_derivatives, + wrap=wrap, + include_ncells=include_ncells, + periodic_sum=periodic_sum, periodic_softmax=periodic_softmax, mic=mic, - wrap=wrap, - eps=eps, + all_ncells=all_ncells, + cell_cutoff=cell_cutoff, + use_cutoff=use_cutoff, + rs_cutoff=rs_cutoff, + re_cutoff=re_cutoff, + dtype=dtype, + use_tags=use_tags, + use_pairs=use_pairs, + reuse_combinations=reuse_combinations, **kwargs, ) - def make_fingerprint(self, atoms, not_masked, masked, **kwargs): - "Calculate the fingerprint and its derivative." - # Set parameters of array sizes - n_atoms = len(atoms) - n_nmasked = len(not_masked) - n_masked = n_atoms - n_nmasked - n_nm_m = n_nmasked * n_masked - n_nm_nm = int(0.5 * n_nmasked * (n_nmasked - 1)) - n_total = n_nm_m + n_nm_nm - # Make indicies arrays - not_masked = np.array(not_masked, dtype=int) - masked = np.array(masked, dtype=int) - indicies = np.arange(n_atoms) - i_nm = np.arange(n_nmasked) - i_m = np.arange(n_masked) - # Calculate all the fingerprints and their derivatives - fij, gij, nmi, nmj = self.get_contributions( - atoms, - not_masked, - masked, - i_nm, - n_total, - n_nmasked, - n_masked, - n_nm_m, - ) - # Get all the indicies of the interactions - indicies_nm_m, indicies_nm_nm = self.get_indicies( - n_nmasked, - n_masked, - n_total, - n_nm_m, - nmi, - nmj, - ) - # Make the arrays of fingerprints and their derivatives - f = [] - g = [] - # Get all informations of the atoms and split them into types - nmasked_indicies, masked_indicies, n_unique = self.element_setup( - atoms, - indicies, - not_masked, - masked, - i_nm, - i_m, - nm_bool=True, + def modify_fp_pairs( + self, + fp, + g, + not_masked, + use_include_ncells, + split_indicies_nm, + split_indicies, + **kwargs, + ): + # Mean the fingerprints and derivatives if neighboring cells are used + if use_include_ncells: + fp = fp.mean(axis=0) + if g is not None: + g = g.mean(axis=0) + # Make the new fingerprint + fp_new = zeros( + (len(split_indicies_nm), len(split_indicies)), + dtype=self.dtype, ) - # Get all combinations of the atom types - combinations = zip(*np.triu_indices(n_unique, k=0, m=None)) - # Run over all combinations - for ci, cj in combinations: - # Find the indicies in the fingerprints for the combinations - indicies_comb, len_i_comb = self.get_indicies_combination( - ci, - cj, - nmasked_indicies, - masked_indicies, - indicies_nm_m, - indicies_nm_nm, + # Calculate the new derivatives + if g is not None: + # Make the new derivatives + g_new = zeros( + ( + len(split_indicies_nm), + len(split_indicies), + len(not_masked), + 3, + ), + dtype=self.dtype, ) - if len_i_comb: - # Mean the fingerprints for the combinations - f, g = self.mean_fp(f, g, fij, gij, indicies_comb) - return np.array(f), np.array(g) + # Mean the fingerprint and derivatives + for i, i_v in enumerate(split_indicies_nm.values()): + fp_i = fp[i_v] + g_i = g[i_v] + g_ij = g_i[:, not_masked].sum(axis=0) + for j, (comb, j_v) in enumerate(split_indicies.items()): + fp_new[i, j] = fp_i[:, j_v].mean() + n_comb = len(i_v) * len(j_v) + g_new[i, j, i_v] = g_i[:, j_v].sum(axis=1) / n_comb + if comb in split_indicies_nm: + ij_comb = split_indicies_nm[comb] + g_new[i, j, ij_comb] -= g_ij[ij_comb] / n_comb + return fp_new.reshape(-1), g_new.reshape(-1, len(not_masked) * 3) + # Mean the fingerprints + for i, i_v in enumerate(split_indicies_nm.values()): + fp_i = fp[i_v] + for j, j_v in enumerate(split_indicies.values()): + fp_new[i, j] = fp_i[:, j_v].mean() + return fp_new.reshape(-1), None - def mean_fp(self, f, g, fij, gij, indicies_comb, **kwargs): - "Mean of the fingerprints." - f.append(np.mean(fij[indicies_comb])) - if self.use_derivatives: - g.append(np.mean(gij[indicies_comb], axis=0)) - return f, g + def modify_fp_elements( + self, + fp, + g, + not_masked, + use_include_ncells, + split_indicies_nm, + **kwargs, + ): + # Mean the fingerprints and derivatives if neighboring cells are used + if use_include_ncells: + fp = fp.mean(axis=0) + if g is not None: + g = g.mean(axis=0) + # Mean the fingerprints + n_atoms = fp.shape[1] + fp = fp.mean(axis=1) + fp = asarray( + [fp[i_v].mean() for i_v in split_indicies_nm.values()], + dtype=self.dtype, + ) + # Calculate the new derivatives + if g is not None: + g_new = zeros((len(split_indicies_nm), len(not_masked), 3)) + for i, i_v in enumerate(split_indicies_nm.values()): + g_new[i, i_v] = g[i_v].sum(axis=1) + g_new[i] -= g[i_v][:, not_masked].sum(axis=0) + g_new[i] /= len(i_v) * n_atoms + g_new = g_new.reshape(-1, len(not_masked) * 3) + return fp, g_new + return fp, None diff --git a/catlearn/regression/gp/fingerprint/meandistancespower.py b/catlearn/regression/gp/fingerprint/meandistancespower.py index c53e5e64..b43de53c 100644 --- a/catlearn/regression/gp/fingerprint/meandistancespower.py +++ b/catlearn/regression/gp/fingerprint/meandistancespower.py @@ -1,17 +1,27 @@ -import numpy as np -from .meandistances import MeanDistances +from numpy import asarray, zeros +from .sumdistancespower import SumDistancesPower -class MeanDistancesPower(MeanDistances): +class MeanDistancesPower(SumDistancesPower): def __init__( self, reduce_dimensions=True, use_derivatives=True, + wrap=True, + include_ncells=False, + periodic_sum=False, periodic_softmax=True, mic=False, - wrap=True, - eps=1e-16, - power=2, + all_ncells=True, + cell_cutoff=4.0, + use_cutoff=False, + rs_cutoff=3.0, + re_cutoff=4.0, + dtype=float, + use_tags=False, + use_pairs=True, + reuse_combinations=True, + power=4, use_roots=True, **kwargs, ): @@ -23,24 +33,66 @@ def __init__( The inverse distances are scaled with covalent radii. Parameters: - reduce_dimensions : bool + reduce_dimensions: bool Whether to reduce the fingerprint space if constrains are used. - use_derivatives : bool + use_derivatives: bool Calculate and store derivatives of the fingerprint wrt. the cartesian coordinates. - periodic_softmax : bool - Use a softmax weighting of the squared distances + wrap: bool + Whether to wrap the atoms to the unit cell or not. + include_ncells: bool + Include the neighboring cells when calculating the distances. + The fingerprint will include the neighboring cells. + include_ncells will replace periodic_softmax and mic. + Either use mic, periodic_sum, periodic_softmax, or + include_ncells. + periodic_sum: bool + Use a sum of the distances to neighboring cells when periodic boundary conditions are used. - mic : bool + Either use mic, periodic_sum, periodic_softmax, or + include_ncells. + periodic_softmax: bool + Use a softmax weighting on the distances to neighboring cells + from the squared distances when periodic boundary conditions + are used. + Either use mic, periodic_sum, periodic_softmax, or + include_ncells. + mic: bool Minimum Image Convention (Shortest distances when periodic boundary conditions are used). - Either use mic or periodic_softmax, not both. + Either use mic, periodic_sum, periodic_softmax, or + include_ncells. mic is faster than periodic_softmax, but the derivatives are discontinuous. - wrap: bool - Whether to wrap the atoms to the unit cell or not. - eps : float - Small number to avoid division by zero. + all_ncells: bool + Use all neighboring cells when calculating the distances. + cell_cutoff is used to check how many neighboring cells are + needed. + cell_cutoff: float + The cutoff distance for the neighboring cells. + It is the scaling of the maximum covalent distance. + use_cutoff: bool + Whether to use a cutoff function for the inverse distance + fingerprint. + The cutoff function is a cosine cutoff function. + rs_cutoff: float + The starting distance for the cutoff function being 1. + re_cutoff: float + The ending distance for the cutoff function being 0. + re_cutoff must be larger than rs_cutoff. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. + use_tags: bool + Use the tags of the atoms to identify the atoms as + another type. + use_pairs: bool + Whether to use pairs of elements or use all elements. + reuse_combinations: bool + Whether to reuse the combinations of the elements. + The change in the atomic numbers and tags will be checked + to see if they are unchanged. + If False, the combinations are calculated each time. power: int The power of the inverse distances. use_roots: bool @@ -50,191 +102,105 @@ def __init__( super().__init__( reduce_dimensions=reduce_dimensions, use_derivatives=use_derivatives, + wrap=wrap, + include_ncells=include_ncells, + periodic_sum=periodic_sum, periodic_softmax=periodic_softmax, mic=mic, - wrap=wrap, - eps=eps, + all_ncells=all_ncells, + cell_cutoff=cell_cutoff, + use_cutoff=use_cutoff, + rs_cutoff=rs_cutoff, + re_cutoff=re_cutoff, + dtype=dtype, + use_tags=use_tags, + use_pairs=use_pairs, + reuse_combinations=reuse_combinations, power=power, use_roots=use_roots, **kwargs, ) - def update_arguments( + def modify_fp_pairs( self, - reduce_dimensions=None, - use_derivatives=None, - periodic_softmax=None, - mic=None, - wrap=None, - eps=None, - power=None, - use_roots=None, + fp, + g, + not_masked, + use_include_ncells, + split_indicies_nm, + split_indicies, **kwargs, ): - """ - Update the class with its arguments. - The existing arguments are used if they are not given. - - Parameters: - reduce_dimensions : bool - Whether to reduce the fingerprint space if constrains are used. - use_derivatives : bool - Calculate and store derivatives of the fingerprint wrt. - the cartesian coordinates. - periodic_softmax : bool - Use a softmax weighting of the squared distances - when periodic boundary conditions are used. - mic : bool - Minimum Image Convention (Shortest distances when - periodic boundary conditions are used). - Either use mic or periodic_softmax, not both. - mic is faster than periodic_softmax, - but the derivatives are discontinuous. - wrap: bool - Whether to wrap the atoms to the unit cell or not. - eps : float - Small number to avoid division by zero. - power: int - The power of the inverse distances. - use_roots: bool - Whether to use roots of the power elements. - - Returns: - self: The updated instance itself. - """ - if reduce_dimensions is not None: - self.reduce_dimensions = reduce_dimensions - if use_derivatives is not None: - self.use_derivatives = use_derivatives - if periodic_softmax is not None: - self.periodic_softmax = periodic_softmax - if mic is not None: - self.mic = mic - if wrap is not None: - self.wrap = wrap - if eps is not None: - self.eps = abs(float(eps)) - if power is not None: - self.power = int(power) - if use_roots is not None: - self.use_roots = use_roots - return self - - def make_fingerprint(self, atoms, not_masked, masked, **kwargs): - "Calculate the fingerprint and its derivative." - # Set parameters of array sizes - n_atoms = len(atoms) - n_nmasked = len(not_masked) - n_masked = n_atoms - n_nmasked - n_nm_m = n_nmasked * n_masked - n_nm_nm = int(0.5 * n_nmasked * (n_nmasked - 1)) - n_total = n_nm_m + n_nm_nm - # Make indicies arrays - not_masked = np.array(not_masked, dtype=int) - masked = np.array(masked, dtype=int) - indicies = np.arange(n_atoms) - i_nm = np.arange(n_nmasked) - i_m = np.arange(n_masked) - # Calculate all the fingerprints and their derivatives - fij, gij, nmi, nmj = self.get_contributions( - atoms, - not_masked, - masked, - i_nm, - n_total, - n_nmasked, - n_masked, - n_nm_m, + # Mean the fingerprints and derivatives if neighboring cells are used + if use_include_ncells: + fp = fp.mean(axis=0) + if g is not None: + g = g.mean(axis=0) + # Make the new fingerprint + fp_new = zeros( + (len(split_indicies_nm), len(split_indicies)), + dtype=self.dtype, ) - # Get all the indicies of the interactions - indicies_nm_m, indicies_nm_nm = self.get_indicies( - n_nmasked, - n_masked, - n_total, - n_nm_m, - nmi, - nmj, - ) - # Make the arrays of fingerprints and their derivatives - f = [] - g = [] - # Get all informations of the atoms and split them into types - nmasked_indicies, masked_indicies, n_unique = self.element_setup( - atoms, - indicies, - not_masked, - masked, - i_nm, - i_m, - nm_bool=True, - ) - # Get all combinations of the atom types - combinations = zip(*np.triu_indices(n_unique, k=0, m=None)) - # Run over all combinations - for ci, cj in combinations: - # Find the indicies in the fingerprints for the combinations - indicies_comb, len_i_comb = self.get_indicies_combination( - ci, - cj, - nmasked_indicies, - masked_indicies, - indicies_nm_m, - indicies_nm_nm, + # Calculate the new derivatives + if g is not None: + # Make the new derivatives + g_new = zeros( + ( + len(split_indicies_nm), + len(split_indicies), + len(not_masked), + 3, + ), + dtype=self.dtype, ) - if len_i_comb: - # Mean the fingerprints for the combinations - f, g = self.mean_fp_power( - f, g, fij, gij, indicies_comb, len_i_comb - ) - return np.array(f), np.array(g) + # Mean the fingerprint and derivatives + for i, i_v in enumerate(split_indicies_nm.values()): + fp_i = fp[i_v] + g_i = g[i_v] + g_ij = g_i[:, not_masked].sum(axis=0) + for j, (comb, j_v) in enumerate(split_indicies.items()): + fp_new[i, j] = fp_i[:, j_v].mean() + n_comb = len(i_v) * len(j_v) + g_new[i, j, i_v] = g_i[:, j_v].sum(axis=1) / n_comb + if comb in split_indicies_nm: + ij_comb = split_indicies_nm[comb] + g_new[i, j, ij_comb] -= g_ij[ij_comb] / n_comb + return fp_new.reshape(-1), g_new.reshape(-1, len(not_masked) * 3) + # Mean the fingerprints + for i, i_v in enumerate(split_indicies_nm.values()): + fp_i = fp[i_v] + for j, j_v in enumerate(split_indicies.values()): + fp_new[i, j] = fp_i[:, j_v].mean() + return fp_new.reshape(-1), None - def mean_fp_power( + def modify_fp_elements( self, - f, + fp, g, - fij, - gij, - indicies_comb, - len_i_comb, + not_masked, + use_include_ncells, + split_indicies_nm, **kwargs, ): - "Mean of the fingerprints." - powers = np.arange(1, self.power + 1) - fij_powers = fij[indicies_comb].reshape(-1, 1) ** powers - fij_means = np.mean(fij_powers, axis=0) - if self.use_roots: - f.extend(fij_means ** (1.0 / powers)) - else: - f.extend(fij_means) - if self.use_derivatives: - g.append(np.mean(gij[indicies_comb], axis=0)) - fg_prod = np.mean( - fij_powers[:, :-1].T.reshape(self.power - 1, len_i_comb, 1) - * gij[indicies_comb], - axis=1, - ) - if self.use_roots: - fpowers = (1.0 - powers[1:]) / powers[1:] - g.extend(fg_prod * (fij_means[1:] ** fpowers).reshape(-1, 1)) - else: - g.extend(powers[1:].reshape(-1, 1) * fg_prod) - return f, g - - def get_arguments(self): - "Get the arguments of the class itself." - # Get the arguments given to the class in the initialization - arg_kwargs = dict( - reduce_dimensions=self.reduce_dimensions, - use_derivatives=self.use_derivatives, - periodic_softmax=self.periodic_softmax, - mic=self.mic, - wrap=self.wrap, - eps=self.eps, - power=self.power, - use_roots=self.use_roots, + # Mean the fingerprints and derivatives if neighboring cells are used + if use_include_ncells: + fp = fp.mean(axis=0) + if g is not None: + g = g.mean(axis=0) + # Mean the fingerprints + n_atoms = fp.shape[1] + fp = fp.mean(axis=1) + fp = asarray( + [fp[i_v].mean() for i_v in split_indicies_nm.values()], + dtype=self.dtype, ) - # Get the constants made within the class - constant_kwargs = dict() - # Get the objects made within the class - object_kwargs = dict() - return arg_kwargs, constant_kwargs, object_kwargs + # Calculate the new derivatives + if g is not None: + g_new = zeros((len(split_indicies_nm), len(not_masked), 3)) + for i, i_v in enumerate(split_indicies_nm.values()): + g_new[i, i_v] = g[i_v].sum(axis=1) + g_new[i] -= g[i_v][:, not_masked].sum(axis=0) + g_new[i] /= len(i_v) * n_atoms + g_new = g_new.reshape(-1, len(not_masked) * 3) + return fp, g_new + return fp, None diff --git a/catlearn/regression/gp/fingerprint/sorteddistances.py b/catlearn/regression/gp/fingerprint/sorteddistances.py index dc4cf544..25387ed9 100644 --- a/catlearn/regression/gp/fingerprint/sorteddistances.py +++ b/catlearn/regression/gp/fingerprint/sorteddistances.py @@ -1,16 +1,26 @@ -import numpy as np +from numpy import argsort, concatenate from .invdistances import InvDistances -class SortedDistances(InvDistances): +class SortedInvDistances(InvDistances): def __init__( self, reduce_dimensions=True, use_derivatives=True, + wrap=True, + include_ncells=False, + periodic_sum=False, periodic_softmax=True, mic=False, - wrap=True, - eps=1e-16, + all_ncells=True, + cell_cutoff=4.0, + use_cutoff=False, + rs_cutoff=3.0, + re_cutoff=4.0, + dtype=float, + use_tags=False, + use_sort_all=False, + reuse_combinations=True, **kwargs, ): """ @@ -20,128 +30,313 @@ def __init__( The inverse distances are scaled with covalent radii. Parameters: - reduce_dimensions : bool + reduce_dimensions: bool Whether to reduce the fingerprint space if constrains are used. - use_derivatives : bool + use_derivatives: bool Calculate and store derivatives of the fingerprint wrt. the cartesian coordinates. - periodic_softmax : bool - Use a softmax weighting of the squared distances + wrap: bool + Whether to wrap the atoms to the unit cell or not. + include_ncells: bool + Include the neighboring cells when calculating the distances. + The fingerprint will include the neighboring cells. + include_ncells will replace periodic_softmax and mic. + Either use mic, periodic_sum, periodic_softmax, or + include_ncells. + periodic_sum: bool + Use a sum of the distances to neighboring cells when periodic boundary conditions are used. - mic : bool + Either use mic, periodic_sum, periodic_softmax, or + include_ncells. + periodic_softmax: bool + Use a softmax weighting on the distances to neighboring cells + from the squared distances when periodic boundary conditions + are used. + Either use mic, periodic_sum, periodic_softmax, or + include_ncells. + mic: bool Minimum Image Convention (Shortest distances when periodic boundary conditions are used). - Either use mic or periodic_softmax, not both. + Either use mic, periodic_sum, periodic_softmax, or + include_ncells. mic is faster than periodic_softmax, but the derivatives are discontinuous. - wrap: bool - Whether to wrap the atoms to the unit cell or not. - eps : float - Small number to avoid division by zero. + all_ncells: bool + Use all neighboring cells when calculating the distances. + cell_cutoff is used to check how many neighboring cells are + needed. + cell_cutoff: float + The cutoff distance for the neighboring cells. + It is the scaling of the maximum covalent distance. + use_cutoff: bool + Whether to use a cutoff function for the inverse distance + fingerprint. + The cutoff function is a cosine cutoff function. + rs_cutoff: float + The starting distance for the cutoff function being 1. + re_cutoff: float + The ending distance for the cutoff function being 0. + re_cutoff must be larger than rs_cutoff. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. + use_tags: bool + Use the tags of the atoms to identify the atoms as + another type. + use_sort_all: bool + Whether sort all the the combinations independently of the + pairs. + reuse_combinations: bool + Whether to reuse the combinations of the elements. + The change in the atomic numbers and tags will be checked + to see if they are unchanged. + If False, the combinations are calculated each time. """ # Set the arguments super().__init__( reduce_dimensions=reduce_dimensions, use_derivatives=use_derivatives, + wrap=wrap, + all_ncells=all_ncells, + cell_cutoff=cell_cutoff, + include_ncells=include_ncells, + periodic_sum=periodic_sum, periodic_softmax=periodic_softmax, mic=mic, - wrap=wrap, - eps=eps, + use_cutoff=use_cutoff, + rs_cutoff=rs_cutoff, + re_cutoff=re_cutoff, + dtype=dtype, + use_tags=use_tags, + use_sort_all=use_sort_all, + reuse_combinations=reuse_combinations, **kwargs, ) - def make_fingerprint(self, atoms, not_masked, masked, **kwargs): - "Calculate the fingerprint and its derivative." - # Set parameters of array sizes - n_atoms = len(atoms) - n_nmasked = len(not_masked) - n_masked = n_atoms - n_nmasked - n_nm_m = n_nmasked * n_masked - n_nm_nm = int(0.5 * n_nmasked * (n_nmasked - 1)) - n_total = n_nm_m + n_nm_nm - # Make indicies arrays - not_masked = np.array(not_masked, dtype=int) - masked = np.array(masked, dtype=int) - indicies = np.arange(n_atoms) - i_nm = np.arange(n_nmasked) - i_m = np.arange(n_masked) - # Calculate all the fingerprints and their derivatives - fij, gij, nmi, nmj = self.get_contributions( - atoms, - not_masked, - masked, - i_nm, - n_total, - n_nmasked, - n_masked, - n_nm_m, - ) - # Get all the indicies of the interactions - indicies_nm_m, indicies_nm_nm = self.get_indicies( - n_nmasked, - n_masked, - n_total, - n_nm_m, - nmi, - nmj, - ) - # Make the arrays of fingerprints and their derivatives - f = np.zeros(n_total) - g = np.zeros((n_total, int(n_nmasked * 3))) - # Get all informations of the atoms and split them into types - nmasked_indicies, masked_indicies, n_unique = self.element_setup( - atoms, - indicies, + def modify_fp( + self, + fp, + g, + atomic_numbers, + tags, + not_masked, + masked, + nmi, + nmj, + nmi_ind, + nmj_ind, + use_include_ncells=False, + **kwargs, + ): + "Modify the fingerprint." + # Sort the fingerprint + if self.use_sort_all: + fp, indicies = self.sort_fp_all( + fp, + use_include_ncells=use_include_ncells, + **kwargs, + ) + else: + fp, indicies = self.sort_fp_pair( + fp, + atomic_numbers, + tags, + not_masked, + masked, + use_include_ncells=use_include_ncells, + **kwargs, + ) + # Sort the fingerprints and their derivatives + fp = fp[indicies] + # Insert the derivatives into the derivative matrix + if g is not None: + g = self.insert_to_deriv_matrix( + g=g, + not_masked=not_masked, + masked=masked, + nmi=nmi, + nmj=nmj, + use_include_ncells=use_include_ncells, + ) + g = g[indicies] + return fp, g + + def sort_fp_all(self, fp, use_include_ncells=False, **kwargs): + "Get the indicies for sorting the fingerprint." + # Reshape the fingerprint + if use_include_ncells: + fp = fp.reshape(-1) + # Get the sorted indicies + indicies = argsort(fp) + return fp, indicies + + def sort_fp_pair( + self, + fp, + atomic_numbers, + tags, + not_masked, + masked, + use_include_ncells=False, + **kwargs, + ): + "Get the indicies for sorting the fingerprint." + # Get the indicies of the atomic combinations + split_indicies = self.element_setup( + atomic_numbers, + tags, not_masked, masked, - i_nm, - i_m, - nm_bool=True, + use_include_ncells=use_include_ncells, + c_dim=len(fp), + **kwargs, ) - # Get all combinations of the atom types - combinations = zip(*np.triu_indices(n_unique, k=0, m=None)) - temp_len = 0 - # Run over all combinations - for ci, cj in combinations: - # Find the indicies in the fingerprints for the combinations - indicies_comb, len_i_comb = self.get_indicies_combination( - ci, - cj, - nmasked_indicies, - masked_indicies, - indicies_nm_m, - indicies_nm_nm, - ) - if len_i_comb: - # Sort the fingerprints for the combinations - len_new = temp_len + len_i_comb - f, g = self.sort_fp( - f, - g, - fij, - gij, - indicies_comb, - temp_len, - len_new, - ) - temp_len = len_new - return f, g + # Reshape the fingerprint + if use_include_ncells: + fp = fp.reshape(-1) + # Sort the indicies after inverse distance magnitude + indicies = [ + indi[argsort(fp[indi])] for indi in split_indicies.values() + ] + indicies = concatenate(indicies) + return fp, indicies - def sort_fp( + def update_arguments( self, - f, - g, - fij, - gij, - indicies_comb, - temp_len, - len_new, + reduce_dimensions=None, + use_derivatives=None, + wrap=None, + include_ncells=None, + periodic_sum=None, + periodic_softmax=None, + mic=None, + all_ncells=None, + cell_cutoff=None, + use_cutoff=None, + rs_cutoff=None, + re_cutoff=None, + dtype=None, + use_tags=None, + use_sort_all=None, + reuse_combinations=None, **kwargs, ): - "Sort the fingerprints after inverse distance magnitude." - i_sort = np.argsort(fij[indicies_comb])[::-1] - i_sort = np.array(indicies_comb)[i_sort] - f[temp_len:len_new] = fij[i_sort] - if self.use_derivatives: - g[temp_len:len_new] = gij[i_sort] - return f, g + """ + Update the class with its arguments. + The existing arguments are used if they are not given. + + Parameters: + reduce_dimensions: bool + Whether to reduce the fingerprint space if constrains are used. + use_derivatives: bool + Calculate and store derivatives of the fingerprint wrt. + the cartesian coordinates. + wrap: bool + Whether to wrap the atoms to the unit cell or not. + include_ncells: bool + Include the neighboring cells when calculating the distances. + The fingerprint will include the neighboring cells. + include_ncells will replace periodic_softmax and mic. + Either use mic, periodic_sum, periodic_softmax, or + include_ncells. + periodic_sum: bool + Use a sum of the distances to neighboring cells + when periodic boundary conditions are used. + Either use mic, periodic_sum, periodic_softmax, or + include_ncells. + periodic_softmax: bool + Use a softmax weighting on the distances to neighboring cells + from the squared distances when periodic boundary conditions + are used. + Either use mic, periodic_sum, periodic_softmax, or + include_ncells. + mic: bool + Minimum Image Convention (Shortest distances when + periodic boundary conditions are used). + Either use mic, periodic_sum, periodic_softmax, or + include_ncells. + mic is faster than periodic_softmax, + but the derivatives are discontinuous. + all_ncells: bool + Use all neighboring cells when calculating the distances. + cell_cutoff is used to check how many neighboring cells are + needed. + cell_cutoff: float + The cutoff distance for the neighboring cells. + It is the scaling of the maximum covalent distance. + use_cutoff: bool + Whether to use a cutoff function for the inverse distance + fingerprint. + The cutoff function is a cosine cutoff function. + rs_cutoff: float + The starting distance for the cutoff function being 1. + re_cutoff: float + The ending distance for the cutoff function being 0. + re_cutoff must be larger than rs_cutoff. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. + use_tags: bool + Use the tags of the atoms to identify the atoms as + another type. + use_sort_all: bool + Whether sort all the the combinations independently of the + pairs. + reuse_combinations: bool + Whether to reuse the combinations of the elements. + The change in the atomic numbers and tags will be checked + to see if they are unchanged. + If False, the combinations are calculated each time. + + Returns: + self: The updated instance itself. + """ + super().update_arguments( + reduce_dimensions=reduce_dimensions, + use_derivatives=use_derivatives, + wrap=wrap, + include_ncells=include_ncells, + periodic_sum=periodic_sum, + periodic_softmax=periodic_softmax, + mic=mic, + all_ncells=all_ncells, + cell_cutoff=cell_cutoff, + use_cutoff=use_cutoff, + rs_cutoff=rs_cutoff, + re_cutoff=re_cutoff, + dtype=dtype, + ) + if use_tags is not None: + self.use_tags = use_tags + if use_sort_all is not None: + self.use_sort_all = use_sort_all + if reuse_combinations is not None: + self.reuse_combinations = reuse_combinations + return self + + def get_arguments(self): + "Get the arguments of the class itself." + # Get the arguments given to the class in the initialization + arg_kwargs = dict( + reduce_dimensions=self.reduce_dimensions, + use_derivatives=self.use_derivatives, + wrap=self.wrap, + all_ncells=self.all_ncells, + cell_cutoff=self.cell_cutoff, + include_ncells=self.include_ncells, + periodic_sum=self.periodic_sum, + periodic_softmax=self.periodic_softmax, + mic=self.mic, + use_cutoff=self.use_cutoff, + rs_cutoff=self.rs_cutoff, + re_cutoff=self.re_cutoff, + dtype=self.dtype, + use_tags=self.use_tags, + use_sort_all=self.use_sort_all, + reuse_combinations=self.reuse_combinations, + ) + # Get the constants made within the class + constant_kwargs = dict() + # Get the objects made within the class + object_kwargs = dict() + return arg_kwargs, constant_kwargs, object_kwargs diff --git a/catlearn/regression/gp/fingerprint/sumdistances.py b/catlearn/regression/gp/fingerprint/sumdistances.py index a3644291..e7de44dd 100644 --- a/catlearn/regression/gp/fingerprint/sumdistances.py +++ b/catlearn/regression/gp/fingerprint/sumdistances.py @@ -1,5 +1,12 @@ -import numpy as np +from numpy import arange, asarray, zeros +from ase.data import covalent_radii from .invdistances import InvDistances +from ..fingerprint.geometry import ( + check_atoms, + get_full_distance_matrix, + get_periodic_softmax, + get_periodic_sum, +) class SumDistances(InvDistances): @@ -7,10 +14,20 @@ def __init__( self, reduce_dimensions=True, use_derivatives=True, + wrap=True, + include_ncells=False, + periodic_sum=False, periodic_softmax=True, mic=False, - wrap=True, - eps=1e-16, + all_ncells=True, + cell_cutoff=4.0, + use_cutoff=False, + rs_cutoff=3.0, + re_cutoff=4.0, + dtype=float, + use_tags=False, + use_pairs=True, + reuse_combinations=True, **kwargs, ): """ @@ -20,105 +37,523 @@ def __init__( The inverse distances are scaled with covalent radii. Parameters: - reduce_dimensions : bool + reduce_dimensions: bool Whether to reduce the fingerprint space if constrains are used. - use_derivatives : bool + use_derivatives: bool Calculate and store derivatives of the fingerprint wrt. the cartesian coordinates. - periodic_softmax : bool - Use a softmax weighting of the squared distances + wrap: bool + Whether to wrap the atoms to the unit cell or not. + include_ncells: bool + Include the neighboring cells when calculating the distances. + The fingerprint will include the neighboring cells. + include_ncells will replace periodic_softmax and mic. + Either use mic, periodic_sum, periodic_softmax, or + include_ncells. + periodic_sum: bool + Use a sum of the distances to neighboring cells when periodic boundary conditions are used. - mic : bool + Either use mic, periodic_sum, periodic_softmax, or + include_ncells. + periodic_softmax: bool + Use a softmax weighting on the distances to neighboring cells + from the squared distances when periodic boundary conditions + are used. + Either use mic, periodic_sum, periodic_softmax, or + include_ncells. + mic: bool Minimum Image Convention (Shortest distances when periodic boundary conditions are used). - Either use mic or periodic_softmax, not both. + Either use mic, periodic_sum, periodic_softmax, or + include_ncells. mic is faster than periodic_softmax, but the derivatives are discontinuous. - wrap: bool - Whether to wrap the atoms to the unit cell or not. - eps : float - Small number to avoid division by zero. + all_ncells: bool + Use all neighboring cells when calculating the distances. + cell_cutoff is used to check how many neighboring cells are + needed. + cell_cutoff: float + The cutoff distance for the neighboring cells. + It is the scaling of the maximum covalent distance. + use_cutoff: bool + Whether to use a cutoff function for the inverse distance + fingerprint. + The cutoff function is a cosine cutoff function. + rs_cutoff: float + The starting distance for the cutoff function being 1. + re_cutoff: float + The ending distance for the cutoff function being 0. + re_cutoff must be larger than rs_cutoff. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. + use_tags: bool + Use the tags of the atoms to identify the atoms as + another type. + use_pairs: bool + Whether to use pairs of elements or use all elements. + reuse_combinations: bool + Whether to reuse the combinations of the elements. + The change in the atomic numbers and tags will be checked + to see if they are unchanged. + If False, the combinations are calculated each time. """ # Set the arguments super().__init__( reduce_dimensions=reduce_dimensions, use_derivatives=use_derivatives, + wrap=wrap, + include_ncells=include_ncells, + periodic_sum=periodic_sum, periodic_softmax=periodic_softmax, mic=mic, - wrap=wrap, - eps=eps, + all_ncells=all_ncells, + cell_cutoff=cell_cutoff, + use_cutoff=use_cutoff, + rs_cutoff=rs_cutoff, + re_cutoff=re_cutoff, + dtype=dtype, + use_tags=use_tags, + use_pairs=use_pairs, + reuse_combinations=reuse_combinations, **kwargs, ) - def make_fingerprint(self, atoms, not_masked, masked, **kwargs): - "Calculate the fingerprint and its derivative." - # Set parameters of array sizes - n_atoms = len(atoms) - n_nmasked = len(not_masked) - n_masked = n_atoms - n_nmasked - n_nm_m = n_nmasked * n_masked - n_nm_nm = int(0.5 * n_nmasked * (n_nmasked - 1)) - n_total = n_nm_m + n_nm_nm - # Make indicies arrays - not_masked = np.array(not_masked, dtype=int) - masked = np.array(masked, dtype=int) - indicies = np.arange(n_atoms) - i_nm = np.arange(n_nmasked) - i_m = np.arange(n_masked) - # Calculate all the fingerprints and their derivatives - fij, gij, nmi, nmj = self.get_contributions( - atoms, + def modify_fp( + self, + fp, + g, + atomic_numbers, + tags, + not_masked, + masked, + nmi, + nmj, + nmi_ind, + nmj_ind, + use_include_ncells, + **kwargs, + ): + "Modify the fingerprint." + # Get the indicies of the atomic combinations + split_indicies_nm, split_indicies = self.element_setup( + atomic_numbers, + tags, not_masked, - masked, - i_nm, - n_total, - n_nmasked, - n_masked, - n_nm_m, + **kwargs, ) - # Get all the indicies of the interactions - indicies_nm_m, indicies_nm_nm = self.get_indicies( - n_nmasked, - n_masked, - n_total, - n_nm_m, - nmi, - nmj, + # Modify the fingerprint + if self.use_pairs: + # Use pairs of elements + fp, g = self.modify_fp_pairs( + fp=fp, + g=g, + not_masked=not_masked, + use_include_ncells=use_include_ncells, + split_indicies_nm=split_indicies_nm, + split_indicies=split_indicies, + **kwargs, + ) + else: + # Use all elements + fp, g = self.modify_fp_elements( + fp=fp, + g=g, + not_masked=not_masked, + use_include_ncells=use_include_ncells, + split_indicies_nm=split_indicies_nm, + **kwargs, + ) + return fp, g + + def modify_fp_pairs( + self, + fp, + g, + not_masked, + use_include_ncells, + split_indicies_nm, + split_indicies, + **kwargs, + ): + "Modify the fingerprint over pairs of elements." + # Sum the fingerprints and derivatives if neighboring cells are used + if use_include_ncells: + fp = fp.sum(axis=0) + if g is not None: + g = g.sum(axis=0) + # Make the new fingerprint + fp_new = zeros( + (len(split_indicies_nm), len(split_indicies)), + dtype=self.dtype, ) - # Make the arrays of fingerprints and their derivatives - f = [] - g = [] - # Get all informations of the atoms and split them into types - nmasked_indicies, masked_indicies, n_unique = self.element_setup( - atoms, - indicies, - not_masked, - masked, - i_nm, - i_m, - nm_bool=True, + # Sum the fingerprints + for i, i_v in enumerate(split_indicies_nm.values()): + fp_i = fp[i_v] + for j, j_v in enumerate(split_indicies.values()): + fp_new[i, j] = fp_i[:, j_v].sum() + fp_new = fp_new.reshape(-1) + # Calculate the new derivatives + if g is not None: + # Make the new derivatives + g_new = zeros( + ( + len(split_indicies_nm), + len(split_indicies), + len(not_masked), + 3, + ), + dtype=self.dtype, + ) + # Sum the derivatives + for i, i_v in enumerate(split_indicies_nm.values()): + g_i = g[i_v] + g_ij = g_i[:, not_masked].sum(axis=0) + for j, (comb, j_v) in enumerate(split_indicies.items()): + g_new[i, j, i_v] = g_i[:, j_v].sum(axis=1) + if comb in split_indicies_nm: + ij_comb = split_indicies_nm[comb] + g_new[i, j, ij_comb] -= g_ij[ij_comb] + g_new = g_new.reshape(-1, len(not_masked) * 3) + return fp_new, g_new + return fp_new, None + + def modify_fp_elements( + self, + fp, + g, + not_masked, + use_include_ncells, + split_indicies_nm, + **kwargs, + ): + "Modify the fingerprint over all elements." + # Sum the fingerprints and derivatives if neighboring cells are used + if use_include_ncells: + fp = fp.sum(axis=0) + if g is not None: + g = g.sum(axis=0) + # Sum the fingerprints + fp = fp.sum(axis=1) + fp = asarray( + [fp[i_v].sum() for i_v in split_indicies_nm.values()], + dtype=self.dtype, + ) + # Calculate the new derivatives + if g is not None: + g_new = zeros((len(split_indicies_nm), len(not_masked), 3)) + for i, i_v in enumerate(split_indicies_nm.values()): + g_new[i, i_v] = g[i_v].sum(axis=1) + g_new[i] -= g[i_v][:, not_masked].sum(axis=0) + g_new = g_new.reshape(-1, len(not_masked) * 3) + return fp, g_new + return fp, None + + def update_arguments( + self, + reduce_dimensions=None, + use_derivatives=None, + wrap=None, + include_ncells=None, + periodic_sum=None, + periodic_softmax=None, + mic=None, + all_ncells=None, + cell_cutoff=None, + use_cutoff=None, + rs_cutoff=None, + re_cutoff=None, + dtype=None, + use_tags=None, + use_pairs=None, + reuse_combinations=None, + **kwargs, + ): + """ + Update the class with its arguments. + The existing arguments are used if they are not given. + + Parameters: + reduce_dimensions: bool + Whether to reduce the fingerprint space if constrains are used. + use_derivatives: bool + Calculate and store derivatives of the fingerprint wrt. + the cartesian coordinates. + wrap: bool + Whether to wrap the atoms to the unit cell or not. + include_ncells: bool + Include the neighboring cells when calculating the distances. + The fingerprint will include the neighboring cells. + include_ncells will replace periodic_softmax and mic. + Either use mic, periodic_sum, periodic_softmax, or + include_ncells. + periodic_sum: bool + Use a sum of the distances to neighboring cells + when periodic boundary conditions are used. + Either use mic, periodic_sum, periodic_softmax, or + include_ncells. + periodic_softmax: bool + Use a softmax weighting on the distances to neighboring cells + from the squared distances when periodic boundary conditions + are used. + Either use mic, periodic_sum, periodic_softmax, or + include_ncells. + mic: bool + Minimum Image Convention (Shortest distances when + periodic boundary conditions are used). + Either use mic, periodic_sum, periodic_softmax, or + include_ncells. + mic is faster than periodic_softmax, + but the derivatives are discontinuous. + all_ncells: bool + Use all neighboring cells when calculating the distances. + cell_cutoff is used to check how many neighboring cells are + needed. + cell_cutoff: float + The cutoff distance for the neighboring cells. + It is the scaling of the maximum covalent distance. + use_cutoff: bool + Whether to use a cutoff function for the inverse distance + fingerprint. + The cutoff function is a cosine cutoff function. + rs_cutoff: float + The starting distance for the cutoff function being 1. + re_cutoff: float + The ending distance for the cutoff function being 0. + re_cutoff must be larger than rs_cutoff. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. + use_tags: bool + Use the tags of the atoms to identify the atoms as + another type. + use_pairs: bool + Whether to use pairs of elements or use all elements. + reuse_combinations: bool + Whether to reuse the combinations of the elements. + The change in the atomic numbers and tags will be checked + to see if they are unchanged. + If False, the combinations are calculated each time. + + Returns: + self: The updated instance itself. + """ + super().update_arguments( + reduce_dimensions=reduce_dimensions, + use_derivatives=use_derivatives, + wrap=wrap, + include_ncells=include_ncells, + periodic_sum=periodic_sum, + periodic_softmax=periodic_softmax, + mic=mic, + all_ncells=all_ncells, + cell_cutoff=cell_cutoff, + use_cutoff=use_cutoff, + rs_cutoff=rs_cutoff, + re_cutoff=re_cutoff, + dtype=dtype, ) - # Get all combinations of the atom types - combinations = zip(*np.triu_indices(n_unique, k=0, m=None)) - # Run over all combinations - for ci, cj in combinations: - # Find the indicies in the fingerprints for the combinations - indicies_comb, len_i_comb = self.get_indicies_combination( - ci, - cj, - nmasked_indicies, - masked_indicies, - indicies_nm_m, - indicies_nm_nm, + if use_tags is not None: + self.use_tags = use_tags + if use_pairs is not None: + self.use_pairs = use_pairs + if reuse_combinations is not None: + self.reuse_combinations = reuse_combinations + if not hasattr(self, "split_indicies_nm"): + self.split_indicies_nm = None + return self + + def calc_fp( + self, + dist, + dist_vec, + not_masked, + masked, + nmi, + nmj, + nmi_ind, + nmj_ind, + atomic_numbers, + tags=None, + use_include_ncells=False, + use_periodic_sum=False, + use_periodic_softmax=False, + **kwargs, + ): + "Calculate the fingerprint." + # Add small number to avoid division by zero to the distances + dist += self.eps + # Get the covalent distances + covdis = self.get_covalent_distances( + atomic_numbers=atomic_numbers, + not_masked=not_masked, + ) + # Check if the distances include the neighboring cells + if use_include_ncells or use_periodic_sum or use_periodic_softmax: + covdis = covdis[None, ...] + # Get the index of the not masked atoms + i_nm = arange(len(not_masked)) + # Calculate the inverse distances + fp = covdis / dist + # Check what distance method should be used + if use_periodic_softmax: + # Calculate the fingerprint with the periodic softmax + fp, g = get_periodic_softmax( + dist_eps=dist, + dist_vec=dist_vec, + fpinner=fp, + covdis=covdis, + use_inv_dis=True, + use_derivatives=self.use_derivatives, + eps=self.eps, + **kwargs, ) - if len_i_comb: - # Sum the fingerprints for the combinations - f, g = self.sum_fp(f, g, fij, gij, indicies_comb) - return np.array(f), np.array(g) + elif use_periodic_sum: + # Calculate the fingerprint with the periodic sum + fp, g = get_periodic_sum( + dist_eps=dist, + dist_vec=dist_vec, + fpinner=fp, + use_inv_dis=True, + use_derivatives=self.use_derivatives, + **kwargs, + ) + else: + # Get the derivative of the fingerprint + if self.use_derivatives: + g = dist_vec * (fp / (dist**2))[..., None] + else: + g = None + # Apply the cutoff function + if self.use_cutoff: + fp, g = self.apply_cutoff(fp, g, **kwargs) + # Remove self interaction + fp[..., i_nm, not_masked] = 0.0 + # Update the fingerprint with the modification + fp, g = self.modify_fp( + fp=fp, + g=g, + atomic_numbers=atomic_numbers, + tags=tags, + not_masked=not_masked, + masked=masked, + nmi=nmi, + nmj=nmj, + nmi_ind=nmi_ind, + nmj_ind=nmj_ind, + use_include_ncells=use_include_ncells, + **kwargs, + ) + return fp, g + + def get_distances( + self, + atoms, + not_masked=None, + masked=None, + nmi=None, + nmj=None, + nmi_ind=None, + nmj_ind=None, + use_vector=False, + include_ncells=False, + mic=False, + **kwargs, + ): + """ + Get the distances and their vectors. + """ + return get_full_distance_matrix( + atoms=atoms, + not_masked=not_masked, + use_vector=use_vector, + wrap=self.wrap, + include_ncells=include_ncells, + mic=mic, + all_ncells=self.all_ncells, + cell_cutoff=self.cell_cutoff, + dtype=self.dtype, + ) + + def get_covalent_distances(self, atomic_numbers, not_masked): + "Get the covalent distances of the atoms." + cov_dis = covalent_radii[atomic_numbers] + return asarray(cov_dis + cov_dis[not_masked, None], dtype=self.dtype) - def sum_fp(self, f, g, fij, gij, indicies_comb, **kwargs): - "Sum of the fingerprints." - f.append(np.sum(fij[indicies_comb])) - if self.use_derivatives: - g.append(np.sum(gij[indicies_comb], axis=0)) - return f, g + def element_setup( + self, + atomic_numbers, + tags, + not_masked, + **kwargs, + ): + """ + Get all informations of the atom combinations and split them + into types. + """ + # Check if the atomic setup is the same + if self.reuse_combinations: + if ( + self.atomic_numbers is not None + or self.not_masked is not None + or self.tags is not None + or self.split_indicies is not None + or self.split_indicies_nm is not None + ): + atoms_equal = check_atoms( + atomic_numbers=self.atomic_numbers, + atomic_numbers_test=atomic_numbers, + tags=self.tags, + tags_test=tags, + not_masked=self.not_masked, + not_masked_test=not_masked, + **kwargs, + ) + if atoms_equal: + return self.split_indicies_nm, self.split_indicies + # Save the atomic numbers and tags + self.atomic_numbers = atomic_numbers + self.tags = tags + self.not_masked = not_masked + # Get the atomic types of the atoms + if not self.use_tags: + tags = zeros((len(atomic_numbers)), dtype=int) + combis = list(zip(atomic_numbers, tags)) + split_indicies = {} + for i, combi in enumerate(combis): + split_indicies.setdefault(combi, []).append(i) + self.split_indicies = split_indicies + # Get the atomic types of the not masked atoms + combis = list(zip(atomic_numbers[not_masked], tags[not_masked])) + split_indicies_nm = {} + for i, combi in enumerate(combis): + split_indicies_nm.setdefault(combi, []).append(i) + self.split_indicies_nm = split_indicies_nm + return split_indicies_nm, split_indicies + + def get_arguments(self): + "Get the arguments of the class itself." + # Get the arguments given to the class in the initialization + arg_kwargs = dict( + reduce_dimensions=self.reduce_dimensions, + use_derivatives=self.use_derivatives, + wrap=self.wrap, + include_ncells=self.include_ncells, + periodic_sum=self.periodic_sum, + periodic_softmax=self.periodic_softmax, + mic=self.mic, + all_ncells=self.all_ncells, + cell_cutoff=self.cell_cutoff, + use_cutoff=self.use_cutoff, + rs_cutoff=self.rs_cutoff, + re_cutoff=self.re_cutoff, + dtype=self.dtype, + use_tags=self.use_tags, + use_pairs=self.use_pairs, + reuse_combinations=self.reuse_combinations, + ) + # Get the constants made within the class + constant_kwargs = dict() + # Get the objects made within the class + object_kwargs = dict() + return arg_kwargs, constant_kwargs, object_kwargs diff --git a/catlearn/regression/gp/fingerprint/sumdistancespower.py b/catlearn/regression/gp/fingerprint/sumdistancespower.py index 73e37233..6923de6b 100644 --- a/catlearn/regression/gp/fingerprint/sumdistancespower.py +++ b/catlearn/regression/gp/fingerprint/sumdistancespower.py @@ -1,4 +1,4 @@ -import numpy as np +from numpy import zeros from .sumdistances import SumDistances @@ -7,11 +7,21 @@ def __init__( self, reduce_dimensions=True, use_derivatives=True, + wrap=True, + include_ncells=False, + periodic_sum=False, periodic_softmax=True, mic=False, - wrap=True, - eps=1e-16, - power=2, + all_ncells=True, + cell_cutoff=4.0, + use_cutoff=False, + rs_cutoff=3.0, + re_cutoff=4.0, + dtype=float, + use_tags=False, + use_pairs=True, + reuse_combinations=True, + power=4, use_roots=True, **kwargs, ): @@ -23,24 +33,66 @@ def __init__( The inverse distances are scaled with covalent radii. Parameters: - reduce_dimensions : bool + reduce_dimensions: bool Whether to reduce the fingerprint space if constrains are used. - use_derivatives : bool + use_derivatives: bool Calculate and store derivatives of the fingerprint wrt. the cartesian coordinates. - periodic_softmax : bool - Use a softmax weighting of the squared distances + wrap: bool + Whether to wrap the atoms to the unit cell or not. + include_ncells: bool + Include the neighboring cells when calculating the distances. + The fingerprint will include the neighboring cells. + include_ncells will replace periodic_softmax and mic. + Either use mic, periodic_sum, periodic_softmax, or + include_ncells. + periodic_sum: bool + Use a sum of the distances to neighboring cells when periodic boundary conditions are used. - mic : bool + Either use mic, periodic_sum, periodic_softmax, or + include_ncells. + periodic_softmax: bool + Use a softmax weighting on the distances to neighboring cells + from the squared distances when periodic boundary conditions + are used. + Either use mic, periodic_sum, periodic_softmax, or + include_ncells. + mic: bool Minimum Image Convention (Shortest distances when periodic boundary conditions are used). - Either use mic or periodic_softmax, not both. + Either use mic, periodic_sum, periodic_softmax, or + include_ncells. mic is faster than periodic_softmax, but the derivatives are discontinuous. - wrap: bool - Whether to wrap the atoms to the unit cell or not. - eps : float - Small number to avoid division by zero. + all_ncells: bool + Use all neighboring cells when calculating the distances. + cell_cutoff is used to check how many neighboring cells are + needed. + cell_cutoff: float + The cutoff distance for the neighboring cells. + It is the scaling of the maximum covalent distance. + use_cutoff: bool + Whether to use a cutoff function for the inverse distance + fingerprint. + The cutoff function is a cosine cutoff function. + rs_cutoff: float + The starting distance for the cutoff function being 1. + re_cutoff: float + The ending distance for the cutoff function being 0. + re_cutoff must be larger than rs_cutoff. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. + use_tags: bool + Use the tags of the atoms to identify the atoms as + another type. + use_pairs: bool + Whether to use pairs of elements or use all elements. + reuse_combinations: bool + Whether to reuse the combinations of the elements. + The change in the atomic numbers and tags will be checked + to see if they are unchanged. + If False, the combinations are calculated each time. power: int The power of the inverse distances. use_roots: bool @@ -50,23 +102,225 @@ def __init__( super().__init__( reduce_dimensions=reduce_dimensions, use_derivatives=use_derivatives, + wrap=wrap, + include_ncells=include_ncells, + periodic_sum=periodic_sum, periodic_softmax=periodic_softmax, mic=mic, - wrap=wrap, - eps=eps, + all_ncells=all_ncells, + cell_cutoff=cell_cutoff, + use_cutoff=use_cutoff, + rs_cutoff=rs_cutoff, + re_cutoff=re_cutoff, + dtype=dtype, + use_tags=use_tags, + use_pairs=use_pairs, + reuse_combinations=reuse_combinations, power=power, use_roots=use_roots, **kwargs, ) + def modify_fp( + self, + fp, + g, + atomic_numbers, + tags, + not_masked, + masked, + nmi, + nmj, + nmi_ind, + nmj_ind, + use_include_ncells, + **kwargs, + ): + # Get the indicies of the atomic combinations + split_indicies_nm, split_indicies = self.element_setup( + atomic_numbers, + tags, + not_masked, + **kwargs, + ) + # Get the number of atomic combinations + if self.use_pairs: + fp_len = len(split_indicies_nm) * len(split_indicies) + else: + fp_len = len(split_indicies_nm) + # Create the new fingerprint and derivatives + fp_new = zeros( + (fp_len, self.power), + dtype=self.dtype, + ) + g_new = zeros( + ( + fp_len, + self.power, + 3 * len(not_masked), + ), + dtype=self.dtype, + ) + # Loop over the powers + for p in range(self.power): + power = p + 1 + if power > 1: + # Calculate the power of the inverse distances at power > 1 + fp_new[:, p], g_new[:, p] = self.modify_fp_powers( + fp=fp, + g=g, + not_masked=not_masked, + masked=masked, + nmi=nmi, + nmj=nmj, + use_include_ncells=use_include_ncells, + split_indicies_nm=split_indicies_nm, + split_indicies=split_indicies, + power=power, + ) + else: + # Special case for power equal to 1 + fp_new[:, p], g_new[:, p] = self.modify_fp_power1( + fp=fp, + g=g, + not_masked=not_masked, + masked=masked, + nmi=nmi, + nmj=nmj, + use_include_ncells=use_include_ncells, + split_indicies_nm=split_indicies_nm, + split_indicies=split_indicies, + ) + # Reshape fingerprint and derivatives + fp_new = fp_new.reshape(-1) + # Return the new fingerprint and derivatives + if g is not None: + g_new = g_new.reshape(-1, 3 * len(not_masked)) + return fp_new, g_new + return fp_new, None + + def modify_fp_power1( + self, + fp, + g, + not_masked, + masked, + nmi, + nmj, + use_include_ncells, + split_indicies_nm, + split_indicies, + **kwargs, + ): + """ + Calculate the sum of the inverse distances at power = 1 + for each sets of atomic combinations. + """ + # Modify the fingerprint + if self.use_pairs: + # Use pairs of elements + fp_new, g_new = self.modify_fp_pairs( + fp=fp, + g=g, + not_masked=not_masked, + use_include_ncells=use_include_ncells, + split_indicies_nm=split_indicies_nm, + split_indicies=split_indicies, + **kwargs, + ) + else: + # Use all elements + fp_new, g_new = self.modify_fp_elements( + fp=fp, + g=g, + not_masked=not_masked, + use_include_ncells=use_include_ncells, + split_indicies_nm=split_indicies_nm, + **kwargs, + ) + # Add a small number to avoid division by zero + fp_new += self.eps + return fp_new, g_new + + def modify_fp_powers( + self, + fp, + g, + not_masked, + masked, + nmi, + nmj, + use_include_ncells, + split_indicies_nm, + split_indicies, + power, + **kwargs, + ): + """ + Calculate the sum of the inverse distances at power > 1 + for each sets of atomic combinations. + """ + # Calculate the power of the inverse distances + fp_new = fp**power + # Calculate the derivatives + if g is not None: + g_new = (fp ** (power - 1))[..., None] * g + else: + g_new = None + # Modify the fingerprint + if self.use_pairs: + # Use pairs of elements + fp_new, g_new = self.modify_fp_pairs( + fp=fp_new, + g=g_new, + not_masked=not_masked, + use_include_ncells=use_include_ncells, + split_indicies_nm=split_indicies_nm, + split_indicies=split_indicies, + **kwargs, + ) + else: + # Use all elements + fp_new, g_new = self.modify_fp_elements( + fp=fp_new, + g=g_new, + not_masked=not_masked, + use_include_ncells=use_include_ncells, + split_indicies_nm=split_indicies_nm, + **kwargs, + ) + # Add a small number to avoid division by zero + fp_new += self.eps + # Calculate the root of the sum + if self.use_roots: + if g is not None: + mroot = (1.0 / power) - 1.0 + g_new = g_new * (fp_new**mroot)[..., None] + root = 1.0 / power + fp_new = fp_new**root + else: + if g is not None: + g_new *= power + return fp_new, g_new + def update_arguments( self, reduce_dimensions=None, use_derivatives=None, + wrap=None, + include_ncells=None, + periodic_sum=None, periodic_softmax=None, mic=None, - wrap=None, - eps=None, + all_ncells=None, + cell_cutoff=None, + use_cutoff=None, + rs_cutoff=None, + re_cutoff=None, + dtype=None, + use_tags=None, + use_pairs=None, + reuse_combinations=None, power=None, use_roots=None, **kwargs, @@ -76,24 +330,66 @@ def update_arguments( The existing arguments are used if they are not given. Parameters: - reduce_dimensions : bool + reduce_dimensions: bool Whether to reduce the fingerprint space if constrains are used. - use_derivatives : bool + use_derivatives: bool Calculate and store derivatives of the fingerprint wrt. the cartesian coordinates. - periodic_softmax : bool - Use a softmax weighting of the squared distances + wrap: bool + Whether to wrap the atoms to the unit cell or not. + include_ncells: bool + Include the neighboring cells when calculating the distances. + The fingerprint will include the neighboring cells. + include_ncells will replace periodic_softmax and mic. + Either use mic, periodic_sum, periodic_softmax, or + include_ncells. + periodic_sum: bool + Use a sum of the distances to neighboring cells when periodic boundary conditions are used. - mic : bool + Either use mic, periodic_sum, periodic_softmax, or + include_ncells. + periodic_softmax: bool + Use a softmax weighting on the distances to neighboring cells + from the squared distances when periodic boundary conditions + are used. + Either use mic, periodic_sum, periodic_softmax, or + include_ncells. + mic: bool Minimum Image Convention (Shortest distances when periodic boundary conditions are used). - Either use mic or periodic_softmax, not both. + Either use mic, periodic_sum, periodic_softmax, or + include_ncells. mic is faster than periodic_softmax, but the derivatives are discontinuous. - wrap: bool - Whether to wrap the atoms to the unit cell or not. - eps : float - Small number to avoid division by zero. + all_ncells: bool + Use all neighboring cells when calculating the distances. + cell_cutoff is used to check how many neighboring cells are + needed. + cell_cutoff: float + The cutoff distance for the neighboring cells. + It is the scaling of the maximum covalent distance. + use_cutoff: bool + Whether to use a cutoff function for the inverse distance + fingerprint. + The cutoff function is a cosine cutoff function. + rs_cutoff: float + The starting distance for the cutoff function being 1. + re_cutoff: float + The ending distance for the cutoff function being 0. + re_cutoff must be larger than rs_cutoff. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. + use_tags: bool + Use the tags of the atoms to identify the atoms as + another type. + use_pairs: bool + Whether to use pairs of elements or use all elements. + reuse_combinations: bool + Whether to reuse the combinations of the elements. + The change in the atomic numbers and tags will be checked + to see if they are unchanged. + If False, the combinations are calculated each time. power: int The power of the inverse distances. use_roots: bool @@ -102,139 +398,50 @@ def update_arguments( Returns: self: The updated instance itself. """ - if reduce_dimensions is not None: - self.reduce_dimensions = reduce_dimensions - if use_derivatives is not None: - self.use_derivatives = use_derivatives - if periodic_softmax is not None: - self.periodic_softmax = periodic_softmax - if mic is not None: - self.mic = mic - if wrap is not None: - self.wrap = wrap - if eps is not None: - self.eps = abs(float(eps)) + super().update_arguments( + reduce_dimensions=reduce_dimensions, + use_derivatives=use_derivatives, + wrap=wrap, + include_ncells=include_ncells, + periodic_sum=periodic_sum, + periodic_softmax=periodic_softmax, + mic=mic, + all_ncells=all_ncells, + cell_cutoff=cell_cutoff, + use_cutoff=use_cutoff, + rs_cutoff=rs_cutoff, + re_cutoff=re_cutoff, + dtype=dtype, + use_tags=use_tags, + use_pairs=use_pairs, + reuse_combinations=reuse_combinations, + ) if power is not None: self.power = int(power) if use_roots is not None: self.use_roots = use_roots return self - def make_fingerprint(self, atoms, not_masked, masked, **kwargs): - "Calculate the fingerprint and its derivative." - # Set parameters of array sizes - n_atoms = len(atoms) - n_nmasked = len(not_masked) - n_masked = n_atoms - n_nmasked - n_nm_m = n_nmasked * n_masked - n_nm_nm = int(0.5 * n_nmasked * (n_nmasked - 1)) - n_total = n_nm_m + n_nm_nm - # Make indicies arrays - not_masked = np.array(not_masked, dtype=int) - masked = np.array(masked, dtype=int) - indicies = np.arange(n_atoms) - i_nm = np.arange(n_nmasked) - i_m = np.arange(n_masked) - # Calculate all the fingerprints and their derivatives - fij, gij, nmi, nmj = self.get_contributions( - atoms, - not_masked, - masked, - i_nm, - n_total, - n_nmasked, - n_masked, - n_nm_m, - ) - # Get all the indicies of the interactions - indicies_nm_m, indicies_nm_nm = self.get_indicies( - n_nmasked, - n_masked, - n_total, - n_nm_m, - nmi, - nmj, - ) - # Make the arrays of fingerprints and their derivatives - f = [] - g = [] - # Get all informations of the atoms and split them into types - nmasked_indicies, masked_indicies, n_unique = self.element_setup( - atoms, - indicies, - not_masked, - masked, - i_nm, - i_m, - nm_bool=True, - ) - # Get all combinations of the atom types - combinations = zip(*np.triu_indices(n_unique, k=0, m=None)) - # Run over all combinations - for ci, cj in combinations: - # Find the indicies in the fingerprints for the combinations - indicies_comb, len_i_comb = self.get_indicies_combination( - ci, - cj, - nmasked_indicies, - masked_indicies, - indicies_nm_m, - indicies_nm_nm, - ) - if len_i_comb: - # Sum the fingerprints for the combinations - f, g = self.sum_fp_power( - f, - g, - fij, - gij, - indicies_comb, - len_i_comb, - ) - return np.array(f), np.array(g) - - def sum_fp_power( - self, - f, - g, - fij, - gij, - indicies_comb, - len_i_comb, - **kwargs, - ): - "Sum of the fingerprints." - powers = np.arange(1, self.power + 1) - fij_powers = fij[indicies_comb].reshape(-1, 1) ** powers - fij_sums = np.sum(fij_powers, axis=0) - if self.use_roots: - f.extend(fij_sums ** (1.0 / powers)) - else: - f.extend(fij_sums) - if self.use_derivatives: - g.append(np.sum(gij[indicies_comb], axis=0)) - fg_prod = np.sum( - fij_powers[:, :-1].T.reshape(self.power - 1, len_i_comb, 1) - * gij[indicies_comb], - axis=1, - ) - if self.use_roots: - fpowers = (1.0 - powers[1:]) / powers[1:] - g.extend(fg_prod * (fij_sums[1:] ** fpowers).reshape(-1, 1)) - else: - g.extend(powers[1:].reshape(-1, 1) * fg_prod) - return f, g - def get_arguments(self): "Get the arguments of the class itself." # Get the arguments given to the class in the initialization arg_kwargs = dict( reduce_dimensions=self.reduce_dimensions, use_derivatives=self.use_derivatives, + wrap=self.wrap, + include_ncells=self.include_ncells, + periodic_sum=self.periodic_sum, periodic_softmax=self.periodic_softmax, mic=self.mic, - wrap=self.wrap, - eps=self.eps, + all_ncells=self.all_ncells, + cell_cutoff=self.cell_cutoff, + use_cutoff=self.use_cutoff, + rs_cutoff=self.rs_cutoff, + re_cutoff=self.re_cutoff, + dtype=self.dtype, + use_tags=self.use_tags, + use_pairs=self.use_pairs, + reuse_combinations=self.reuse_combinations, power=self.power, use_roots=self.use_roots, ) From 85869c9c58e5222095ec3101dfbf7032877c53f4 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Thu, 15 May 2025 11:45:45 +0200 Subject: [PATCH 106/194] Informative and rounded values in MLModel --- catlearn/regression/gp/calculator/mlmodel.py | 174 +++++++++++++++++-- 1 file changed, 155 insertions(+), 19 deletions(-) diff --git a/catlearn/regression/gp/calculator/mlmodel.py b/catlearn/regression/gp/calculator/mlmodel.py index 4d1abbd6..2b0320e0 100644 --- a/catlearn/regression/gp/calculator/mlmodel.py +++ b/catlearn/regression/gp/calculator/mlmodel.py @@ -1,5 +1,6 @@ from numpy import asarray, ndarray, sqrt, zeros import warnings +from ase.parallel import parprint class MLModel: @@ -13,7 +14,7 @@ def __init__( pdis=None, include_noise=False, verbose=False, - dtype=None, + dtype=float, **kwargs, ): """ @@ -45,10 +46,10 @@ def __init__( """ # Make default model if it is not given if model is None: - model = get_default_model() + model = get_default_model(dtype=dtype) # Make default database if it is not given if database is None: - database = get_default_database() + database = get_default_database(dtype=dtype) # Set the arguments self.update_arguments( model=model, @@ -310,7 +311,7 @@ def update_arguments( if verbose is not None: self.verbose = verbose if dtype is not None or not hasattr(self, "dtype"): - self.dtype = dtype + self.set_dtype(dtype=dtype) # Check if the baseline is used if self.baseline is None: self.use_baseline = False @@ -335,8 +336,6 @@ def model_optimization(self, features, targets, **kwargs): **kwargs, ) if self.verbose: - from ase.parallel import parprint - parprint(sol) return self.model @@ -356,10 +355,10 @@ def model_prediction( ): "Predict the targets and uncertainties." # Calculate fingerprint - fp = self.database.make_atoms_feature(atoms) + fp = self.make_atoms_feature(atoms) # Calculate energy, forces, and uncertainty y, var, var_deriv = self.model.predict( - asarray([fp], dtype=self.dtype), + fp, get_derivatives=get_forces, get_variance=get_uncertainty, include_noise=self.include_noise, @@ -447,7 +446,11 @@ def store_results( return results def add_baseline_correction( - self, targets, atoms, use_derivatives=True, **kwargs + self, + targets, + atoms, + use_derivatives=True, + **kwargs, ): "Add the baseline correction to the targets if a baseline is used." if self.use_baseline: @@ -545,6 +548,102 @@ def get_constraints(self, atoms, **kwargs): not_masked = self.database.get_constraints(atoms, **kwargs) return natoms, not_masked + def set_seed(self, seed=None, **kwargs): + """ + Set the random seed. + + Parameters: + seed: int (optional) + The random seed. + The seed can be an integer, RandomState, or Generator instance. + If not given, the default random number generator is used. + + Returns: + self: The instance itself. + """ + # Set the random seed for the database + self.database.set_seed(seed) + # Set the random seed for the model + self.model.set_seed(seed) + return self + + def set_dtype(self, dtype, **kwargs): + """ + Set the data type of the arrays. + + Parameters: + dtype: type + The data type of the arrays. + + Returns: + self: The updated object itself. + """ + # Set the data type + self.dtype = dtype + # Set the data type of the model and database + self.model.set_dtype(dtype=dtype, **kwargs) + self.database.set_dtype(dtype=dtype, **kwargs) + # Set the data type of the baseline if it is used + if self.baseline is not None: + self.baseline.set_dtype(dtype=dtype, **kwargs) + # Set the data type of the prior distributions if they are used + if self.pdis is not None: + for pdis in self.pdis.values(): + pdis.set_dtype(dtype=dtype, **kwargs) + return self + + def set_use_fingerprint(self, use_fingerprint, **kwargs): + """ + Set whether to use fingerprints in the model and database. + + Parameters: + use_fingerprint: bool + Whether to use fingerprints in the model and database. + + Returns: + self: The updated object itself. + """ + self.model.set_use_fingerprint(use_fingerprint=use_fingerprint) + self.database.set_use_fingerprint(use_fingerprint=use_fingerprint) + return self + + def set_use_derivatives(self, use_derivatives, **kwargs): + """ + Set whether to use derivatives in the model and database. + + Parameters: + use_derivatives: bool + Whether to use derivatives in the model and database. + + Returns: + self: The updated object itself. + """ + self.model.set_use_derivatives(use_derivatives=use_derivatives) + self.database.set_use_derivatives(use_derivatives=use_derivatives) + # Set the data type of the baseline if it is used + if self.baseline is not None: + self.baseline.set_use_forces(use_derivatives) + return self + + def make_atoms_feature(self, atoms, **kwargs): + """ + Make the feature or fingerprint of a single Atoms object. + It can e.g. be used for predicting. + + Parameters: + atoms: ASE Atoms + The ASE Atoms object with a calculator. + + Returns: + array of fingerprint object: The fingerprint object of the + Atoms object. + or + array: The feature or fingerprint array of the Atoms object. + """ + # Calculate fingerprint + fp = self.database.make_atoms_feature(atoms, **kwargs) + return asarray([fp]) + def check_attributes(self): "Check if all attributes agree between the class and subclasses." if ( @@ -617,6 +716,8 @@ def get_default_model( global_optimization=True, parallel=False, n_reduced=None, + round_hp=3, + dtype=float, **kwargs, ): """ @@ -643,6 +744,11 @@ def get_default_model( If n_reduced is an integer, the hyperparameters are only optimized when the data set size is equal to or below the integer. If n_reduced is None, the hyperparameter is always optimized. + round_hp: int (optional) + The number of decimals to round the hyperparameters to. + If None, the hyperparameters are not rounded. + dtype: type + The data type of the arrays. Returns: model: Model @@ -676,6 +782,7 @@ def get_default_model( kernel = SE( use_fingerprint=use_fingerprint, use_derivatives=use_derivatives, + dtype=dtype, ) # Set the hyperparameter optimization method if global_optimization: @@ -691,6 +798,7 @@ def get_default_model( ngrid=80, loops=3, parallel=True, + dtype=dtype, ) else: from ..optimizers.linesearcher import GoldenSearch @@ -699,12 +807,14 @@ def get_default_model( optimize=True, multiple_min=False, parallel=False, + dtype=dtype, ) optimizer = FactorizedOptimizer( line_optimizer=line_optimizer, ngrid=80, calculate_init=False, parallel=parallel, + dtype=dtype, ) else: from ..optimizers.localoptimizer import ScipyOptimizer @@ -716,6 +826,7 @@ def get_default_model( method="l-bfgs-b", use_bounds=False, tol=1e-12, + dtype=dtype, ) if parallel: warnings.warn( @@ -732,7 +843,8 @@ def get_default_model( kernel=kernel, use_derivatives=use_derivatives, a=1e-4, - b=2.0, + b=10.0, + dtype=dtype, ) # Set objective function if global_optimization: @@ -753,6 +865,7 @@ def get_default_model( prior=prior, kernel=kernel, use_derivatives=use_derivatives, + dtype=dtype, ) # Set objective function if global_optimization: @@ -760,16 +873,21 @@ def get_default_model( FactorizedLogLikelihood, ) - func = FactorizedLogLikelihood() + func = FactorizedLogLikelihood(dtype=dtype) else: from ..objectivefunctions.gp.likelihood import LogLikelihood - func = LogLikelihood() + func = LogLikelihood(dtype=dtype) # Set hpfitter and whether a maximum data set size is applied if n_reduced is None: from ..hpfitter import HyperparameterFitter - hpfitter = HyperparameterFitter(func=func, optimizer=optimizer) + hpfitter = HyperparameterFitter( + func=func, + optimizer=optimizer, + round_hp=round_hp, + dtype=dtype, + ) else: from ..hpfitter.redhpfitter import ReducedHyperparameterFitter @@ -777,6 +895,8 @@ def get_default_model( func=func, optimizer=optimizer, opt_tr_size=n_reduced, + round_hp=round_hp, + dtype=dtype, ) model.update_arguments(hpfitter=hpfitter) return model @@ -787,8 +907,8 @@ def get_default_database( use_derivatives=True, database_reduction=False, database_reduction_kwargs={}, - round_targets=None, - dtype=None, + round_targets=5, + dtype=float, **kwargs, ): """ @@ -806,6 +926,11 @@ def get_default_database( database_reduction_kwargs: dict A dictionary with the arguments for the reduced database if it is used. + round_targets: int (optional) + The number of decimals to round the targets to. + If None, the targets are not rounded. + dtype: type + The data type of the arrays. Returns: database: Database object @@ -895,9 +1020,10 @@ def get_default_mlmodel( n_reduced=None, database_reduction=False, database_reduction_kwargs={}, - round_targets=None, + round_targets=5, + round_hp=3, verbose=False, - dtype=None, + dtype=float, **kwargs, ): """ @@ -937,8 +1063,16 @@ def get_default_mlmodel( database_reduction_kwargs: dict A dictionary with the arguments for the reduced database if it is used. + round_targets: int (optional) + The number of decimals to round the targets to. + If None, the targets are not rounded. + round_hp: int (optional) + The number of decimals to round the hyperparameters to. + If None, the hyperparameters are not rounded. verbose: bool Whether to print statements in the optimization. + dtype: type + The data type of the arrays. Returns: mlmodel: MLModel class object @@ -959,6 +1093,8 @@ def get_default_mlmodel( global_optimization=global_optimization, parallel=parallel, n_reduced=n_reduced, + round_hp=round_hp, + dtype=dtype, ) # Make the database database = get_default_database( @@ -974,8 +1110,8 @@ def get_default_mlmodel( from ..pdistributions.normal import Normal_prior pdis = dict( - length=Normal_prior(mu=[-0.8], std=[0.2]), - noise=Normal_prior(mu=[-6.0], std=[0.2]), + length=Normal_prior(mu=[-1.0], std=[0.1], dtype=dtype), + noise=Normal_prior(mu=[-6.0], std=[0.1], dtype=dtype), ) else: pdis = None From 7143a1f5c4f30b227c2e5ab750ec771f4afbf16f Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Thu, 15 May 2025 11:47:48 +0200 Subject: [PATCH 107/194] Use dtype, seed, update arguments, and import from scipy and numpy. Debug. --- catlearn/regression/gp/calculator/bocalc.py | 40 +- .../regression/gp/calculator/copy_atoms.py | 6 +- catlearn/regression/gp/calculator/database.py | 182 +- .../gp/calculator/database_reduction.py | 231 ++- .../regression/gp/calculator/hiermodel.py | 52 +- catlearn/regression/gp/calculator/mlcalc.py | 45 +- .../gp/ensemble/clustering/clustering.py | 89 +- .../gp/ensemble/clustering/fixed.py | 56 +- .../gp/ensemble/clustering/k_means.py | 96 +- .../gp/ensemble/clustering/k_means_auto.py | 108 +- .../gp/ensemble/clustering/k_means_number.py | 109 +- .../gp/ensemble/clustering/random.py | 69 +- .../gp/ensemble/clustering/random_number.py | 66 +- catlearn/regression/gp/ensemble/ensemble.py | 387 +++-- .../gp/ensemble/ensemble_clustering.py | 83 +- catlearn/regression/gp/hpboundary/boundary.py | 221 ++- catlearn/regression/gp/hpboundary/educated.py | 103 +- catlearn/regression/gp/hpboundary/hptrans.py | 299 ++-- catlearn/regression/gp/hpboundary/length.py | 132 +- .../regression/gp/hpboundary/restricted.py | 48 +- catlearn/regression/gp/hpboundary/strict.py | 53 +- .../regression/gp/hpboundary/updatebounds.py | 103 +- catlearn/regression/gp/hpfitter/fbpmgp.py | 107 +- catlearn/regression/gp/hpfitter/hpfitter.py | 103 +- .../regression/gp/hpfitter/redhpfitter.py | 60 +- catlearn/regression/gp/kernel/kernel.py | 134 +- catlearn/regression/gp/kernel/se.py | 183 +- catlearn/regression/gp/means/constant.py | 25 +- catlearn/regression/gp/means/first.py | 10 +- catlearn/regression/gp/means/max.py | 13 +- catlearn/regression/gp/means/mean.py | 13 +- catlearn/regression/gp/means/median.py | 12 +- catlearn/regression/gp/means/min.py | 13 +- catlearn/regression/gp/means/prior.py | 41 +- catlearn/regression/gp/models/gp.py | 91 +- catlearn/regression/gp/models/model.py | 330 ++-- catlearn/regression/gp/models/tp.py | 126 +- .../regression/gp/objectivefunctions/batch.py | 118 +- .../gp/objectivefunctions/best_batch.py | 30 +- .../objectivefunctions/gp/factorized_gpp.py | 112 +- .../gp/factorized_likelihood.py | 122 +- .../gp/factorized_likelihood_svd.py | 18 +- .../gp/objectivefunctions/gp/gpe.py | 51 +- .../gp/objectivefunctions/gp/gpp.py | 60 +- .../gp/objectivefunctions/gp/likelihood.py | 30 +- .../gp/objectivefunctions/gp/loo.py | 67 +- .../gp/objectivefunctions/gp/mle.py | 65 +- .../objectivefunctions/objectivefunction.py | 147 +- .../tp/factorized_likelihood.py | 69 +- .../tp/factorized_likelihood_svd.py | 18 +- .../gp/objectivefunctions/tp/likelihood.py | 33 +- catlearn/regression/gp/optimizers/__init__.py | 2 + .../gp/optimizers/globaloptimizer.py | 1523 +++++++++++------ .../regression/gp/optimizers/linesearcher.py | 552 +++--- .../gp/optimizers/localoptimizer.py | 283 ++- .../regression/gp/optimizers/noisesearcher.py | 231 ++- .../regression/gp/optimizers/optimizer.py | 276 ++- .../regression/gp/pdistributions/gamma.py | 54 +- .../gp/pdistributions/gen_normal.py | 48 +- .../regression/gp/pdistributions/invgamma.py | 55 +- .../regression/gp/pdistributions/normal.py | 40 +- .../gp/pdistributions/pdistributions.py | 38 +- .../regression/gp/pdistributions/uniform.py | 72 +- .../gp/pdistributions/update_pdis.py | 18 +- 64 files changed, 5342 insertions(+), 2629 deletions(-) diff --git a/catlearn/regression/gp/calculator/bocalc.py b/catlearn/regression/gp/calculator/bocalc.py index 0f44e10a..945d9dd4 100644 --- a/catlearn/regression/gp/calculator/bocalc.py +++ b/catlearn/regression/gp/calculator/bocalc.py @@ -212,23 +212,31 @@ def update_arguments( Returns: self: The updated object itself. """ - if mlmodel is not None: - self.mlmodel = mlmodel.copy() - if calc_forces is not None: - self.calc_forces = calc_forces - if calc_unc is not None: - self.calc_unc = calc_unc - if calc_force_unc is not None: - self.calc_force_unc = calc_force_unc - if calc_unc_deriv is not None: - self.calc_unc_deriv = calc_unc_deriv - if calc_kwargs is not None: - self.calc_kwargs = calc_kwargs.copy() - if round_pred is not None or not hasattr(self, "round_pred"): - self.round_pred = round_pred + # Set the parameters in the parent class + super().update_arguments( + mlmodel=mlmodel, + calc_forces=calc_forces, + calc_unc=calc_unc, + calc_force_unc=calc_force_unc, + calc_unc_deriv=calc_unc_deriv, + calc_kwargs=calc_kwargs, + round_pred=round_pred, + ) + # Set the kappa value if kappa is not None: - self.kappa = float(kappa) - # Empty the results + self.set_kappa(kappa) + return self + + def set_kappa(self, kappa, **kwargs): + """ + Set the kappa value. + The kappa value is used to calculate the acquisition function. + + Parameters: + kappa: float + The weight of the uncertainty relative to the energy. + """ + self.kappa = float(kappa) self.reset() return self diff --git a/catlearn/regression/gp/calculator/copy_atoms.py b/catlearn/regression/gp/calculator/copy_atoms.py index 2b62e6dc..96031a30 100644 --- a/catlearn/regression/gp/calculator/copy_atoms.py +++ b/catlearn/regression/gp/calculator/copy_atoms.py @@ -48,13 +48,13 @@ def __init__( super().__init__() self.results = {} # Save the properties - for property, value in results.items(): + for prop, value in results.items(): if value is None: continue elif isinstance(value, (float, int)): - self.results[property] = value + self.results[prop] = value else: - self.results[property] = array(value, dtype=dtype) + self.results[prop] = array(value, dtype=dtype) # Save the configuration self.atoms = atoms.copy() diff --git a/catlearn/regression/gp/calculator/database.py b/catlearn/regression/gp/calculator/database.py index 161cb4fd..440e50c6 100644 --- a/catlearn/regression/gp/calculator/database.py +++ b/catlearn/regression/gp/calculator/database.py @@ -16,7 +16,7 @@ def __init__( use_fingerprint=True, round_targets=None, seed=None, - dtype=None, + dtype=float, **kwargs, ): """ @@ -39,7 +39,7 @@ def __init__( If None, the targets are not rounded. seed: int (optional) The random seed. - The seed an also be a RandomState or Generator instance. + The seed can also be a RandomState or Generator instance. If not given, the default random number generator is used. dtype: type The data type of the arrays. @@ -51,6 +51,7 @@ def __init__( self.set_default_fp( reduce_dimensions=reduce_dimensions, use_derivatives=use_derivatives, + dtype=dtype, ) # Set the arguments self.update_arguments( @@ -135,6 +136,8 @@ def get_features(self, **kwargs): Returns: array: A matrix array with the saved features or fingerprints. """ + if self.use_fingerprint: + return asarray(self.features) return array(self.features, dtype=self.dtype) def get_targets(self, **kwargs): @@ -353,6 +356,134 @@ def get_use_fingerprint(self): "Get whether a fingerprint is used as the features." return self.use_fingerprint + def set_fingerprint(self, fingerprint, **kwargs): + """ + Set the fingerprint instance. + + Parameters: + fingerprint: Fingerprint object + An object as a fingerprint class + that convert atoms to fingerprint. + + Returns: + self: The updated object itself. + """ + self.fingerprint = fingerprint.copy() + # Reset the database if the use fingerprint is changed + self.reset_database() + return self + + def set_dtype(self, dtype, **kwargs): + """ + Set the data type of the arrays. + + Parameters: + dtype: type + The data type of the arrays. + + Returns: + self: The updated object itself. + """ + # Set the data type + self.dtype = dtype + # Set the data type of the fingerprint + self.fingerprint.set_dtype(dtype) + return self + + def set_use_fingerprint(self, use_fingerprint, **kwargs): + """ + Set whether the kernel uses fingerprint objects (True) + or arrays (False). + + Parameters: + use_fingerprint: bool + Whether the kernel uses fingerprint objects (True) + or arrays (False). + + Returns: + self: The updated object itself. + """ + # Check if the use fingerprint is already set + if hasattr(self, "use_fingerprint"): + if self.use_fingerprint == use_fingerprint: + return self + # Set the use fingerprint + self.use_fingerprint = use_fingerprint + # Reset the database if the use fingerprint is changed + self.reset_database() + return self + + def set_use_derivatives(self, use_derivatives, **kwargs): + """ + Set whether to use derivatives/forces in the targets. + + Parameters: + use_derivatives: bool + Whether to use derivatives/forces in the targets. + + Returns: + self: The updated object itself. + """ + # Check if the use derivatives is already set + if hasattr(self, "use_derivatives"): + if self.use_derivatives == use_derivatives: + return self + # Set the use derivatives + self.use_derivatives = use_derivatives + # Set the use derivatives of the fingerprint + if use_derivatives: + self.fingerprint.set_use_derivatives(use_derivatives) + # Reset the database if the use derivatives is changed + self.reset_database() + return self + + def set_reduce_dimensions(self, reduce_dimensions, **kwargs): + """ + Set whether to reduce the fingerprint space if constrains are used. + + Parameters: + reduce_dimensions: bool + Whether to reduce the fingerprint space if constrains are used. + + Returns: + self: The updated object itself. + """ + # Check if the reduce_dimensions is already set + if hasattr(self, "reduce_dimensions"): + if self.reduce_dimensions == reduce_dimensions: + return self + # Set the reduce dimensions + self.reduce_dimensions = reduce_dimensions + # Set the reduce dimensions of the fingerprint + self.fingerprint.set_reduce_dimensions(reduce_dimensions) + # Reset the database if the reduce dimensions is changed + self.reset_database() + return self + + def set_seed(self, seed=None): + """ + Set the random seed. + + Parameters: + seed: int (optional) + The random seed. + The seed can be an integer, RandomState, or Generator instance. + If not given, the default random number generator is used. + + Returns: + self: The instance itself. + """ + if seed is not None: + self.seed = seed + if isinstance(seed, int): + self.rng = default_rng(self.seed) + elif isinstance(seed, Generator) or isinstance(seed, RandomState): + self.rng = seed + else: + self.seed = None + self.rng = default_rng() + return self + def update_arguments( self, fingerprint=None, @@ -388,20 +519,14 @@ def update_arguments( Returns: self: The updated object itself. """ - # Control if the database has to be reset - reset_database = False if fingerprint is not None: - self.fingerprint = fingerprint.copy() - reset_database = True + self.set_fingerprint(fingerprint) if reduce_dimensions is not None: - self.reduce_dimensions = reduce_dimensions - reset_database = True + self.set_reduce_dimensions(reduce_dimensions) if use_derivatives is not None: - self.use_derivatives = use_derivatives - reset_database = True + self.set_use_derivatives(use_derivatives) if use_fingerprint is not None: - self.use_fingerprint = use_fingerprint - reset_database = True + self.set_use_fingerprint(use_fingerprint) if round_targets is not None or not hasattr(self, "round_targets"): self.round_targets = round_targets # Set the seed @@ -412,28 +537,13 @@ def update_arguments( self.set_dtype(dtype) # Check that the database and the fingerprint have the same attributes self.check_attributes() - # Reset the database if an argument has been changed - if reset_database: - self.reset_database() - return self - - def set_seed(self, seed=None): - "Set the random seed." - if seed is not None: - self.seed = seed - if isinstance(seed, int): - self.rng = default_rng(self.seed) - elif isinstance(seed, Generator) or isinstance(seed, RandomState): - self.rng = seed - else: - self.seed = None - self.rng = default_rng() return self def set_default_fp( self, reduce_dimensions=True, use_derivatives=True, + dtype=float, **kwargs, ): "Use default fingerprint if it is not given." @@ -442,24 +552,10 @@ def set_default_fp( return Cartesian( reduce_dimensions=reduce_dimensions, use_derivatives=use_derivatives, + dtype=dtype, **kwargs, ) - def set_dtype(self, dtype, **kwargs): - """ - Set the data type of the arrays. - - Parameters: - dtype: type - The data type of the arrays. - - Returns: - self: The updated object itself. - """ - # Set the data type - self.dtype = dtype - return self - def check_attributes(self): "Check if all attributes agree between the class and subclasses." if self.reduce_dimensions != self.fingerprint.get_reduce_dimensions(): diff --git a/catlearn/regression/gp/calculator/database_reduction.py b/catlearn/regression/gp/calculator/database_reduction.py index 4396c13c..daf996a9 100644 --- a/catlearn/regression/gp/calculator/database_reduction.py +++ b/catlearn/regression/gp/calculator/database_reduction.py @@ -12,7 +12,6 @@ nanmin, sqrt, ) -from numpy.random import choice, permutation from scipy.spatial.distance import cdist from .database import Database @@ -26,7 +25,7 @@ def __init__( use_fingerprint=True, round_targets=None, seed=None, - dtype=None, + dtype=float, npoints=25, initial_indicies=[0], include_last=1, @@ -39,14 +38,14 @@ def __init__( The reduced data set is selected from a method. Parameters: - fingerprint : Fingerprint object + fingerprint: Fingerprint object An object as a fingerprint class that convert atoms to fingerprint. reduce_dimensions: bool Whether to reduce the fingerprint space if constrains are used. - use_derivatives : bool + use_derivatives: bool Whether to use derivatives/forces in the targets. - use_fingerprint : bool + use_fingerprint: bool Whether the kernel uses fingerprint objects (True) or arrays (False). round_targets: int (optional) @@ -58,12 +57,12 @@ def __init__( If not given, the default random number generator is used. dtype: type The data type of the arrays. - npoints : int + npoints: int Number of points that are used from the database. - initial_indicies : list + initial_indicies: list The indicies of the data points that must be included in the used data base. - include_last : int + include_last: int Number of last data point to include in the used data base. """ # The negative forces have to be used since the derivatives are used @@ -75,6 +74,7 @@ def __init__( self.set_default_fp( reduce_dimensions=reduce_dimensions, use_derivatives=use_derivatives, + dtype=dtype, ) # Set the arguments self.update_arguments( @@ -110,14 +110,14 @@ def update_arguments( if they are not given. Parameters: - fingerprint : Fingerprint object + fingerprint: Fingerprint object An object as a fingerprint class that convert atoms to fingerprint. reduce_dimensions: bool Whether to reduce the fingerprint space if constrains are used. - use_derivatives : bool + use_derivatives: bool Whether to use derivatives/forces in the targets. - use_fingerprint : bool + use_fingerprint: bool Whether the kernel uses fingerprint objects (True) or arrays (False). round_targets: int (optional) @@ -129,12 +129,12 @@ def update_arguments( If not given, the default random number generator is used. dtype: type The data type of the arrays. - npoints : int + npoints: int Number of points that are used from the database. - initial_indicies : list + initial_indicies: list The indicies of the data points that must be included in the used data base. - include_last : int + include_last: int Number of last data point to include in the used data base. Returns: @@ -149,7 +149,6 @@ def update_arguments( round_targets=round_targets, seed=seed, dtype=dtype, - **kwargs, ) # Set the number of points to use if npoints is not None: @@ -199,6 +198,8 @@ def get_features(self, **kwargs): array: A matrix array with the saved features or fingerprints. """ indicies = self.get_reduction_indicies() + if self.use_fingerprint: + return array(self.features)[indicies] return array(self.features, dtype=self.dtype)[indicies] def get_all_feature_vectors(self, **kwargs): @@ -241,18 +242,19 @@ def get_last_indicies(self, indicies, not_indicies, **kwargs): Include the last indicies that are not in the used indicies list. Parameters: - indicies : list + indicies: list A list of used indicies. - not_indicies : list + not_indicies: list A list of indicies that not used yet. Returns: list: A list of the used indicies including the last indicies. """ if self.include_last != 0: + last = -self.include_last indicies = append( indicies, - [not_indicies[-self.include_last :]], + [not_indicies[last:]], ) return indicies @@ -261,9 +263,9 @@ def get_not_indicies(self, indicies, all_indicies, **kwargs): Get a list of the indicies that are not in the used indicies list. Parameters: - indicies : list + indicies: list A list of indicies. - all_indicies : list + all_indicies: list A list of all indicies. Returns: @@ -336,7 +338,7 @@ def __init__( use_fingerprint=True, round_targets=None, seed=None, - dtype=None, + dtype=float, npoints=25, initial_indicies=[0], include_last=1, @@ -349,14 +351,14 @@ def __init__( The reduced data set is selected from the distances. Parameters: - fingerprint : Fingerprint object + fingerprint: Fingerprint object An object as a fingerprint class that convert atoms to fingerprint. reduce_dimensions: bool Whether to reduce the fingerprint space if constrains are used. - use_derivatives : bool + use_derivatives: bool Whether to use derivatives/forces in the targets. - use_fingerprint : bool + use_fingerprint: bool Whether the kernel uses fingerprint objects (True) or arrays (False). round_targets: int (optional) @@ -368,12 +370,12 @@ def __init__( If not given, the default random number generator is used. dtype: type The data type of the arrays. - npoints : int + npoints: int Number of points that are used from the database. - initial_indicies : list + initial_indicies: list The indicies of the data points that must be included in the used data base. - include_last : int + include_last: int Number of last data point to include in the used data base. """ super().__init__( @@ -400,7 +402,7 @@ def make_reduction(self, all_indicies, **kwargs): indicies = self.get_last_indicies(indicies, not_indicies) # Get a random index if no fixed index exist if len(indicies) == 0: - indicies = asarray([choice(all_indicies)]) + indicies = asarray([self.rng.choice(not_indicies)]) # Get all the features features = self.get_all_feature_vectors() fdim = len(features[0]) @@ -427,7 +429,7 @@ def __init__( use_fingerprint=True, round_targets=None, seed=None, - dtype=None, + dtype=float, npoints=25, initial_indicies=[0], include_last=1, @@ -440,14 +442,14 @@ def __init__( The reduced data set is selected from random. Parameters: - fingerprint : Fingerprint object + fingerprint: Fingerprint object An object as a fingerprint class that convert atoms to fingerprint. reduce_dimensions: bool Whether to reduce the fingerprint space if constrains are used. - use_derivatives : bool + use_derivatives: bool Whether to use derivatives/forces in the targets. - use_fingerprint : bool + use_fingerprint: bool Whether the kernel uses fingerprint objects (True) or arrays (False). round_targets: int (optional) @@ -459,12 +461,12 @@ def __init__( If not given, the default random number generator is used. dtype: type The data type of the arrays. - npoints : int + npoints: int Number of points that are used from the database. - initial_indicies : list + initial_indicies: list The indicies of the data points that must be included in the used data base. - include_last : int + include_last: int Number of last data point to include in the used data base. """ super().__init__( @@ -496,7 +498,7 @@ def make_reduction(self, all_indicies, **kwargs): # Randomly get the indicies indicies = append( indicies, - permutation(not_indicies)[:npoints], + self.rng.permutation(not_indicies)[:npoints], ) return array(indicies, dtype=int) @@ -510,7 +512,7 @@ def __init__( use_fingerprint=True, round_targets=None, seed=None, - dtype=None, + dtype=float, npoints=25, initial_indicies=[0], include_last=1, @@ -525,14 +527,14 @@ def __init__( the distances and random. Parameters: - fingerprint : Fingerprint object + fingerprint: Fingerprint object An object as a fingerprint class that convert atoms to fingerprint. reduce_dimensions: bool Whether to reduce the fingerprint space if constrains are used. - use_derivatives : bool + use_derivatives: bool Whether to use derivatives/forces in the targets. - use_fingerprint : bool + use_fingerprint: bool Whether the kernel uses fingerprint objects (True) or arrays (False). round_targets: int (optional) @@ -544,14 +546,14 @@ def __init__( If not given, the default random number generator is used. dtype: type The data type of the arrays. - npoints : int + npoints: int Number of points that are used from the database. - initial_indicies : list + initial_indicies: list The indicies of the data points that must be included in the used data base. - include_last : int + include_last: int Number of last data point to include in the used data base. - random_fraction : int + random_fraction: int How often the data point is sampled randomly. """ super().__init__( @@ -589,14 +591,14 @@ def update_arguments( The existing arguments are used if they are not given. Parameters: - fingerprint : Fingerprint object + fingerprint: Fingerprint object An object as a fingerprint class that convert atoms to fingerprint. reduce_dimensions: bool Whether to reduce the fingerprint space if constrains are used. - use_derivatives : bool + use_derivatives: bool Whether to use derivatives/forces in the targets. - use_fingerprint : bool + use_fingerprint: bool Whether the kernel uses fingerprint objects (True) or arrays (False). round_targets: int (optional) @@ -608,14 +610,14 @@ def update_arguments( If not given, the default random number generator is used. dtype: type The data type of the arrays. - npoints : int + npoints: int Number of points that are used from the database. - initial_indicies : list + initial_indicies: list The indicies of the data points that must be included in the used data base. - include_last : int + include_last: int Number of last data point to include in the used data base. - random_fraction : int + random_fraction: int How often the data point is sampled randomly. Returns: @@ -633,7 +635,6 @@ def update_arguments( npoints=npoints, initial_indicies=initial_indicies, include_last=include_last, - **kwargs, ) # Set the random fraction if random_fraction is not None: @@ -655,7 +656,7 @@ def make_reduction(self, all_indicies, **kwargs): indicies = self.get_last_indicies(indicies, not_indicies) # Get a random index if no fixed index exist if len(indicies) == 0: - indicies = [choice(all_indicies)] + indicies = [self.rng.choice(not_indicies)] # Get all the features features = self.get_all_feature_vectors() fdim = len(features[0]) @@ -664,10 +665,7 @@ def make_reduction(self, all_indicies, **kwargs): not_indicies = self.get_not_indicies(indicies, all_indicies) if i % self.random_fraction == 0: # Get a random index - indicies = append( - indicies, - [choice(not_indicies)], - ) + indicies = append(indicies, [self.rng.choice(not_indicies)]) else: # Calculate the distances to the points already used dist = cdist( @@ -716,7 +714,7 @@ def __init__( use_fingerprint=True, round_targets=None, seed=None, - dtype=None, + dtype=float, npoints=25, initial_indicies=[0], include_last=1, @@ -730,14 +728,14 @@ def __init__( The reduced data set is selected from the smallest targets. Parameters: - fingerprint : Fingerprint object + fingerprint: Fingerprint object An object as a fingerprint class that convert atoms to fingerprint. reduce_dimensions: bool Whether to reduce the fingerprint space if constrains are used. - use_derivatives : bool + use_derivatives: bool Whether to use derivatives/forces in the targets. - use_fingerprint : bool + use_fingerprint: bool Whether the kernel uses fingerprint objects (True) or arrays (False). round_targets: int (optional) @@ -749,14 +747,14 @@ def __init__( If not given, the default random number generator is used. dtype: type The data type of the arrays. - npoints : int + npoints: int Number of points that are used from the database. - initial_indicies : list + initial_indicies: list The indicies of the data points that must be included in the used data base. - include_last : int + include_last: int Number of last data point to include in the used data base. - force_targets : bool + force_targets: bool Whether to include the derivatives/forces in targets when the smallest targets are found. """ @@ -794,14 +792,14 @@ def update_arguments( The existing arguments are used if they are not given. Parameters: - fingerprint : Fingerprint object + fingerprint: Fingerprint object An object as a fingerprint class that convert atoms to fingerprint. reduce_dimensions: bool Whether to reduce the fingerprint space if constrains are used. - use_derivatives : bool + use_derivatives: bool Whether to use derivatives/forces in the targets. - use_fingerprint : bool + use_fingerprint: bool Whether the kernel uses fingerprint objects (True) or arrays (False). round_targets: int (optional) @@ -813,14 +811,14 @@ def update_arguments( If not given, the default random number generator is used. dtype: type The data type of the arrays. - npoints : int + npoints: int Number of points that are used from the database. - initial_indicies : list + initial_indicies: list The indicies of the data points that must be included in the used data base. - include_last : int + include_last: int Number of last data point to include in the used data base. - force_targets : bool + force_targets: bool Whether to include the derivatives/forces in targets when the smallest targets are found. @@ -839,7 +837,6 @@ def update_arguments( npoints=npoints, initial_indicies=initial_indicies, include_last=include_last, - **kwargs, ) # Set the force targets if force_targets is not None: @@ -910,7 +907,7 @@ def __init__( use_fingerprint=True, round_targets=None, seed=None, - dtype=None, + dtype=float, npoints=25, initial_indicies=[0], include_last=1, @@ -923,14 +920,14 @@ def __init__( The reduced data set is selected from the last data points. Parameters: - fingerprint : Fingerprint object + fingerprint: Fingerprint object An object as a fingerprint class that convert atoms to fingerprint. reduce_dimensions: bool Whether to reduce the fingerprint space if constrains are used. - use_derivatives : bool + use_derivatives: bool Whether to use derivatives/forces in the targets. - use_fingerprint : bool + use_fingerprint: bool Whether the kernel uses fingerprint objects (True) or arrays (False). round_targets: int (optional) @@ -942,12 +939,12 @@ def __init__( If not given, the default random number generator is used. dtype: type The data type of the arrays. - npoints : int + npoints: int Number of points that are used from the database. - initial_indicies : list + initial_indicies: list The indicies of the data points that must be included in the used data base. - include_last : int + include_last: int Number of last data point to include in the used data base. """ super().__init__( @@ -987,7 +984,7 @@ def __init__( use_fingerprint=True, round_targets=None, seed=None, - dtype=None, + dtype=float, npoints=25, initial_indicies=[0], include_last=1, @@ -1001,14 +998,14 @@ def __init__( The initial indicies and the last data point is used at each restart. Parameters: - fingerprint : Fingerprint object + fingerprint: Fingerprint object An object as a fingerprint class that convert atoms to fingerprint. reduce_dimensions: bool Whether to reduce the fingerprint space if constrains are used. - use_derivatives : bool + use_derivatives: bool Whether to use derivatives/forces in the targets. - use_fingerprint : bool + use_fingerprint: bool Whether the kernel uses fingerprint objects (True) or arrays (False). round_targets: int (optional) @@ -1020,12 +1017,12 @@ def __init__( If not given, the default random number generator is used. dtype: type The data type of the arrays. - npoints : int + npoints: int Number of points that are used from the database. - initial_indicies : list + initial_indicies: list The indicies of the data points that must be included in the used data base. - include_last : int + include_last: int Number of last data point to include in the used data base. """ super().__init__( @@ -1065,7 +1062,8 @@ def make_reduction(self, all_indicies, **kwargs): # Get the indicies for the system not already included not_indicies = self.get_not_indicies(indicies, all_indicies) # Include the indicies - indicies = append(indicies, not_indicies[-(n_extra + lasts) :]) + lasts_i = -(n_extra + lasts) + indicies = append(indicies, not_indicies[lasts_i:]) return array(indicies, dtype=int) @@ -1078,7 +1076,7 @@ def __init__( use_fingerprint=True, round_targets=None, seed=None, - dtype=None, + dtype=float, npoints=25, initial_indicies=[0], include_last=1, @@ -1096,14 +1094,14 @@ def __init__( to any of the points of interest. Parameters: - fingerprint : Fingerprint object + fingerprint: Fingerprint object An object as a fingerprint class that convert atoms to fingerprint. reduce_dimensions: bool Whether to reduce the fingerprint space if constrains are used. - use_derivatives : bool + use_derivatives: bool Whether to use derivatives/forces in the targets. - use_fingerprint : bool + use_fingerprint: bool Whether the kernel uses fingerprint objects (True) or arrays (False). round_targets: int (optional) @@ -1111,17 +1109,17 @@ def __init__( If None, the targets are not rounded. dtype: type The data type of the arrays. - npoints : int + npoints: int Number of points that are used from the database. - initial_indicies : list + initial_indicies: list The indicies of the data points that must be included in the used data base. - include_last : int + include_last: int Number of last data point to include in the used data base. - feature_distance : bool + feature_distance: bool Whether to calculate the distance in feature space (True) or Cartesian coordinate space (False). - point_interest : list + point_interest: list A list of the points of interest as ASE Atoms instances. """ super().__init__( @@ -1160,7 +1158,7 @@ def get_positions(self, atoms_list, **kwargs): Get the Cartesian coordinates of the atoms. Parameters: - atoms_list : list or ASE Atoms + atoms_list: list or ASE Atoms A list of ASE Atoms. Returns: @@ -1196,7 +1194,7 @@ def get_distances(self, not_indicies, **kwargs): Calculate the distances to the points of interest. Parameters: - not_indicies : list + not_indicies: list A list of indicies that not used yet. Returns: @@ -1240,14 +1238,14 @@ def update_arguments( The existing arguments are used if they are not given. Parameters: - fingerprint : Fingerprint object + fingerprint: Fingerprint object An object as a fingerprint class that convert atoms to fingerprint. reduce_dimensions: bool Whether to reduce the fingerprint space if constrains are used. - use_derivatives : bool + use_derivatives: bool Whether to use derivatives/forces in the targets. - use_fingerprint : bool + use_fingerprint: bool Whether the kernel uses fingerprint objects (True) or arrays (False). round_targets: int (optional) @@ -1259,17 +1257,17 @@ def update_arguments( If not given, the default random number generator is used. dtype: type The data type of the arrays. - npoints : int + npoints: int Number of points that are used from the database. - initial_indicies : list + initial_indicies: list The indicies of the data points that must be included in the used data base. - include_last : int + include_last: int Number of last data point to include in the used data base. - feature_distance : bool + feature_distance: bool Whether to calculate the distance in feature space (True) or Cartesian coordinate space (False). - point_interest : list + point_interest: list A list of the points of interest as ASE Atoms instances. Returns: @@ -1287,7 +1285,6 @@ def update_arguments( npoints=npoints, initial_indicies=initial_indicies, include_last=include_last, - **kwargs, ) # Set the feature distance if feature_distance is not None: @@ -1365,7 +1362,7 @@ def __init__( use_fingerprint=True, round_targets=None, seed=None, - dtype=None, + dtype=float, npoints=25, initial_indicies=[0], include_last=1, @@ -1383,14 +1380,14 @@ def __init__( and it is performed iteratively. Parameters: - fingerprint : Fingerprint object + fingerprint: Fingerprint object An object as a fingerprint class that convert atoms to fingerprint. reduce_dimensions: bool Whether to reduce the fingerprint space if constrains are used. - use_derivatives : bool + use_derivatives: bool Whether to use derivatives/forces in the targets. - use_fingerprint : bool + use_fingerprint: bool Whether the kernel uses fingerprint objects (True) or arrays (False). round_targets: int (optional) @@ -1402,17 +1399,17 @@ def __init__( If not given, the default random number generator is used. dtype: type The data type of the arrays. - npoints : int + npoints: int Number of points that are used from the database. - initial_indicies : list + initial_indicies: list The indicies of the data points that must be included in the used data base. - include_last : int + include_last: int Number of last data point to include in the used data base. - feature_distance : bool + feature_distance: bool Whether to calculate the distance in feature space (True) or Cartesian coordinate space (False). - point_interest : list + point_interest: list A list of the points of interest as ASE Atoms instances. """ super().__init__( diff --git a/catlearn/regression/gp/calculator/hiermodel.py b/catlearn/regression/gp/calculator/hiermodel.py index b3c2d525..8218cddc 100644 --- a/catlearn/regression/gp/calculator/hiermodel.py +++ b/catlearn/regression/gp/calculator/hiermodel.py @@ -16,7 +16,7 @@ def __init__( verbose=False, npoints=25, initial_indicies=[0], - dtype=None, + dtype=float, **kwargs, ): """ @@ -103,7 +103,7 @@ def add_training(self, atoms_list, **kwargs): super().add_training(data_atoms) super().add_training(atoms_list) else: - raise Exception( + raise AttributeError( "New baseline model can not be made without training. " "Include one point at the time!" ) @@ -160,44 +160,24 @@ def update_arguments( Returns: self: The updated object itself. """ - if model is not None: - self.model = model.copy() - if database is not None: - self.database = database.copy() - if baseline is not None: - self.baseline = baseline.copy() - elif not hasattr(self, "baseline"): - self.baseline = None - if optimize is not None: - self.optimize = optimize - if hp is not None: - self.hp = hp.copy() - elif not hasattr(self, "hp"): - self.hp = None - if pdis is not None: - self.pdis = pdis.copy() - elif not hasattr(self, "pdis"): - self.pdis = None - if include_noise is not None: - self.include_noise = include_noise - if verbose is not None: - self.verbose = verbose - if dtype is not None or not hasattr(self, "dtype"): - self.dtype = dtype + # Set the parameters in the parent class + super().update_arguments( + model=model, + database=database, + baseline=baseline, + optimize=optimize, + hp=hp, + pdis=pdis, + include_noise=include_noise, + verbose=verbose, + dtype=dtype, + ) + # Set the number of points if npoints is not None: self.npoints = int(npoints) + # Set the initial indicies if initial_indicies is not None: self.initial_indicies = initial_indicies.copy() - # Check if the baseline is used - if self.baseline is None: - self.use_baseline = False - else: - self.use_baseline = True - # Make a list of the baseline targets - if baseline is not None or database is not None: - self.baseline_targets = [] - # Check that the model and database have the same attributes - self.check_attributes() return self def get_arguments(self): diff --git a/catlearn/regression/gp/calculator/mlcalc.py b/catlearn/regression/gp/calculator/mlcalc.py index 4bed4b17..92fca055 100644 --- a/catlearn/regression/gp/calculator/mlcalc.py +++ b/catlearn/regression/gp/calculator/mlcalc.py @@ -1,5 +1,6 @@ from numpy import round as round_ from ase.calculators.calculator import Calculator, all_changes +from .mlmodel import MLModel class MLCalculator(Calculator): @@ -55,8 +56,6 @@ def __init__( Calculator.__init__(self, **calc_kwargs) # Set default mlmodel if mlmodel is None: - from .mlmodel import MLModel - mlmodel = MLModel( model=None, database=None, @@ -385,22 +384,62 @@ def update_arguments( Returns: self: The updated object itself. """ + reset = False if mlmodel is not None: self.mlmodel = mlmodel.copy() + reset = True if calc_forces is not None: self.calc_forces = calc_forces + reset = True if calc_unc is not None: self.calc_unc = calc_unc + reset = True if calc_force_unc is not None: self.calc_force_unc = calc_force_unc + reset = True if calc_unc_deriv is not None: self.calc_unc_deriv = calc_unc_deriv + reset = True if calc_kwargs is not None: self.calc_kwargs = calc_kwargs.copy() + reset = True if round_pred is not None or not hasattr(self, "round_pred"): self.round_pred = round_pred + reset = True # Empty the results - self.reset() + if reset: + self.reset() + return self + + def set_seed(self, seed=None, **kwargs): + """ + Set the random seed. + + Parameters: + seed: int (optional) + The random seed. + The seed can be an integer, RandomState, or Generator instance. + If not given, the default random number generator is used. + + Returns: + self: The instance itself. + """ + # Set the random seed for the ML model + self.mlmodel.set_seed(seed) + return self + + def set_dtype(self, dtype, **kwargs): + """ + Set the data type of the arrays. + + Parameters: + dtype: type + The data type of the arrays. + + Returns: + self: The updated object itself. + """ + self.mlmodel.set_dtype(dtype, **kwargs) return self def get_property_arguments(self, properties=[], **kwargs): diff --git a/catlearn/regression/gp/ensemble/clustering/clustering.py b/catlearn/regression/gp/ensemble/clustering/clustering.py index 27d6aca5..ac4caae0 100644 --- a/catlearn/regression/gp/ensemble/clustering/clustering.py +++ b/catlearn/regression/gp/ensemble/clustering/clustering.py @@ -1,22 +1,36 @@ +from numpy.random import default_rng, Generator, RandomState + + class Clustering: def __init__( self, + seed=None, + dtype=float, **kwargs, ): """ Clustering class object for data sets. + + Parameters: + seed: int (optional) + The random seed. + The seed can be an integer, RandomState, or Generator instance. + If not given, the default random number generator is used. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. """ # Set default descriptors self.n_clusters = 1 # Set the arguments - self.update_arguments(**kwargs) + self.update_arguments(seed=seed, dtype=dtype, **kwargs) def fit(self, X, **kwargs): """ Fit the clustering algorithm. Parameters: - X : (N,D) array + X: (N,D) array Training features with N data points. Returns: @@ -31,7 +45,7 @@ def cluster_fit_data(self, X, **kwargs): Fit the clustering algorithm and return the clustered data. Parameters: - X : (N,D) array + X: (N,D) array Training features with N data points. Returns: @@ -44,7 +58,7 @@ def cluster(self, X, **kwargs): Cluster the given data if it is fitted. Parameters: - X : (M,D) array + X: (M,D) array Features with M data points. Returns: @@ -52,20 +66,83 @@ def cluster(self, X, **kwargs): """ raise NotImplementedError() - def update_arguments(self, metric=None, **kwargs): + def get_n_clusters(self): + """ + Get the number of clusters. + + Returns: + int: The number of clusters. + """ + return self.n_clusters + + def set_dtype(self, dtype, **kwargs): + """ + Set the data type of the arrays. + + Parameters: + dtype: type + The data type of the arrays. + + Returns: + self: The updated object itself. + """ + # Set the data type + self.dtype = dtype + return self + + def set_seed(self, seed=None, **kwargs): + """ + Set the random seed. + + Parameters: + seed: int (optional) + The random seed. + The seed can be an integer, RandomState, or Generator instance. + If not given, the default random number generator is used. + + Returns: + self: The instance itself. + """ + if seed is not None: + self.seed = seed + if isinstance(seed, int): + self.rng = default_rng(self.seed) + elif isinstance(seed, Generator) or isinstance(seed, RandomState): + self.rng = seed + else: + self.seed = None + self.rng = default_rng() + return self + + def update_arguments(self, seed=None, dtype=None, **kwargs): """ Update the class with its arguments. The existing arguments are used if they are not given. + Parameters: + seed: int (optional) + The random seed. + The seed can be an integer, RandomState, or Generator instance. + If not given, the default random number generator is used. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. + Returns: self: The updated object itself. """ + # Set the seed + if seed is not None or not hasattr(self, "seed"): + self.set_seed(seed) + # Set the data type + if dtype is not None or not hasattr(self, "dtype"): + self.set_dtype(dtype) return self def get_arguments(self): "Get the arguments of the class itself." # Get the arguments given to the class in the initialization - arg_kwargs = dict() + arg_kwargs = dict(seed=self.seed, dtype=self.dtype) # Get the constants made within the class constant_kwargs = dict(n_clusters=self.n_clusters) # Get the objects made within the class diff --git a/catlearn/regression/gp/ensemble/clustering/fixed.py b/catlearn/regression/gp/ensemble/clustering/fixed.py index ab8556c3..bd01b9e8 100644 --- a/catlearn/regression/gp/ensemble/clustering/fixed.py +++ b/catlearn/regression/gp/ensemble/clustering/fixed.py @@ -1,4 +1,4 @@ -import numpy as np +from numpy import arange, argmin, empty from .k_means import K_means @@ -6,7 +6,9 @@ class FixedClustering(K_means): def __init__( self, metric="euclidean", - centroids=np.array([]), + centroids=empty(0), + seed=None, + dtype=float, **kwargs, ): """ @@ -14,31 +16,53 @@ def __init__( Use distances to pre-defined fixed centroids for clustering. Parameters: - metric : str + metric: str The metric used to calculate the distances of the data. - centroids : (K,D) array + centroids: (K,D) array An array with the centroids of the K clusters. The centroids must have the same dimensions as the features. + seed: int (optional) + The random seed. + The seed can be an integer, RandomState, or Generator instance. + If not given, the default random number generator is used. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. """ # Set the arguments - self.update_arguments(centroids=centroids, metric=metric, **kwargs) + self.update_arguments( + centroids=centroids, + metric=metric, + seed=seed, + dtype=dtype, + **kwargs, + ) def cluster_fit_data(self, X, **kwargs): - indicies = np.array(range(len(X))) - i_min = np.argmin(self.calculate_distances(X, self.centroids), axis=1) + indicies = arange(len(X)) + i_min = argmin(self.calculate_distances(X, self.centroids), axis=1) return [indicies[i_min == ki] for ki in range(self.n_clusters)] - def update_arguments(self, metric=None, centroids=None, **kwargs): + def update_arguments( + self, metric=None, centroids=None, seed=None, dtype=None, **kwargs + ): """ Update the class with its arguments. The existing arguments are used if they are not given. Parameters: - metric : str + metric: str The metric used to calculate the distances of the data. - centroids : (K,D) array + centroids: (K,D) array An array with the centroids of the K clusters. The centroids must have the same dimensions as the features. + seed: int (optional) + The random seed. + The seed can be an integer, RandomState, or Generator instance. + If not given, the default random number generator is used. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. Returns: self: The updated object itself. @@ -47,12 +71,22 @@ def update_arguments(self, metric=None, centroids=None, **kwargs): self.set_centroids(centroids) if metric is not None: self.metric = metric + # Set the parameters of the parent class + super(K_means, self).update_arguments( + seed=seed, + dtype=dtype, + ) return self def get_arguments(self): "Get the arguments of the class itself." # Get the arguments given to the class in the initialization - arg_kwargs = dict(metric=self.metric, centroids=self.centroids) + arg_kwargs = dict( + metric=self.metric, + centroids=self.centroids, + seed=self.seed, + dtype=self.dtype, + ) # Get the constants made within the class constant_kwargs = dict() # Get the objects made within the class diff --git a/catlearn/regression/gp/ensemble/clustering/k_means.py b/catlearn/regression/gp/ensemble/clustering/k_means.py index 0b3e4c80..56ec751a 100644 --- a/catlearn/regression/gp/ensemble/clustering/k_means.py +++ b/catlearn/regression/gp/ensemble/clustering/k_means.py @@ -1,4 +1,5 @@ -import numpy as np +from numpy import arange, argmax, argmin, array, append, asarray, empty +from numpy.linalg import norm from scipy.spatial.distance import cdist from .clustering import Clustering @@ -10,6 +11,8 @@ def __init__( n_clusters=4, maxiter=100, tol=1e-4, + seed=None, + dtype=float, **kwargs, ): """ @@ -17,17 +20,24 @@ def __init__( The K-means++ algorithm for clustering. Parameters: - metric : str + metric: str The metric used to calculate the distances of the data. - n_clusters : int + n_clusters: int The number of used clusters. - maxiter : int + maxiter: int The maximum number of iterations used to fit the clusters. - tol : float + tol: float The tolerance before the cluster fit is converged. + seed: int (optional) + The random seed. + The seed can be an integer, RandomState, or Generator instance. + If not given, the default random number generator is used. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. """ # Set default descriptors - self.centroids = np.array([]) + self.centroids = empty(0, dtype=dtype) self.n_clusters = 1 # Set the arguments super().__init__( @@ -35,14 +45,18 @@ def __init__( n_clusters=n_clusters, maxiter=maxiter, tol=tol, + seed=seed, + dtype=dtype, **kwargs, ) def cluster_fit_data(self, X, **kwargs): + # Copy the data + X = array(X, dtype=self.dtype) # If only one cluster is used give the full data if self.n_clusters == 1: - self.centroids = np.array([np.mean(X, axis=0)]) - return [np.arange(len(X))] + self.centroids = asarray([X.mean(axis=0)]) + return [arange(len(X))] # Initiate the centroids centroids = self.initiate_centroids(X) # Optimize position of the centroids @@ -51,8 +65,8 @@ def cluster_fit_data(self, X, **kwargs): return self.cluster(X) def cluster(self, X, **kwargs): - indicies = np.arange(len(X)) - i_min = np.argmin(self.calculate_distances(X, self.centroids), axis=1) + indicies = arange(len(X)) + i_min = argmin(self.calculate_distances(X, self.centroids), axis=1) return [indicies[i_min == ki] for ki in range(self.n_clusters)] def set_centroids(self, centroids, **kwargs): @@ -60,7 +74,7 @@ def set_centroids(self, centroids, **kwargs): Set user defined centroids. Parameters: - centroids : (K,D) array + centroids: (K,D) array An array with the centroids of the K clusters. The centroids must have the same dimensions as the features. @@ -71,22 +85,42 @@ def set_centroids(self, centroids, **kwargs): self.n_clusters = len(self.centroids) return self + def set_dtype(self, dtype, **kwargs): + super().set_dtype(dtype, **kwargs) + # Set the dtype + self.centroids = self.centroids.astype(dtype) + return self + def update_arguments( - self, metric=None, n_clusters=None, maxiter=None, tol=None, **kwargs + self, + metric=None, + n_clusters=None, + maxiter=None, + tol=None, + seed=None, + dtype=None, + **kwargs, ): """ Update the class with its arguments. The existing arguments are used if they are not given. Parameters: - metric : str + metric: str The metric used to calculate the distances of the data. - n_clusters : int + n_clusters: int The number of used clusters. - maxiter : int + maxiter: int The maximum number of iterations used to fit the clusters. - tol : float + tol: float The tolerance before the cluster fit is converged. + seed: int (optional) + The random seed. + The seed can be an integer, RandomState, or Generator instance. + If not given, the default random number generator is used. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. Returns: self: The updated object itself. @@ -99,6 +133,11 @@ def update_arguments( self.maxiter = int(maxiter) if tol is not None: self.tol = tol + # Set the parameters of the parent class + super().update_arguments( + seed=seed, + dtype=dtype, + ) return self def calculate_distances(self, Q, X, **kwargs): @@ -108,30 +147,25 @@ def calculate_distances(self, Q, X, **kwargs): def initiate_centroids(self, X, **kwargs): "Initial the centroids from the K-mean++ method." # Get the first centroid randomly - centroids = np.array(X[np.random.choice(len(X), size=1)]) + centroids = X[self.rng.choice(len(X), size=1)] for ki in range(1, self.n_clusters): # Calculate the maximum nearest neighbor - i_max = np.argmax( - np.min(self.calculate_distances(X, centroids), axis=1) - ) - centroids = np.append(centroids, [X[i_max]], axis=0) + i_max = argmax(self.calculate_distances(X, centroids).min(axis=1)) + centroids = append(centroids, [X[i_max]], axis=0) return centroids def optimize_centroids(self, X, centroids, **kwargs): "Optimize the positions of the centroids." - for i in range(1, self.maxiter + 1): + for _ in range(1, self.maxiter + 1): # Store the old centroids centroids_old = centroids.copy() # Calculate which centroids that are closest - i_min = np.argmin(self.calculate_distances(X, centroids), axis=1) - centroids = np.array( - [ - np.mean(X[i_min == ki], axis=0) - for ki in range(self.n_clusters) - ] + i_min = argmin(self.calculate_distances(X, centroids), axis=1) + centroids = asarray( + [X[i_min == ki].mean(axis=0) for ki in range(self.n_clusters)] ) # Check if it is converged - if np.linalg.norm(centroids - centroids_old) <= self.tol: + if norm(centroids - centroids_old) <= self.tol: break return centroids @@ -143,9 +177,11 @@ def get_arguments(self): n_clusters=self.n_clusters, maxiter=self.maxiter, tol=self.tol, + seed=self.seed, + dtype=self.dtype, ) # Get the constants made within the class constant_kwargs = dict() # Get the objects made within the class - object_kwargs = dict(centroids=self.centroids) + object_kwargs = dict(centroids=self.centroids.copy()) return arg_kwargs, constant_kwargs, object_kwargs diff --git a/catlearn/regression/gp/ensemble/clustering/k_means_auto.py b/catlearn/regression/gp/ensemble/clustering/k_means_auto.py index ad428849..a14418fb 100644 --- a/catlearn/regression/gp/ensemble/clustering/k_means_auto.py +++ b/catlearn/regression/gp/ensemble/clustering/k_means_auto.py @@ -1,4 +1,5 @@ -import numpy as np +from numpy import append, arange, argmin, argsort, array, asarray +from numpy.linalg import norm from .k_means import K_means @@ -10,6 +11,8 @@ def __init__( max_data=30, maxiter=100, tol=1e-4, + seed=None, + dtype=float, **kwargs, ): """ @@ -18,16 +21,23 @@ def __init__( of clusters are updated. Parameters: - metric : str + metric: str The metric used to calculate the distances of the data. - min_data : int + min_data: int The minimum number of data point in each cluster. - max_data : int + max_data: int The maximum number of data point in each cluster. - maxiter : int + maxiter: int The maximum number of iterations used to fit the clusters. - tol : float + tol: float The tolerance before the cluster fit is converged. + seed: int (optional) + The random seed. + The seed can be an integer, RandomState, or Generator instance. + If not given, the default random number generator is used. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. """ super().__init__( metric=metric, @@ -35,24 +45,26 @@ def __init__( max_data=max_data, maxiter=maxiter, tol=tol, + seed=seed, + dtype=dtype, **kwargs, ) def cluster_fit_data(self, X, **kwargs): + # Copy the data + X = array(X, dtype=self.dtype) # Calculate the number of clusters - n_data = len(X) - self.n_clusters = int(n_data // self.max_data) - if n_data - (self.n_clusters * self.max_data): - self.n_clusters = self.n_clusters + 1 + self.n_clusters = self.calc_n_clusters(X) # If only one cluster is used give the full data if self.n_clusters == 1: - self.centroids = np.array([np.mean(X, axis=0)]) - return [np.arange(n_data)] + self.centroids = asarray([X.mean(axis=0)]) + return [arange(len(X))] # Initiate the centroids centroids = self.initiate_centroids(X) # Optimize position of the centroids self.centroids, cluster_indicies = self.optimize_centroids( - X, centroids + X, + centroids, ) # Return the cluster indicies return cluster_indicies @@ -64,6 +76,8 @@ def update_arguments( max_data=None, maxiter=None, tol=None, + seed=None, + dtype=None, **kwargs, ): """ @@ -71,54 +85,76 @@ def update_arguments( The existing arguments are used if they are not given. Parameters: - metric : str + metric: str The metric used to calculate the distances of the data. - min_data : int + min_data: int The minimum number of data point in each cluster. - max_data : int + max_data: int The maximum number of data point in each cluster. - maxiter : int + maxiter: int The maximum number of iterations used to fit the clusters. - tol : float + tol: float The tolerance before the cluster fit is converged. + seed: int (optional) + The random seed. + The seed can be an integer, RandomState, or Generator instance. + If not given, the default random number generator is used. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. Returns: self: The updated object itself. """ - if metric is not None: - self.metric = metric if min_data is not None: self.min_data = int(min_data) if max_data is not None: self.max_data = int(max_data) - if maxiter is not None: - self.maxiter = int(maxiter) - if tol is not None: - self.tol = tol # Check that the numbers of used data points agree if self.max_data < self.min_data: self.max_data = int(self.min_data) + # Set the arguments of the parent class + super().update_arguments( + metric=metric, + n_clusters=None, + maxiter=maxiter, + tol=tol, + seed=seed, + dtype=dtype, + ) return self + def calc_n_clusters(self, X, **kwargs): + """ + Calculate the number of clusters based on the data. + """ + n_data = len(X) + n_clusters = int(n_data // self.max_data) + if n_data > (n_clusters * self.max_data): + n_clusters += 1 + return n_clusters + def optimize_centroids(self, X, centroids, **kwargs): "Optimize the positions of the centroids." - indicies = np.arange(len(X)) - for i in range(1, self.maxiter + 1): + indicies = arange(len(X)) + for _ in range(1, self.maxiter + 1): # Store the old centroids centroids_old = centroids.copy() # Calculate which centroids that are closest distance_matrix = self.calculate_distances(X, centroids) cluster_indicies = self.count_clusters( - X, indicies, distance_matrix + X, + indicies, + distance_matrix, ) - centroids = np.array( + centroids = asarray( [ - np.mean(X[indicies_ki], axis=0) + X[indicies_ki].mean(axis=0) for indicies_ki in cluster_indicies ] ) # Check if it is converged - if np.linalg.norm(centroids - centroids_old) <= self.tol: + if norm(centroids - centroids_old) <= self.tol: break return centroids, cluster_indicies @@ -129,22 +165,22 @@ def count_clusters(self, X, indicies, distance_matrix, **kwargs): between the minimum and maximum number of allowed cluster sizes. """ # Make a list cluster indicies - klist = np.arange(self.n_clusters).reshape(-1, 1) + klist = arange(self.n_clusters).reshape(-1, 1) # Find the cluster that each point is closest to - k_indicies = np.argmin(distance_matrix, axis=1) + k_indicies = argmin(distance_matrix, axis=1) indicies_ki_bool = klist == k_indicies # Check the number of points per cluster - n_ki = np.sum(indicies_ki_bool, axis=1) + n_ki = indicies_ki_bool.sum(axis=1) # Ensure the number is within the conditions n_ki[n_ki > self.max_data] = self.max_data n_ki[n_ki < self.min_data] = self.min_data # Sort the indicies as function of the distances to the centroids - d_indicies = np.argsort(distance_matrix, axis=0) + d_indicies = argsort(distance_matrix, axis=0) indicies_sorted = indicies[d_indicies.T] indicies_ki_bool = indicies_ki_bool[klist, indicies_sorted] # Prioritize the points that is part of each cluster cluster_indicies = [ - np.append( + append( indicies_sorted[ki, indicies_ki_bool[ki]], indicies_sorted[ki, ~indicies_ki_bool[ki]], )[: n_ki[ki]] @@ -161,6 +197,8 @@ def get_arguments(self): max_data=self.max_data, maxiter=self.maxiter, tol=self.tol, + seed=self.seed, + dtype=self.dtype, ) # Get the constants made within the class constant_kwargs = dict(n_clusters=self.n_clusters) diff --git a/catlearn/regression/gp/ensemble/clustering/k_means_number.py b/catlearn/regression/gp/ensemble/clustering/k_means_number.py index 8b0e04d6..a6c6ea53 100644 --- a/catlearn/regression/gp/ensemble/clustering/k_means_number.py +++ b/catlearn/regression/gp/ensemble/clustering/k_means_number.py @@ -1,4 +1,5 @@ -import numpy as np +from numpy import append, arange, argmin, argsort, array, asarray +from numpy.linalg import norm from .k_means import K_means @@ -9,6 +10,8 @@ def __init__( data_number=25, maxiter=100, tol=1e-4, + seed=None, + dtype=float, **kwargs, ): """ @@ -17,91 +20,129 @@ def __init__( of clusters are updated from a fixed number data point in each cluster. Parameters: - metric : str + metric: str The metric used to calculate the distances of the data. - data_number : int + data_number: int The number of data point in each cluster. - maxiter : int + maxiter: int The maximum number of iterations used to fit the clusters. - tol : float + tol: float The tolerance before the cluster fit is converged. + seed: int (optional) + The random seed. + The seed can be an integer, RandomState, or Generator instance. + If not given, the default random number generator is used. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. """ super().__init__( metric=metric, data_number=data_number, maxiter=maxiter, tol=tol, + seed=seed, + dtype=dtype, **kwargs, ) def cluster_fit_data(self, X, **kwargs): + # Copy the data + X = array(X, dtype=self.dtype) # Calculate the number of clusters - n_data = len(X) - self.n_clusters = int(n_data // self.data_number) - if n_data - (self.n_clusters * self.data_number): - self.n_clusters = self.n_clusters + 1 + self.n_clusters = self.calc_n_clusters(X) # If only one cluster is used give the full data if self.n_clusters == 1: - self.centroids = np.array([np.mean(X, axis=0)]) - return [np.arange(n_data)] + self.centroids = asarray([X.mean(axis=0)]) + return [arange(len(X))] # Initiate the centroids centroids = self.initiate_centroids(X) # Optimize position of the centroids self.centroids, cluster_indicies = self.optimize_centroids( - X, centroids + X, + centroids, ) # Return the cluster indicies return cluster_indicies def update_arguments( - self, metric=None, data_number=None, maxiter=None, tol=None, **kwargs + self, + metric=None, + data_number=None, + maxiter=None, + tol=None, + seed=None, + dtype=None, + **kwargs, ): """ Update the class with its arguments. The existing arguments are used if they are not given. Parameters: - metric : str + metric: str The metric used to calculate the distances of the data. - data_number : int + data_number: int The number of data point in each cluster. - maxiter : int + maxiter: int The maximum number of iterations used to fit the clusters. - tol : float + tol: float The tolerance before the cluster fit is converged. + seed: int (optional) + The random seed. + The seed can be an integer, RandomState, or Generator instance. + If not given, the default random number generator is used. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. Returns: self: The updated object itself. """ - if metric is not None: - self.metric = metric if data_number is not None: self.data_number = int(data_number) - if maxiter is not None: - self.maxiter = int(maxiter) - if tol is not None: - self.tol = tol + # Set the arguments of the parent class + super().update_arguments( + metric=metric, + n_clusters=None, + maxiter=maxiter, + tol=tol, + seed=seed, + dtype=dtype, + ) return self + def calc_n_clusters(self, X, **kwargs): + """ + Calculate the number of clusters based on the data. + """ + n_data = len(X) + n_clusters = int(n_data // self.data_number) + if n_data - (n_clusters * self.data_number): + n_clusters += 1 + return n_clusters + def optimize_centroids(self, X, centroids, **kwargs): "Optimize the positions of the centroids." - indicies = np.arange(len(X)) - for i in range(1, self.maxiter + 1): + indicies = arange(len(X)) + for _ in range(1, self.maxiter + 1): # Store the old centroids centroids_old = centroids.copy() # Calculate which centroids that are closest distance_matrix = self.calculate_distances(X, centroids) cluster_indicies = self.count_clusters( - X, indicies, distance_matrix + X, + indicies, + distance_matrix, ) - centroids = np.array( + centroids = asarray( [ - np.mean(X[indicies_ki], axis=0) + X[indicies_ki].mean(axis=0) for indicies_ki in cluster_indicies ] ) # Check if it is converged - if np.linalg.norm(centroids - centroids_old) <= self.tol: + if norm(centroids - centroids_old) <= self.tol: break return centroids, cluster_indicies @@ -112,17 +153,17 @@ def count_clusters(self, X, indicies, distance_matrix, **kwargs): between the minimum and maximum number of allowed cluster sizes. """ # Make a list cluster indicies - klist = np.arange(self.n_clusters).reshape(-1, 1) + klist = arange(self.n_clusters).reshape(-1, 1) # Find the cluster that each point is closest to - k_indicies = np.argmin(distance_matrix, axis=1) + k_indicies = argmin(distance_matrix, axis=1) indicies_ki_bool = klist == k_indicies # Sort the indicies as function of the distances to the centroids - d_indicies = np.argsort(distance_matrix, axis=0) + d_indicies = argsort(distance_matrix, axis=0) indicies_sorted = indicies[d_indicies.T] indicies_ki_bool = indicies_ki_bool[klist, indicies_sorted] # Prioritize the points that is part of each cluster cluster_indicies = [ - np.append( + append( indicies_sorted[ki, indicies_ki_bool[ki]], indicies_sorted[ki, ~indicies_ki_bool[ki]], )[: self.data_number] @@ -138,6 +179,8 @@ def get_arguments(self): data_number=self.data_number, maxiter=self.maxiter, tol=self.tol, + seed=self.seed, + dtype=self.dtype, ) # Get the constants made within the class constant_kwargs = dict(n_clusters=self.n_clusters) diff --git a/catlearn/regression/gp/ensemble/clustering/random.py b/catlearn/regression/gp/ensemble/clustering/random.py index 743c1682..701bdaca 100644 --- a/catlearn/regression/gp/ensemble/clustering/random.py +++ b/catlearn/regression/gp/ensemble/clustering/random.py @@ -1,4 +1,4 @@ -import numpy as np +from numpy import append, arange, array_split, tile from .clustering import Clustering @@ -8,6 +8,7 @@ def __init__( n_clusters=4, equal_size=True, seed=None, + dtype=float, **kwargs, ): """ @@ -15,24 +16,30 @@ def __init__( The K-means++ algorithm for clustering. Parameters: - n_clusters : int + n_clusters: int The number of used clusters. - equal_size : bool + equal_size: bool Whether the clusters are forced to have the same size. - seed : int (optional) - The random seed used to permute the indicies. - If seed=None or False or 0, a random seed is not used. + seed: int (optional) + The random seed. + The seed can be an integer, RandomState, or Generator instance. + If not given, the default random number generator is used. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. """ - # Set a random seed - self.seed = seed super().__init__( - n_clusters=n_clusters, equal_size=equal_size, seed=seed, **kwargs + n_clusters=n_clusters, + equal_size=equal_size, + seed=seed, + dtype=dtype, + **kwargs, ) def cluster_fit_data(self, X, **kwargs): # Make indicies n_data = len(X) - indicies = np.arange(n_data) + indicies = arange(n_data) # If only one cluster is used give the full data if self.n_clusters == 1: return [indicies] @@ -45,20 +52,29 @@ def cluster(self, X, **kwargs): return self.cluster_fit_data(X) def update_arguments( - self, n_clusters=None, equal_size=None, seed=None, **kwargs + self, + n_clusters=None, + equal_size=None, + seed=None, + dtype=None, + **kwargs, ): """ Update the class with its arguments. The existing arguments are used if they are not given. Parameters: - n_clusters : int + n_clusters: int The number of used clusters. - equal_size : bool + equal_size: bool Whether the clusters are forced to have the same size. - seed : int (optional) - The random seed used to permute the indicies. - If seed=None or False or 0, a random seed is not used. + seed: int (optional) + The random seed. + The seed can be an integer, RandomState, or Generator instance. + If not given, the default random number generator is used. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. Returns: self: The updated object itself. @@ -67,8 +83,11 @@ def update_arguments( self.n_clusters = int(n_clusters) if equal_size is not None: self.equal_size = equal_size - if seed is not None: - self.seed = seed + # Set the parameters of the parent class + super().update_arguments( + seed=seed, + dtype=dtype, + ) return self def randomized_clusters(self, indicies, n_data, **kwargs): @@ -78,15 +97,12 @@ def randomized_clusters(self, indicies, n_data, **kwargs): # Ensure equal sizes of clusters if chosen if self.equal_size: i_perm = self.ensure_equal_sizes(i_perm, n_data) - i_clusters = np.array_split(i_perm, self.n_clusters) + i_clusters = array_split(i_perm, self.n_clusters) return i_clusters def get_permutation(self, indicies): "Permute the indicies" - if self.seed: - rng = np.random.default_rng(seed=self.seed) - return rng.permutation(indicies) - return np.random.permutation(indicies) + return self.rng.permutation(indicies) def ensure_equal_sizes(self, i_perm, n_data, **kwargs): "Extend the permuted indicies so the clusters have equal sizes." @@ -100,12 +116,12 @@ def ensure_equal_sizes(self, i_perm, n_data, **kwargs): # Extend the permuted indicies if n_missing > 0: if n_missing > n_data: - i_perm = np.append( + i_perm = append( i_perm, - np.tile(i_perm, (n_missing // n_data) + 1)[:n_missing], + tile(i_perm, (n_missing // n_data) + 1)[:n_missing], ) else: - i_perm = np.append(i_perm, i_perm[:n_missing]) + i_perm = append(i_perm, i_perm[:n_missing]) return i_perm def get_arguments(self): @@ -115,6 +131,7 @@ def get_arguments(self): n_clusters=self.n_clusters, equal_size=self.equal_size, seed=self.seed, + dtype=self.dtype, ) # Get the constants made within the class constant_kwargs = dict() diff --git a/catlearn/regression/gp/ensemble/clustering/random_number.py b/catlearn/regression/gp/ensemble/clustering/random_number.py index 2dd54e93..cb04987d 100644 --- a/catlearn/regression/gp/ensemble/clustering/random_number.py +++ b/catlearn/regression/gp/ensemble/clustering/random_number.py @@ -1,26 +1,35 @@ -import numpy as np +from numpy import append, arange, array_split, tile from .random import RandomClustering class RandomClustering_number(RandomClustering): - def __init__(self, data_number=25, seed=None, **kwargs): + def __init__(self, data_number=25, seed=None, dtype=float, **kwargs): """ Clustering class object for data sets. The K-means++ algorithm for clustering. Parameters: - data_number : int + data_number: int The number of data point in each cluster. - seed : int (optional) - The random seed used to permute the indicies. - If seed=None or False or 0, a random seed is not used. + seed: int (optional) + The random seed. + The seed can be an integer, RandomState, or Generator instance. + If not given, the default random number generator is used. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. """ - super().__init__(data_number=data_number, seed=seed, **kwargs) + super(RandomClustering, self).__init__( + data_number=data_number, + seed=seed, + dtype=dtype, + **kwargs, + ) def cluster_fit_data(self, X, **kwargs): # Make indicies n_data = len(X) - indicies = np.arange(n_data) + indicies = arange(n_data) # Calculate the number of clusters self.n_clusters = int(n_data // self.data_number) if n_data - (self.n_clusters * self.data_number): @@ -33,25 +42,38 @@ def cluster_fit_data(self, X, **kwargs): # Return the cluster indicies return i_clusters - def update_arguments(self, data_number=None, seed=None, **kwargs): + def update_arguments( + self, + data_number=None, + seed=None, + dtype=None, + **kwargs, + ): """ Update the class with its arguments. The existing arguments are used if they are not given. Parameters: - data_number : int + data_number: int The number of data point in each cluster. - seed : int (optional) - The random seed used to permute the indicies. - If seed=None or False or 0, a random seed is not used. + seed: int (optional) + The random seed. + The seed can be an integer, RandomState, or Generator instance. + If not given, the default random number generator is used. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. Returns: self: The updated object itself. """ if data_number is not None: self.data_number = int(data_number) - if seed is not None: - self.seed = seed + # Set the parameters of the parent class + super(RandomClustering, self).update_arguments( + seed=seed, + dtype=dtype, + ) return self def randomized_clusters(self, indicies, n_data, **kwargs): @@ -59,7 +81,7 @@ def randomized_clusters(self, indicies, n_data, **kwargs): i_perm = self.get_permutation(indicies) # Ensure equal sizes of clusters i_perm = self.ensure_equal_sizes(i_perm, n_data) - i_clusters = np.array_split(i_perm, self.n_clusters) + i_clusters = array_split(i_perm, self.n_clusters) return i_clusters def ensure_equal_sizes(self, i_perm, n_data, **kwargs): @@ -69,18 +91,22 @@ def ensure_equal_sizes(self, i_perm, n_data, **kwargs): # Extend the permuted indicies if n_missing > 0: if n_missing > n_data: - i_perm = np.append( + i_perm = append( i_perm, - np.tile(i_perm, (n_missing // n_data) + 1)[:n_missing], + tile(i_perm, (n_missing // n_data) + 1)[:n_missing], ) else: - i_perm = np.append(i_perm, i_perm[:n_missing]) + i_perm = append(i_perm, i_perm[:n_missing]) return i_perm def get_arguments(self): "Get the arguments of the class itself." # Get the arguments given to the class in the initialization - arg_kwargs = dict(data_number=self.data_number, seed=self.seed) + arg_kwargs = dict( + data_number=self.data_number, + seed=self.seed, + dtype=self.dtype, + ) # Get the constants made within the class constant_kwargs = dict(n_clusters=self.n_clusters) # Get the objects made within the class diff --git a/catlearn/regression/gp/ensemble/ensemble.py b/catlearn/regression/gp/ensemble/ensemble.py index ae38c308..1b653211 100644 --- a/catlearn/regression/gp/ensemble/ensemble.py +++ b/catlearn/regression/gp/ensemble/ensemble.py @@ -1,5 +1,8 @@ -import numpy as np +from numpy import asarray, exp +import pickle +import warnings from ..means.constant import Prior_constant +from ..calculator.mlmodel import get_default_model class EnsembleModel: @@ -9,57 +12,99 @@ def __init__( use_variance_ensemble=True, use_softmax=False, use_same_prior_mean=True, + dtype=float, **kwargs, ): """ Ensemble model of machine learning models. Parameters: - model : Model + model: Model The Machine Learning Model with kernel and prior that are optimized. - use_variance_ensemble : bool + use_variance_ensemble: bool Whether to use the predicted inverse variances to weight the predictions. Else an average of the predictions is used. - use_softmax : bool + use_softmax: bool Whether to use the softmax of the predicted inverse variances as weights. It is only active if use_variance_ensemble=True, too. - use_same_prior_mean : bool + use_same_prior_mean: bool Whether to use the same prior mean for all models. + dtype: type + The data type of the arrays. """ # Make default model if it is not given if model is None: - from ..calculator.mlmodel import get_default_model - - model = get_default_model() + model = get_default_model(dtype=dtype) # Set the arguments self.update_arguments( model=model, use_variance_ensemble=use_variance_ensemble, use_softmax=use_softmax, use_same_prior_mean=use_same_prior_mean, + dtype=dtype, **kwargs, ) def train(self, features, targets, **kwargs): """ Train the model with training features and targets. + Parameters: - features : (N,D) array or (N) list of fingerprint objects + features: (N,D) array or (N) list of fingerprint objects Training features with N data points. - targets : (N,1) array - Training targets with N data points - or - targets : (N,1+D) array - Training targets in first column and derivatives - of each feature in the next columns if use_derivatives is True. + targets: (N,1) array or (N,1+D) array + Training targets with N data points. + If use_derivatives=True, the training targets is in + first column and derivatives is in the next columns. + Returns: self: The trained model object itself. """ raise NotImplementedError() + def optimize( + self, + features, + targets, + retrain=True, + hp=None, + pdis=None, + verbose=False, + **kwargs, + ): + """ + Optimize the hyperparameter of the model and its kernel. + + Parameters: + features: (N,D) array or (N) list of fingerprint objects + Training features with N data points. + targets: (N,1) array or (N,D+1) array + Training targets with or without derivatives with + N data points. + retrain: bool + Whether to retrain the model after the optimization. + hp: dict + Use a set of hyperparameters to optimize from + else the current set is used. + maxiter: int + Maximum number of iterations used by local or + global optimization method. + pdis: dict + A dict of prior distributions for each hyperparameter type. + verbose: bool + Print the optimized hyperparameters and + the object function value. + + Returns: + list: List of solution dictionaries with objective function value, + optimized hyperparameters, success statement, + and number of used evaluations. + """ + raise NotImplementedError() + def predict( self, features, @@ -75,28 +120,28 @@ def predict( coefficients from training data. Parameters: - features : (M,D) array or (M) list of fingerprint objects + features: (M,D) array or (M) list of fingerprint objects Test features with M data points. - get_derivatives : bool + get_derivatives: bool Whether to predict the derivatives of the prediction mean. - get_variance : bool + get_variance: bool Whether to predict the variance of the targets. - include_noise : bool + include_noise: bool Whether to include the noise of data in the predicted variance. - get_derivtives_var : bool + get_derivtives_var: bool Whether to predict the variance of the derivatives of the targets. - get_var_derivatives : bool + get_var_derivatives: bool Whether to calculate the derivatives of the predicted variance of the targets. Returns: - Y_predict : (M,1) or (M,1+D) array + Y_predict: (M,1) or (M,1+D) array The predicted mean values with or without derivatives. - var : (M,1) or (M,1+D) array + var: (M,1) or (M,1+D) array The predicted variance of the targets with or without derivatives. - var_deriv : (M,D) array + var_deriv: (M,D) array The derivatives of the predicted variance of the targets. """ # Calculate the predicted values for one model @@ -154,64 +199,76 @@ def predict_mean(self, features, get_derivatives=False, **kwargs): from training data. Parameters: - features : (M,D) array or (M) list of fingerprint objects + features: (M,D) array or (M) list of fingerprint objects Test features with M data points. - get_derivatives : bool + get_derivatives: bool Whether to predict the derivatives of the prediction mean. Returns: - Y_predict : (M,1) array + Y_predict: (M,1) array The predicted mean values if get_derivatives=False or - Y_predict : (M,1+D) array + Y_predict: (M,1+D) array The predicted mean values and its derivatives if get_derivatives=True. """ # Check if the variance was needed for prediction mean if self.use_variance_ensemble: - raise Exception( + raise AttributeError( "The predict_mean function is not defined " "with use_variance_ensemble=True!" ) # Calculate the predicted values for one model if self.n_models == 1: return self.model_prediction_mean( - self.model, features, get_derivatives=get_derivatives, **kwargs + self.model, + features, + get_derivatives=get_derivatives, + **kwargs, ) # Calculate the predicted values for multiple model Y_preds = [] for model in self.models: Y_predict = self.model_prediction_mean( - model, features, get_derivatives=get_derivatives, **kwargs + model, + features, + get_derivatives=get_derivatives, + **kwargs, ) Y_preds.append(Y_predict) return self.ensemble( - Y_preds, get_derivatives=get_derivatives, get_variance=False + Y_preds, + get_derivatives=get_derivatives, + get_variance=False, ) def predict_variance( - self, features, get_derivatives=False, include_noise=False, **kwargs + self, + features, + get_derivatives=False, + include_noise=False, + **kwargs, ): """ Calculate the predicted variance of the test targets. Parameters: - features : (M,D) array or (M) list of fingerprint objects + features: (M,D) array or (M) list of fingerprint objects Test features with M data points. - KQX : (M,N) or (M,N+N*D) or (M+M*D,N+N*D) array + KQX: (M,N) or (M,N+N*D) or (M+M*D,N+N*D) array The kernel matrix of the test and training features. If KQX=None, it is calculated. - get_derivatives : bool + get_derivatives: bool Whether to predict the uncertainty of the derivatives of the targets. - include_noise : bool + include_noise: bool Whether to include the noise of data in the predicted variance Returns: - var : (M,1) array + var: (M,1) array The predicted variance of the targets if get_derivatives=False. or - var : (M,1+D) array + var: (M,1+D) array The predicted variance of the targets and its derivatives if get_derivatives=True. @@ -224,56 +281,33 @@ def calculate_variance_derivatives(self, features, **kwargs): of the test targets. Parameters: - features : (M,D) array or (M) list of fingerprint objects + features: (M,D) array or (M) list of fingerprint objects Test features with M data points. - KQX : (M,N) or (M,N+N*D) or (M+M*D,N+N*D) array + KQX: (M,N) or (M,N+N*D) or (M+M*D,N+N*D) array The kernel matrix of the test and training features. If KQX=None, it is calculated. Returns: - var_deriv : (M,D) array + var_deriv: (M,D) array The derivatives of the predicted variance of the targets. """ raise NotImplementedError() - def optimize( - self, - features, - targets, - retrain=True, - hp=None, - pdis=None, - verbose=False, - **kwargs, - ): + def get_hyperparams(self, **kwargs): """ - Optimize the hyperparameter of the model and its kernel. + Get the hyperparameters for the model and the kernel. - Parameters: - features : (N,D) array or (N) list of fingerprint objects - Training features with N data points. - targets : (N,1) array or (N,D+1) array - Training targets with or without derivatives with - N data points. - retrain : bool - Whether to retrain the model after the optimization. - hp : dict - Use a set of hyperparameters to optimize from - else the current set is used. - maxiter : int - Maximum number of iterations used by local or - global optimization method. - pdis : dict - A dict of prior distributions for each hyperparameter type. - verbose : bool - Print the optimized hyperparameters and - the object function value. Returns: - list : List of solution dictionaries with objective function value, - optimized hyperparameters, success statement, - and number of used evaluations. + dict: The hyperparameters in the log-space from + the model and kernel class if multiple models are not defined. + or + list: A list of dictionaries with the hyperparameters + in the log-space from the model and kernel class + if multiple models are defined. """ - raise NotImplementedError() + if len(self.models): + return [model.get_hyperparams() for model in self.models] + return self.model.get_hyperparams() def get_use_derivatives(self): "Get whether the derivatives of the targets are used." @@ -283,12 +317,113 @@ def get_use_fingerprint(self): "Get whether a fingerprint is used as the features." return self.model.get_use_fingerprint() + def set_dtype(self, dtype, **kwargs): + """ + Set the data type of the arrays. + + Parameters: + dtype: type + The data type of the arrays. + + Returns: + self: The updated object itself. + """ + # Set the data type + self.dtype = dtype + # Set the data type of the attributes + self.model.set_dtype(dtype=dtype, **kwargs) + self.copy_prior() + if len(self.models): + for model in self.models: + model.set_dtype(dtype=dtype, **kwargs) + return self + + def set_seed(self, seed, **kwargs): + """ + Set the random seed. + + Parameters: + seed: int (optional) + The random seed. + The seed can be an integer, RandomState, or Generator instance. + If not given, the default random number generator is used. + + Returns: + self: The instance itself. + """ + self.model.set_seed(seed) + if len(self.models): + for model in self.models: + model.set_seed(seed) + return self + + def set_use_derivatives(self, use_derivatives, **kwargs): + """ + Set whether to use derivatives/gradients for training and predictions. + + Parameters: + use_derivatives: bool + Use derivatives/gradients for training and predictions. + + Returns: + self: The updated object itself. + """ + # Set whether to use derivatives for the kernel + self.model.set_use_derivatives(use_derivatives) + return self + + def set_use_fingerprint(self, use_fingerprint, **kwargs): + """ + Set whether to use a fingerprint as the features. + + Parameters: + use_fingerprint: bool + Use a fingerprint as the features. + + Returns: + self: The updated object itself. + """ + # Set whether to use a fingerprint for the features + self.model.set_use_fingerprint(use_fingerprint) + return self + + def save_model(self, filename="model.pkl", **kwargs): + """ + Save the model object to a file. + + Parameters: + filename: str + The name of the file where the object is saved. + + Returns: + self: The object itself. + """ + with open(filename, "wb") as file: + pickle.dump(self, file) + return self + + def load_model(self, filename="model.pkl", **kwargs): + """ + Load the model object from a file. + + Parameters: + filename: str + The name of the file where the object is saved. + + Returns: + model: The loaded model object. + """ + with open(filename, "rb") as file: + model = pickle.load(file) + return model + def update_arguments( self, model=None, use_variance_ensemble=None, use_softmax=None, use_same_prior_mean=None, + dtype=None, **kwargs, ): """ @@ -296,19 +431,21 @@ def update_arguments( The existing arguments are used if they are not given. Parameters: - model : Model + model: Model The Machine Learning Model with kernel and prior that are optimized. - use_variance_ensemble : bool + use_variance_ensemble: bool Whether to use the predicted inverse variances to weight the predictions. Else an average of the predictions is used. - use_softmax : bool + use_softmax: bool Whether to use the softmax of the predicted inverse variances as weights. It is only active if use_variance_ensemble=True, too. - use_same_prior_mean : bool + use_same_prior_mean: bool Whether to use the same prior mean for all models. + dtype: type + The data type of the arrays. Returns: self: The updated object itself. @@ -316,16 +453,29 @@ def update_arguments( if model is not None: self.model = model.copy() # Set descriptor of the ensemble model - self.n_models = 1 - self.models = [] - # Get the prior mean instance - self.prior = self.model.prior.copy() + self.reset_models() if use_variance_ensemble is not None: self.use_variance_ensemble = use_variance_ensemble if use_softmax is not None: self.use_softmax = use_softmax if use_same_prior_mean is not None: self.use_same_prior_mean = use_same_prior_mean + if dtype is not None or not hasattr(self, "dtype"): + self.set_dtype(dtype=dtype, **kwargs) + return self + + def copy_prior(self): + "Copy the prior of the model." + self.prior = self.model.prior.copy() + return self + + def reset_models(self, **kwargs): + "Reset the models." + # Set descriptor of the ensemble model + self.n_models = 1 + self.models = [] + # Get the prior mean instance + self.copy_prior() return self def model_training(self, model, features, targets, **kwargs): @@ -377,11 +527,17 @@ def model_prediction( ) def model_prediction_mean( - self, model, features, get_derivatives=False, **kwargs + self, + model, + features, + get_derivatives=False, + **kwargs, ): "Predict mean with the model." return model.predict_mean( - features, get_derivatives=get_derivatives, **kwargs + features, + get_derivatives=get_derivatives, + **kwargs, ) def model_prediction_variance( @@ -423,13 +579,13 @@ def ensemble( The variance weighted ensemble is used if variance_ensemble=True. """ # Transform the input to arrays - Y_preds = np.array(Y_preds) + Y_preds = asarray(Y_preds) if get_variance: - var_preds = np.array(var_preds) + var_preds = asarray(var_preds) else: var_preds = None if get_var_derivatives and var_derivs is not None: - var_derivs = np.array(var_derivs) + var_derivs = asarray(var_derivs) else: var_derivs = None # Perform ensemble of the predictions @@ -474,23 +630,22 @@ def ensemble_mean( var_predict = None var_deriv = None # Calculate the prediction mean - Y_predict = np.mean(Y_preds, axis=0) + Y_predict = Y_preds.mean(axis=0) # Calculate the predicted variance if get_variance: - var_predict = np.mean( - var_preds + ((Y_preds - Y_predict) ** 2), axis=0 + var_predict = (var_preds + ((Y_preds - Y_predict) ** 2)).mean( + axis=0 ) # Calculate the derivative of the predicted variance if get_var_derivatives: - var_deriv = np.mean( + var_deriv = ( var_derivs + ( 2.0 * (Y_preds[:, :, 0] - Y_predict[:, 0]) * (Y_preds[:, :, 1:] - Y_predict[:, 1:]) - ), - axis=0, - ) + ) + ).mean(axis=0) return Y_predict, var_predict, var_deriv def ensemble_variance( @@ -516,28 +671,26 @@ def ensemble_variance( var_preds, var_derivs, get_derivatives ) # Calculate the prediction mean - Y_predict = np.sum(weights * Y_preds, axis=0) + Y_predict = (weights * Y_preds).sum(axis=0) # Calculate the derivative of the prediction mean if get_derivatives: # Add extra contribution from weight derivatives - Y_predict[:, 1:] += np.sum( - Y_preds[:, :, 0:1] * weights_deriv, axis=0 + Y_predict[:, 1:] += (Y_preds[:, :, 0:1] * weights_deriv).sum( + axis=0 ) # Calculate the predicted variance if get_variance: - var_predict = np.sum( - weights * (var_preds + ((Y_preds - Y_predict) ** 2)), axis=0 - ) + var_predict = ( + weights * (var_preds + ((Y_preds - Y_predict) ** 2)) + ).sum(axis=0) if get_derivtives_var: - import warnings - warnings.warn( "Check if it is the right expression for" "the variance of the derivatives!" ) # Calculate the derivative of the predicted variance if get_var_derivatives: - var_deriv = np.sum( + var_deriv = ( weights * ( var_derivs @@ -546,36 +699,39 @@ def ensemble_variance( * (Y_preds[:, :, 0:1] - Y_predict[:, 0:1]) * (Y_preds[:, :, 1:] - Y_predict[:, 1:]) ) - ), - axis=0, - ) - var_deriv += np.sum(var_preds[:, :, 0:1] * weights_deriv, axis=0) + ) + ).sum(axis=0) + var_deriv += (var_preds[:, :, 0:1] * weights_deriv).sum(axis=0) return Y_predict, var_predict, var_deriv def get_weights( - self, var_preds=None, var_derivs=None, get_derivatives=False, **kwargs + self, + var_preds=None, + var_derivs=None, + get_derivatives=False, + **kwargs, ): "Calculate the weights." weights_deriv = None if var_preds is None: - raise Exception("The predicted variance is missing!") + raise AttributeError("The predicted variance is missing!") # Use the predicted variance to weight predictions if self.use_softmax: - var_coef = np.exp(-var_preds[:, :, 0:1]) + var_coef = exp(-var_preds[:, :, 0:1]) else: var_coef = 1.0 / var_preds[:, :, 0:1] # Normalize the weights - weights = var_coef / np.sum(var_coef, axis=0) + weights = var_coef / var_coef.sum(axis=0) # Calculate the derivative of the prediction mean if get_derivatives: # Calculate the derivative of the weights if self.use_softmax: weights_deriv = weights * ( - np.sum(weights * var_derivs, axis=0) - var_derivs + (weights * var_derivs).sum(axis=0) - var_derivs ) else: weights_deriv = weights * ( - np.sum(weights * var_coef * var_derivs, axis=0) + (weights * var_coef * var_derivs).sum(axis=0) - (var_coef * var_derivs) ) return weights, weights_deriv @@ -600,6 +756,7 @@ def get_arguments(self): use_variance_ensemble=self.use_variance_ensemble, use_softmax=self.use_softmax, use_same_prior_mean=self.use_same_prior_mean, + dtype=self.dtype, ) # Get the constants made within the class constant_kwargs = dict(n_models=self.n_models) diff --git a/catlearn/regression/gp/ensemble/ensemble_clustering.py b/catlearn/regression/gp/ensemble/ensemble_clustering.py index 3c8d41ca..066401fc 100644 --- a/catlearn/regression/gp/ensemble/ensemble_clustering.py +++ b/catlearn/regression/gp/ensemble/ensemble_clustering.py @@ -1,5 +1,6 @@ -import numpy as np -from .ensemble import EnsembleModel +from numpy import array, ndarray +from .ensemble import EnsembleModel, get_default_model +from .clustering.k_means_number import K_means_number class EnsembleClustering(EnsembleModel): @@ -10,6 +11,7 @@ def __init__( use_variance_ensemble=True, use_softmax=False, use_same_prior_mean=True, + dtype=float, **kwargs, ): """ @@ -17,33 +19,31 @@ def __init__( from a clustering algorithm.. Parameters: - model : Model + model: Model The Machine Learning Model with kernel and prior that are optimized. - clustering : Clustering class object + clustering: Clustering class object The clustering method used to split the data to different models. - use_variance_ensemble : bool + use_variance_ensemble: bool Whether to use the predicted inverse variances to weight the predictions. Else an average of the predictions is used. - use_softmax : bool + use_softmax: bool Whether to use the softmax of the predicted inverse variances as weights. It is only active if use_variance_ensemble=True, too. - use_same_prior_mean : bool + use_same_prior_mean: bool Whether to use the same prior mean for all models. + dtype: type + The data type of the arrays. """ # Make default model if it is not given if model is None: - from ..calculator.mlmodel import get_default_model - - model = get_default_model() + model = get_default_model(dtype=dtype) # Make default clustering if it is not given if clustering is None: - from .clustering.k_means_number import K_means_number - - clustering = K_means_number() + clustering = K_means_number(dtype=dtype) # Set the arguments self.update_arguments( model=model, @@ -51,6 +51,7 @@ def __init__( use_variance_ensemble=use_variance_ensemble, use_softmax=use_softmax, use_same_prior_mean=use_same_prior_mean, + dtype=dtype, **kwargs, ) @@ -124,6 +125,18 @@ def optimize( self.models.append(model) return sols + def set_dtype(self, dtype, **kwargs): + super().set_dtype(dtype, **kwargs) + # Set the data type of the clustering + self.clustering.set_dtype(dtype=dtype) + return self + + def set_seed(self, seed, **kwargs): + super().set_seed(seed, **kwargs) + # Set the random seed of the clustering + self.clustering.set_seed(seed=seed) + return self + def update_arguments( self, model=None, @@ -131,6 +144,7 @@ def update_arguments( use_variance_ensemble=None, use_softmax=None, use_same_prior_mean=None, + dtype=None, **kwargs, ): """ @@ -138,49 +152,49 @@ def update_arguments( The existing arguments are used if they are not given. Parameters: - model : Model + model: Model The Machine Learning Model with kernel and prior that are optimized. - clustering : Clustering class object + clustering: Clustering class object The clustering method used to split the data to different models. - use_variance_ensemble : bool + use_variance_ensemble: bool Whether to use the predicted inverse variances to weight the predictions. Else an average of the predictions is used. - use_softmax : bool + use_softmax: bool Whether to use the softmax of the predicted inverse variances as weights. It is only active if use_variance_ensemble=True, too. - use_same_prior_mean : bool + use_same_prior_mean: bool Whether to use the same prior mean for all models. + dtype: type + The data type of the arrays. Returns: self: The updated object itself. """ - if model is not None: - self.model = model.copy() - # Set descriptor of the ensemble model - self.n_models = 1 - self.models = [] - # Get the prior mean instance - self.prior = self.model.prior.copy() if clustering is not None: self.clustering = clustering.copy() - if use_variance_ensemble is not None: - self.use_variance_ensemble = use_variance_ensemble - if use_softmax is not None: - self.use_softmax = use_softmax - if use_same_prior_mean is not None: - self.use_same_prior_mean = use_same_prior_mean + # Set the parameters for the parent class + super().update_arguments( + model=model, + use_variance_ensemble=use_variance_ensemble, + use_softmax=use_softmax, + use_same_prior_mean=use_same_prior_mean, + dtype=dtype, + ) return self def cluster(self, features, targets, **kwargs): "Cluster the data." - if isinstance(features[0], (np.ndarray, list)): - X = features.copy() + if isinstance(features[0], (ndarray, list)): + X = array(features, dtype=self.dtype) else: - X = np.array([feature.get_vector() for feature in features]) + X = array( + [feature.get_vector() for feature in features], + dtype=self.dtype, + ) cluster_indicies = self.clustering.cluster_fit_data(X) return [ (features[indicies_ki], targets[indicies_ki]) @@ -196,6 +210,7 @@ def get_arguments(self): use_variance_ensemble=self.use_variance_ensemble, use_softmax=self.use_softmax, use_same_prior_mean=self.use_same_prior_mean, + dtype=self.dtype, ) # Get the constants made within the class constant_kwargs = dict(n_models=self.n_models) diff --git a/catlearn/regression/gp/hpboundary/boundary.py b/catlearn/regression/gp/hpboundary/boundary.py index a10aca91..7f2e24fd 100644 --- a/catlearn/regression/gp/hpboundary/boundary.py +++ b/catlearn/regression/gp/hpboundary/boundary.py @@ -1,8 +1,26 @@ -import numpy as np +from numpy import ( + array, + concatenate, + exp, + finfo, + full, + linspace, + log, + sqrt, +) +from numpy.random import default_rng, Generator, RandomState class HPBoundaries: - def __init__(self, bounds_dict={}, scale=1.0, log=True, **kwargs): + def __init__( + self, + bounds_dict={}, + scale=1.0, + use_log=True, + seed=None, + dtype=float, + **kwargs, + ): """ Boundary conditions for the hyperparameters. A dictionary with boundary conditions of the hyperparameters @@ -11,18 +29,27 @@ def __init__(self, bounds_dict={}, scale=1.0, log=True, **kwargs): the hyperparameters not given in the dictionary. Parameters: - bounds_dict : dict + bounds_dict: dict A dictionary with boundary conditions as numpy (H,2) arrays with two columns for each type of hyperparameter. - scale : float + scale: float Scale the boundary conditions. - log : bool + use_log: bool Whether to use hyperparameters in log-scale or not. + seed: int (optional) + The random seed. + The seed can be an integer, RandomState, or Generator instance. + If not given, the default random number generator is used. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. """ self.update_arguments( bounds_dict=bounds_dict, scale=scale, - log=log, + use_log=use_log, + seed=seed, + dtype=dtype, **kwargs, ) @@ -31,19 +58,19 @@ def update_bounds(self, model, X, Y, parameters, **kwargs): Create and update the boundary conditions for the hyperparameters. Parameters: - model : Model + model: Model The Machine Learning Model with kernel and prior that are optimized. - X : (N,D) array + X: (N,D) array Training features with N data points and D dimensions. - Y : (N,1) array or (N,D+1) array + Y: (N,1) array or (N,D+1) array Training targets with or without derivatives with N data points. - parameters : (H) list of strings + parameters: (H) list of strings A list of names of the hyperparameters. Returns: - self : The object itself. + self: The object itself. """ # Update parameters self.make_parameters_set(parameters) @@ -57,72 +84,73 @@ def update_bounds(self, model, X, Y, parameters, **kwargs): ) return self - def get_bounds(self, parameters=None, array=False, **kwargs): + def get_bounds(self, parameters=None, use_array=False, **kwargs): """ Get the boundary conditions of the hyperparameters. Parameters : - parameters : list of str or None + parameters: list of str or None A list of the specific used hyperparameter names as strings. If parameters=None, then the stored hyperparameters are used. - array : bool + use_array: bool Whether to get an array or a dictionary as output. Returns: - (H,2) array : The boundary conditions as an array if array=True. + (H,2) array: The boundary conditions as an array if use_array=True. or - dict : A dictionary of the boundary conditions. + dict: A dictionary of the boundary conditions. """ # Make the sorted unique hyperparameters if they are given parameters_set = self.get_parameters_set(parameters=parameters) # Make the boundary conditions for the given hyperparameters - if array: - return np.concatenate( - [self.bounds_dict[para] for para in parameters_set], axis=0 + if use_array: + return concatenate( + [self.bounds_dict[para] for para in parameters_set], + axis=0, ) return {para: self.bounds_dict[para].copy() for para in parameters_set} - def get_hp(self, parameters=None, array=False, **kwargs): + def get_hp(self, parameters=None, use_array=False, **kwargs): """ Get the guess of the hyperparameters. The mean of the boundary conditions in log-space is used as the guess. Parameters: - parameters : list of str or None + parameters: list of str or None A list of the specific used hyperparameter names as strings. If parameters=None, then the stored hyperparameters are used. - array : bool + use_array: bool Whether to get an array or a dictionary as output. Returns: - (H) array : The guesses of the hyperparameters as an array - if array=True. + (H) array: The guesses of the hyperparameters as an array + if use_array=True. or - dict : A dictionary of the guesses of the hyperparameters. + dict: A dictionary of the guesses of the hyperparameters. """ # Make the sorted unique hyperparameters if they are given parameters_set = self.get_parameters_set(parameters=parameters) - if self.log: - if array: - return np.concatenate( + if self.use_log: + if use_array: + return concatenate( [ - np.mean(self.bounds_dict[para], axis=1) + self.bounds_dict[para].mean(axis=1) for para in parameters_set ] ) return { - para: np.mean(self.bounds_dict[para], axis=1) + para: self.bounds_dict[para].mean(axis=1) for para in parameters_set } - if array: - return np.concatenate( + if use_array: + return concatenate( [ - np.exp(np.mean(np.log(self.bounds_dict[para]), axis=1)) + exp(self.bounds_dict[para].mean(axis=1)) for para in parameters_set ] ) return { - para: np.exp(np.mean(np.log(self.bounds_dict[para]), axis=1)) + para: exp(log(self.bounds_dict[para]).mean(axis=1)) for para in parameters_set } @@ -132,21 +160,21 @@ def make_lines(self, parameters=None, ngrid=80, **kwargs): the boundary conditions. Parameters: - parameters : list of str or None + parameters: list of str or None A list of the specific used hyperparameter names as strings. If parameters=None, then the stored hyperparameters are used. - ngrid : int or (H) list + ngrid: int or (H) list An integer or a list with number of grid points in each dimension. Returns: - (H,) list : A list with grid points for each (H) hyperparameters. + (H,) list: A list with grid points for each (H) hyperparameters. """ - bounds = self.get_bounds(parameters=parameters, array=True) + bounds = self.get_bounds(parameters=parameters, use_array=True) if isinstance(ngrid, (int, float)): ngrid = [int(ngrid)] * len(bounds) return [ - np.linspace(bound[0], bound[1], ngrid[b]) + linspace(bound[0], bound[1], ngrid[b]) for b, bound in enumerate(bounds) ] @@ -156,49 +184,98 @@ def make_single_line(self, parameter, ngrid=80, i=0, **kwargs): the boundary conditions. Parameters: - parameters : str + parameters: str A string of the hyperparameter name. - ngrid : int + ngrid: int An integer with number of grid points in each dimension. - i : int + i: int The index of the hyperparameter used if multiple hyperparameters of the same type exist. Returns: - (ngrid) array : A grid of ngrid points for + (ngrid) array: A grid of ngrid points for the given hyperparameter. """ if not isinstance(ngrid, (int, float)): ngrid = ngrid[int(self.parameters.index(parameter) + i)] bound = self.bounds_dict[parameter][i] - return np.linspace(bound[0], bound[1], int(ngrid)) + return linspace(bound[0], bound[1], int(ngrid)) def sample_thetas(self, parameters=None, npoints=50, **kwargs): """ Sample hyperparameters from the boundary conditions. Parameters: - parameters : list of str or None + parameters: list of str or None A list of the specific used hyperparameter names as strings. If parameters=None, then the stored hyperparameters are used. - npoints : int + npoints: int Number of points to sample. Returns: - (npoints,H) array : An array with sampled hyperparameters. + (npoints,H) array: An array with sampled hyperparameters. """ - bounds = self.get_bounds(parameters=parameters, array=True) - return np.random.uniform( + bounds = self.get_bounds(parameters=parameters, use_array=True) + return self.rng.uniform( low=bounds[:, 0], high=bounds[:, 1], size=(int(npoints), len(bounds)), ) + def set_dtype(self, dtype, **kwargs): + """ + Set the data type of the arrays. + + Parameters: + dtype: type + The data type of the arrays. + + Returns: + self: The updated object itself. + """ + # Set the data type + self.dtype = dtype + # Set a small number to avoid division by zero + self.eps = 1.1 * finfo(self.dtype).eps + # Update the data type of the boundary conditions + if hasattr(self, "bounds_dict"): + self.bounds_dict = { + key: array(value, dtype=self.dtype) + for key, value in self.bounds_dict.items() + } + return self + + def set_seed(self, seed=None, **kwargs): + """ + Set the random seed. + + Parameters: + seed: int (optional) + The random seed. + The seed can be an integer, RandomState, or Generator instance. + If not given, the default random number generator is used. + + Returns: + self: The instance itself. + """ + if seed is not None: + self.seed = seed + if isinstance(seed, int): + self.rng = default_rng(self.seed) + elif isinstance(seed, Generator) or isinstance(seed, RandomState): + self.rng = seed + else: + self.seed = None + self.rng = default_rng() + return self + def update_arguments( self, bounds_dict=None, scale=None, - log=None, + use_log=None, + seed=None, + dtype=None, **kwargs, ): """ @@ -206,23 +283,36 @@ def update_arguments( The existing arguments are used if they are not given. Parameters: - bounds_dict : dict + bounds_dict: dict A dictionary with boundary conditions as numpy (H,2) arrays with two columns for each type of hyperparameter. - scale : float + scale: float Scale the boundary conditions. - log : bool + use_log: bool Whether to use hyperparameters in log-scale or not. + seed: int (optional) + The random seed. + The seed can be an integer, RandomState, or Generator instance. + If not given, the default random number generator is used. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. Returns: self: The updated object itself. """ + # Set the seed + if seed is not None or not hasattr(self, "seed"): + self.set_seed(seed) + # Set the data type + if dtype is not None or not hasattr(self, "dtype"): + self.set_dtype(dtype) if bounds_dict is not None: self.initiate_bounds_dict(bounds_dict) if scale is not None: self.scale = scale - if log is not None: - self.log = log + if use_log is not None: + self.use_log = use_log return self def make_bounds(self, model, X, Y, parameters, parameters_set, **kwargs): @@ -234,10 +324,12 @@ def make_bounds(self, model, X, Y, parameters, parameters_set, **kwargs): bounds = {} for para in parameters_set: if para in self.bounds_dict: - bounds[para] = self.bounds_dict[para].copy() + bounds[para] = array(self.bounds_dict[para], dtype=self.dtype) else: - bounds[para] = np.full( - (parameters.count(para), 2), [eps_lower, eps_upper] + bounds[para] = full( + (parameters.count(para), 2), + [eps_lower, eps_upper], + dtype=self.dtype, ) return bounds @@ -248,7 +340,8 @@ def initiate_bounds_dict(self, bounds_dict, **kwargs): """ # Copy the boundary condition values self.bounds_dict = { - key: np.array(value) for key, value in bounds_dict.items() + key: array(value, dtype=self.dtype) + for key, value in bounds_dict.items() } if "correction" in self.bounds_dict.keys(): self.bounds_dict.pop("correction") @@ -288,9 +381,9 @@ def get_n_parameters(self, parameters=None, **kwargs): def get_boundary_limits(self, **kwargs): "Get the machine precision limits for the hyperparameters." - eps_lower = 10 * np.sqrt(2.0 * np.finfo(float).eps) / self.scale - if self.log: - eps_lower = np.log(eps_lower) + eps_lower = 10 * sqrt(2.0 * self.eps) / self.scale + if self.use_log: + eps_lower = log(eps_lower) return eps_lower, -eps_lower return eps_lower, 1.0 / eps_lower @@ -300,7 +393,9 @@ def get_arguments(self): arg_kwargs = dict( bounds_dict=self.bounds_dict, scale=self.scale, - log=self.log, + log=self.use_log, + seed=self.seed, + dtype=self.dtype, ) # Get the constants made within the class constant_kwargs = dict() diff --git a/catlearn/regression/gp/hpboundary/educated.py b/catlearn/regression/gp/hpboundary/educated.py index 6bd929da..e7a4ef63 100644 --- a/catlearn/regression/gp/hpboundary/educated.py +++ b/catlearn/regression/gp/hpboundary/educated.py @@ -1,4 +1,4 @@ -import numpy as np +from numpy import array, asarray, full, log, sqrt from scipy.spatial.distance import pdist from .restricted import RestrictedBoundaries @@ -8,10 +8,12 @@ def __init__( self, bounds_dict={}, scale=1.0, - log=True, + use_log=True, max_length=True, use_derivatives=False, use_prior_mean=True, + seed=None, + dtype=float, **kwargs, ): """ @@ -21,35 +23,44 @@ def __init__( other hyperparameters not given in the dictionary. Parameters: - bounds_dict : dict + bounds_dict: dict A dictionary with boundary conditions as numpy (H,2) arrays with two columns for each type of hyperparameter. - scale : float + scale: float Scale the boundary conditions. - log : bool + use_log: bool Whether to use hyperparameters in log-scale or not. - max_length : bool + max_length: bool Whether to use the maximum scaling for the length-scale or use a more reasonable scaling. - use_derivatives : bool + use_derivatives: bool Whether the derivatives of the target are used in the model. The boundary conditions of the length-scale hyperparameter(s) will change with the use_derivatives. The use_derivatives will be updated when update_bounds is called. - use_prior_mean : bool + use_prior_mean: bool Whether to use the prior mean to calculate the boundary of the prefactor hyperparameter. If use_prior_mean=False, the minimum and maximum target differences are used as the boundary conditions. + seed: int (optional) + The random seed. + The seed can be an integer, RandomState, or Generator instance. + If not given, the default random number generator is used. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. """ self.update_arguments( bounds_dict=bounds_dict, scale=scale, - log=log, + use_log=use_log, max_length=max_length, use_derivatives=use_derivatives, use_prior_mean=use_prior_mean, + seed=seed, + dtype=dtype, **kwargs, ) @@ -57,10 +68,12 @@ def update_arguments( self, bounds_dict=None, scale=None, - log=None, + use_log=None, max_length=None, use_derivatives=None, use_prior_mean=None, + seed=None, + dtype=None, **kwargs, ): """ @@ -68,41 +81,49 @@ def update_arguments( The existing arguments are used if they are not given. Parameters: - bounds_dict : dict + bounds_dict: dict A dictionary with boundary conditions as numpy (H,2) arrays with two columns for each type of hyperparameter. - scale : float + scale: float Scale the boundary conditions. - log : bool + use_log: bool Whether to use hyperparameters in log-scale or not. - max_length : bool + max_length: bool Whether to use the maximum scaling for the length-scale or use a more reasonable scaling. - use_derivatives : bool + use_derivatives: bool Whether the derivatives of the target are used in the model. The boundary conditions of the length-scale hyperparameter(s) will change with the use_derivatives. The use_derivatives will be updated when update_bounds is called. - use_prior_mean : bool + use_prior_mean: bool Whether to use the prior mean to calculate the boundary of the prefactor hyperparameter. If use_prior_mean=False, the minimum and maximum target differences are used as the boundary conditions. + seed: int (optional) + The random seed. + The seed can be an integer, RandomState, or Generator instance. + If not given, the default random number generator is used. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. Returns: self: The updated object itself. """ - if bounds_dict is not None: - self.initiate_bounds_dict(bounds_dict) - if scale is not None: - self.scale = scale - if log is not None: - self.log = log - if max_length is not None: - self.max_length = max_length - if use_derivatives is not None: - self.use_derivatives = use_derivatives + # Update the parameters of the parent class + super().update_arguments( + bounds_dict=bounds_dict, + scale=scale, + use_log=use_log, + max_length=max_length, + use_derivatives=use_derivatives, + seed=seed, + dtype=dtype, + ) + # Update the parameters of the class itself if use_prior_mean is not None: self.use_prior_mean = use_prior_mean return self @@ -117,7 +138,8 @@ def make_bounds(self, model, X, Y, parameters, parameters_set, **kwargs): elif para == "noise": if "noise_deriv" in parameters_set: bounds[para] = self.noise_bound( - Y[:, 0:1], eps_lower=eps_lower + Y[:, 0:1], + eps_lower=eps_lower, ) else: bounds[para] = self.noise_bound(Y, eps_lower=eps_lower) @@ -126,10 +148,12 @@ def make_bounds(self, model, X, Y, parameters, parameters_set, **kwargs): elif para == "prefactor": bounds[para] = self.prefactor_bound(X, Y, model) elif para in self.bounds_dict: - bounds[para] = self.bounds_dict[para].copy() + bounds[para] = array(self.bounds_dict[para], dtype=self.dtype) else: - bounds[para] = np.full( - (parameters.count(para), 2), [eps_lower, eps_upper] + bounds[para] = full( + (parameters.count(para), 2), + [eps_lower, eps_upper], + dtype=self.dtype, ) return bounds @@ -143,7 +167,7 @@ def prefactor_bound(self, X, Y, model, **kwargs): Y_mean = self.get_prior_mean(X, Y, model) Y_std = Y[:, 0:1] - Y_mean # Calculate the variance relative to the prior mean of the targets - a_mean = np.sqrt(np.mean(Y_std**2)) + a_mean = sqrt((Y_std**2).mean()) # Check that all the targets are not the same if a_mean == 0.0: a_mean = 1.00 @@ -153,16 +177,17 @@ def prefactor_bound(self, X, Y, model, **kwargs): else: # Calculate the differences in the target values dif = pdist(Y[:, 0:1]) + dif = asarray(dif, dtype=self.dtype) # Remove zero differences dif = dif[dif != 0.0] # Check that all the targets are not the same if len(dif) == 0: - dif = [1.0] - a_max = np.max(dif) * self.scale - a_min = np.min(dif) / self.scale - if self.log: - return np.array([[np.log(a_min), np.log(a_max)]]) - return np.array([[a_min, a_max]]) + dif = asarray([1.0], dtype=self.dtype) + a_max = dif.max() * self.scale + a_min = dif.min() / self.scale + if self.use_log: + return asarray([[log(a_min), log(a_max)]], dtype=self.dtype) + return asarray([[a_min, a_max]], dtype=self.dtype) def get_prior_mean(self, X, Y, model, **kwargs): "Get the prior mean value for the target only (without derivatives)." @@ -176,10 +201,12 @@ def get_arguments(self): arg_kwargs = dict( bounds_dict=self.bounds_dict, scale=self.scale, - log=self.log, + use_log=self.use_log, max_length=self.max_length, use_derivatives=self.use_derivatives, use_prior_mean=self.use_prior_mean, + seed=self.seed, + dtype=self.dtype, ) # Get the constants made within the class constant_kwargs = dict() diff --git a/catlearn/regression/gp/hpboundary/hptrans.py b/catlearn/regression/gp/hpboundary/hptrans.py index c2c400b1..20e303af 100644 --- a/catlearn/regression/gp/hpboundary/hptrans.py +++ b/catlearn/regression/gp/hpboundary/hptrans.py @@ -1,5 +1,16 @@ -import numpy as np +from numpy import ( + abs as abs_, + array, + concatenate, + exp, + finfo, + full, + linspace, + log, + where, +) from .boundary import HPBoundaries +from .strict import StrictBoundaries class VariableTransformation(HPBoundaries): @@ -8,7 +19,8 @@ def __init__( var_dict={}, bounds=None, s=0.14, - eps=np.finfo(float).eps, + seed=None, + dtype=float, **kwargs, ): """ @@ -20,41 +32,45 @@ def __init__( the variable transformation parameters. Parameters: - var_dict : dict + var_dict: dict A dictionary with the variable transformation parameters (mean,std) for each hyperparameter. - bounds : Boundary condition class + bounds: Boundary condition class A Boundary condition class that make the boundaries of the hyperparameters. The boundaries are used to calculate the variable transformation parameters. - s : float + s: float The scale parameter in a Logistic distribution. It determines how large part of the distribution that is within the boundaries. s=0.5*p/(ln(p)-ln(1-p)) with p being the quantile that the boundaries constitute. - eps : float - The first value of a grid in the variable transformed - hyperparameter space. - The last value of a grid is 1.0-eps. + seed: int (optional) + The random seed. + The seed can be an integer, RandomState, or Generator instance. + If not given, the default random number generator is used. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. """ # Set the default boundary conditions if bounds is None: - from .strict import StrictBoundaries - bounds = StrictBoundaries( bounds_dict={}, scale=1.0, - log=True, + use_log=True, use_prior_mean=True, + seed=seed, + dtype=dtype, ) # Set all the arguments self.update_arguments( var_dict=var_dict, bounds=bounds, s=s, - eps=eps, + seed=seed, + dtype=dtype, **kwargs, ) @@ -64,87 +80,88 @@ def update_bounds(self, model, X, Y, parameters, **kwargs): Therefore, the variable transformation parameters are also updated. Parameters: - model : Model + model: Model The Machine Learning Model with kernel and prior that are optimized. - X : (N,D) array + X: (N,D) array Training features with N data points and D dimensions. - Y : (N,1) array or (N,D+1) array + Y: (N,1) array or (N,D+1) array Training targets with or without derivatives with N data points. - parameters : (H) list of strings + parameters: (H) list of strings A list of names of the hyperparameters. Returns: - self : The object itself. + self: The object itself. """ # Update the parameters used self.make_parameters_set(parameters) # Update the boundary conditions and get them self.bounds.update_bounds(model, X, Y, parameters) - self.bounds_dict = self.bounds.get_bounds(array=False) + self.bounds_dict = self.bounds.get_bounds(use_array=False) # Update the variable transformation parameters for para, bound in self.bounds_dict.items(): self.var_dict[para] = { - "mean": np.mean(bound, axis=1), - "std": self.s * np.abs(bound[:, 1] - bound[:, 0]), + "mean": bound.mean(axis=1), + "std": self.s * abs_(bound[:, 1] - bound[:, 0]), } return self def get_variable_transformation_parameters( self, parameters=None, - array=False, + use_array=False, **kwargs, ): """ Get the variable transformation parameters. Parameters: - parameters : list of str or None + parameters: list of str or None A list of the specific used hyperparameter names as strings. If parameters=None, then the stored hyperparameters are used. - array : bool + use_array: bool Whether to get an array for the mean and std or a dictionary as output. Returns: - dict : A dictionary of the variable transformation parameters. - If array=True, a dictionary with mean and std is given instead. + dict: A dictionary of the variable transformation parameters. + If use_array=True, a dictionary with mean and std is given instead. """ # Make the sorted unique hyperparameters if they are given parameters_set = self.get_parameters_set(parameters=parameters) - if array: + if use_array: var_dict_array = {} - var_dict_array["mean"] = np.concatenate( + var_dict_array["mean"] = concatenate( [self.var_dict[para]["mean"] for para in parameters_set], axis=0, ) - var_dict_array["std"] = np.concatenate( - [self.var_dict[para]["std"] for para in parameters_set], axis=0 + var_dict_array["std"] = concatenate( + [self.var_dict[para]["std"] for para in parameters_set], + axis=0, ) return var_dict_array return {para: self.var_dict[para].copy() for para in parameters_set} - def transformation(self, hp, array=False, **kwargs): + def transformation(self, hp, use_array=False, **kwargs): """ Transform the hyperparameters with the variable transformation to get a dictionary. Parameters: - hp : dict + hp: dict The dictionary of the hyperparameters - array : bool + use_array: bool Whether to get an array or a dictionary as output. Returns: - (H) array : The variable transformed hyperparameters as an array - if array=True. + (H) array: The variable transformed hyperparameters as an array + if use_array=True. or - dict : A dictionary of the variable transformed hyperparameters. + dict: A dictionary of the variable transformed hyperparameters. """ - if array: - return np.concatenate( + if use_array: + return concatenate( [ self.transform( theta, @@ -152,7 +169,7 @@ def transformation(self, hp, array=False, **kwargs): self.var_dict[para]["std"], ) for para, theta in hp.items() - ] + ], ) return { para: self.transform( @@ -163,25 +180,25 @@ def transformation(self, hp, array=False, **kwargs): for para, theta in hp.items() } - def reverse_trasformation(self, t, array=False, **kwargs): + def reverse_trasformation(self, t, use_array=False, **kwargs): """ Transform the variable transformed hyperparameters back to the hyperparameters dictionary. Parameters: - t : dict + t: dict The dictionary of the variable transformed hyperparameters - array : bool + use_array: bool Whether to get an array or a dictionary as output. Returns: - (H) array : The retransformed hyperparameters as an array - if array=True. + (H) array: The retransformed hyperparameters as an array + if use_array=True. or - dict : A dictionary of the retransformed hyperparameters. + dict: A dictionary of the retransformed hyperparameters. """ - if array: - return np.concatenate( + if use_array: + return concatenate( [ self.retransform( ti, @@ -189,7 +206,7 @@ def reverse_trasformation(self, t, array=False, **kwargs): self.var_dict[para]["std"], ) for para, ti in t.items() - ] + ], ) return { para: self.retransform( @@ -203,7 +220,7 @@ def reverse_trasformation(self, t, array=False, **kwargs): def get_bounds( self, parameters=None, - array=False, + use_array=False, transformed=False, **kwargs, ): @@ -211,43 +228,51 @@ def get_bounds( Get the boundary conditions of hyperparameters. Parameters : - parameters : list of str or None + parameters: list of str or None A list of the specific used hyperparameter names as strings. If parameters=None, then the stored hyperparameters are used. - array : bool + use_array: bool Whether to get an array or a dictionary as output. - transformed : bool + transformed: bool If transformed=True, the boundaries is in variable transformed space. If transformed=False, the boundaries is transformed back to hyperparameter space. Returns: - (H,2) array : The boundary conditions as an array if array=True. + (H,2) array: The boundary conditions as an array if use_array=True. or - dict : A dictionary of the boundary conditions. + dict: A dictionary of the boundary conditions. """ # Get the bounds in the variable transformed space if transformed: - if array: + if use_array: n_parameters = self.get_n_parameters(parameters=parameters) - return np.full((n_parameters, 2), [self.eps, 1.00 - self.eps]) + return full( + (n_parameters, 2), + [self.eps, 1.00 - self.eps], + dtype=self.dtype, + ) # Make the sorted unique hyperparameters if they are given parameters_set = self.get_parameters_set(parameters=parameters) return { - para: np.full( + para: full( (len(self.bounds_dict[para]), 2), [self.eps, 1.00 - self.eps], + dtype=self.dtype, ) for para in parameters_set } # Get the bounds in the hyperparameter space - return self.bounds.get_bounds(parameters=parameters, array=array) + return self.bounds.get_bounds( + parameters=parameters, + use_array=use_array, + ) def get_hp( self, parameters=None, - array=False, + use_array=False, transformed=False, **kwargs, ): @@ -256,36 +281,40 @@ def get_hp( The mean of the boundary conditions in log-space is used as the guess. Parameters: - parameters : list of str or None + parameters: list of str or None A list of the specific used hyperparameter names as strings. If parameters=None, then the stored hyperparameters are used. - array : bool + use_array: bool Whether to get an array or a dictionary as output. - transformed : bool + transformed: bool If transformed=True, the boundaries is in variable transformed space. If transformed=False, the boundaries is transformed back to hyperparameter space. Returns: - (H) array : The guesses of the hyperparameters as an array - if array=True. + (H) array: The guesses of the hyperparameters as an array + if use_array=True. or - dict : A dictionary of the guesses of the hyperparameters. + dict: A dictionary of the guesses of the hyperparameters. """ # Get the hyperparameter guess in the variable transformed space (0.5) if transformed: - if array: + if use_array: n_parameters = self.get_n_parameters(parameters=parameters) - return np.full((n_parameters), 0.50) + return full((n_parameters), 0.50, dtype=self.dtype) # Make the sorted unique hyperparameters if they are given parameters_set = self.get_parameters_set(parameters=parameters) return { - para: np.full((len(self.bounds_dict[para])), 0.50) + para: full( + (len(self.bounds_dict[para])), + 0.50, + dtype=self.dtype, + ) for para in parameters_set } # Get the hyperparameter guess in the hyperparameter space - return self.bounds.get_hp(parameters=parameters, array=array) + return self.bounds.get_hp(parameters=parameters, use_array=use_array) def make_lines( self, @@ -299,16 +328,16 @@ def make_lines( the boundary conditions. Parameters: - ngrid : int or (H) list + ngrid: int or (H) list An integer or a list with number of grid points in each dimension. - transformed : bool + transformed: bool If transformed=True, the grid is in variable transformed space. If transformed=False, the grid is transformed back to hyperparameter space. Returns: - (H,) list : A list with grid points for each (H) hyperparameters. + (H,) list: A list with grid points for each (H) hyperparameters. """ # Get the number of hyperparameters n_parameters = self.get_n_parameters(parameters=parameters) @@ -318,13 +347,13 @@ def make_lines( # The grid is made within the variable transformed hyperparameters if transformed: return [ - np.linspace(self.eps, 1.00 - self.eps, ngrid[i]) + linspace(self.eps, 1.00 - self.eps, ngrid[i], dtype=self.dtype) for i in range(n_parameters) ] # The grid is within the transformed space and it is then retransformed var_dict_array = self.get_variable_transformation_parameters( parameters=parameters, - array=True, + use_array=True, ) lines = [] for i, (vt_mean, vt_std) in enumerate( @@ -333,39 +362,49 @@ def make_lines( var_dict_array["std"], ) ): - t_line = np.linspace(self.eps, 1.00 - self.eps, ngrid[i]) + t_line = linspace( + self.eps, + 1.00 - self.eps, + ngrid[i], + dtype=self.dtype, + ) lines.append(self.retransform(t_line, vt_mean, vt_std)) return lines def make_single_line( - self, parameter, ngrid=80, i=0, transformed=False, **kwargs + self, + parameter, + ngrid=80, + i=0, + transformed=False, + **kwargs, ): """ Make grid in each dimension of the hyperparameters from the boundary conditions. Parameters: - parameters : str + parameters: str A string of the hyperparameter name. - ngrid : int + ngrid: int An integer with number of grid points in each dimension. - i : int + i: int The index of the hyperparameter used if multiple hyperparameters of the same type exist. - transformed : bool + transformed: bool If transformed=True, the grid is in variable transformed space. If transformed=False, the grid is transformed back to hyperparameter space. Returns: - (ngrid) array : A grid of ngrid points for + (ngrid) array: A grid of ngrid points for the given hyperparameter. """ # Make sure that a int of number grid points is used if not isinstance(ngrid, (int, float)): ngrid = ngrid[int(self.parameters.index(parameter) + i)] # The grid is made within the variable transformed hyperparameters - t_line = np.linspace(self.eps, 1.00 - self.eps, ngrid) + t_line = linspace(self.eps, 1.00 - self.eps, ngrid, dtype=self.dtype) if transformed: return t_line # The grid is transformed back to hyperparameter space @@ -376,7 +415,11 @@ def make_single_line( ) def sample_thetas( - self, parameters=None, npoints=50, transformed=False, **kwargs + self, + parameters=None, + npoints=50, + transformed=False, + **kwargs, ): """ Sample hyperparameters from the transformed hyperparameter space. @@ -384,23 +427,23 @@ def sample_thetas( then transformed back to the hyperparameter space. Parameters: - parameters : list of str or None + parameters: list of str or None A list of the specific used hyperparameter names as strings. If parameters=None, then the stored hyperparameters are used. - npoints : int + npoints: int Number of points to sample. - transformed : bool + transformed: bool If transformed=True, the grid is in variable transformed space. If transformed=False, the grid is transformed back to hyperparameter space. Returns: - (npoints,H) array : An array with sampled hyperparameters. + (npoints,H) array: An array with sampled hyperparameters. """ # Get the number of hyperparameters n_parameters = self.get_n_parameters(parameters=parameters) # Sample the hyperparameters from the transformed hyperparameter space - samples = np.random.uniform( + samples = self.rng.uniform( low=self.eps, high=1.00 - self.eps, size=(npoints, n_parameters), @@ -410,7 +453,8 @@ def sample_thetas( return samples # The samples are transformed back to hyperparameter space var_dict_array = self.get_variable_transformation_parameters( - parameters=parameters, array=True + parameters=parameters, + use_array=True, ) for i, (vt_mean, vt_std) in enumerate( zip(var_dict_array["mean"], var_dict_array["std"]) @@ -418,12 +462,34 @@ def sample_thetas( samples[:, i] = self.retransform(samples[:, i], vt_mean, vt_std) return samples + def set_dtype(self, dtype, **kwargs): + super().set_dtype(dtype, **kwargs) + # Set the data type of the bounds + self.bounds.set_dtype(dtype, **kwargs) + # Set the data type of the variable transformation parameters + if hasattr(self, "var_dict"): + self.var_dict = { + key: { + "mean": array(value["mean"], dtype=self.dtype), + "std": array(value["std"], dtype=self.dtype), + } + for key, value in self.var_dict.items() + } + return self + + def set_seed(self, seed=None, **kwargs): + super().set_seed(seed, **kwargs) + # Set the seed of the bounds + self.bounds.set_seed(seed, **kwargs) + return self + def update_arguments( self, var_dict=None, bounds=None, s=None, - eps=None, + seed=None, + dtype=None, **kwargs, ): """ @@ -431,55 +497,65 @@ def update_arguments( The existing arguments are used if they are not given. Parameters: - var_dict : dict + var_dict: dict A dictionary with the variable transformation parameters (mean,std) for each hyperparameter. - bounds : Boundary condition class + bounds: Boundary condition class A Boundary condition class that make the boundaries of the hyperparameters. The boundaries are used to calculate the variable transformation parameters. - s : float + s: float The scale parameter in a Logistic distribution. It determines how large part of the distribution that is within the boundaries. s=0.5*p/(ln(p)-ln(1-p)) with p being the quantile that the boundaries constitute. - eps : float - The first value of a grid in the variable transformed - hyperparameter space. - The last value of a grid is 1.0-eps. + seed: int (optional) + The random seed. + The seed can be an integer, RandomState, or Generator instance. + If not given, the default random number generator is used. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. Returns: self: The updated object itself. """ - if var_dict is not None: - self.initiate_var_dict(var_dict) + # Set the boundary condition instance if bounds is not None: self.initiate_bounds_dict(bounds) + # Set the seed + if seed is not None or not hasattr(self, "seed"): + self.set_seed(seed) + # Set the data type + if dtype is not None or not hasattr(self, "dtype"): + self.set_dtype(dtype) + if var_dict is not None: + self.initiate_var_dict(var_dict) if s is not None: self.s = s - if eps is not None: - self.eps = eps return self def transform(self, theta, vt_mean, vt_std, **kwargs): "Transform the hyperparameters with the variable transformation." - return 1.0 / (1.0 + np.exp(-(theta - vt_mean) / vt_std)) + return 1.0 / (1.0 + exp(-(theta - vt_mean) / vt_std)) def retransform(self, ti, vt_mean, vt_std, **kwargs): """ Transform the variable transformed hyperparameters back to the hyperparameters. """ - return self.numeric_limits(vt_std * np.log(ti / (1.00 - ti)) + vt_mean) + return self.numeric_limits(vt_std * log(ti / (1.00 - ti)) + vt_mean) - def numeric_limits(self, value, dh=0.1 * np.log(np.finfo(float).max)): + def numeric_limits(self, value, dh=None): """ Replace hyperparameters if they are outside of the numeric limits in log-space. """ - return np.where(-dh < value, np.where(value < dh, value, dh), -dh) + if dh is None: + dh = 0.1 * log(finfo(self.dtype).max) + return where(-dh < value, where(value < dh, value, dh), -dh) def initiate_var_dict(self, var_dict, **kwargs): """ @@ -489,8 +565,8 @@ def initiate_var_dict(self, var_dict, **kwargs): # Copy the variable transformation parameters self.var_dict = { key: { - "mean": np.array(value["mean"]), - "std": np.array(value["std"]), + "mean": array(value["mean"], dtype=self.dtype), + "std": array(value["std"], dtype=self.dtype), } for key, value in var_dict.items() } @@ -507,10 +583,10 @@ def initiate_bounds_dict(self, bounds, **kwargs): "Make and store the hyperparameter bounds." # Copy the boundary condition object self.bounds = bounds.copy() - self.bounds_dict = self.bounds.get_bounds(array=False) + self.bounds_dict = self.bounds.get_bounds(use_array=False) # Make sure log-scale of the hyperparameters are used - if self.bounds.log is False: - raise Exception( + if self.bounds.use_log is False: + raise ValueError( "The Variable Transformation need to " "use boundary conditions in the log-scale!" ) @@ -523,7 +599,8 @@ def get_arguments(self): var_dict=self.var_dict, bounds=self.bounds, s=self.s, - eps=self.eps, + seed=self.seed, + dtype=self.dtype, ) # Get the constants made within the class constant_kwargs = dict() diff --git a/catlearn/regression/gp/hpboundary/length.py b/catlearn/regression/gp/hpboundary/length.py index 6fccbf97..aebb2aef 100644 --- a/catlearn/regression/gp/hpboundary/length.py +++ b/catlearn/regression/gp/hpboundary/length.py @@ -1,4 +1,15 @@ -import numpy as np +from numpy import ( + array, + asarray, + fill_diagonal, + full, + inf, + log, + median, + ndarray, + sqrt, + zeros, +) from scipy.spatial.distance import pdist, squareform from .boundary import HPBoundaries @@ -8,9 +19,11 @@ def __init__( self, bounds_dict={}, scale=1.0, - log=True, + use_log=True, max_length=True, use_derivatives=False, + seed=None, + dtype=float, **kwargs, ): """ @@ -20,39 +33,64 @@ def __init__( the rest of the hyperparameters not given in the dictionary. Parameters: - bounds_dict : dict + bounds_dict: dict A dictionary with boundary conditions as numpy (H,2) arrays with two columns for each type of hyperparameter. - scale : float + scale: float Scale the boundary conditions. - log : bool + use_log: bool Whether to use hyperparameters in log-scale or not. - max_length : bool + max_length: bool Whether to use the maximum scaling for the length-scale or use a more reasonable scaling. - use_derivatives : bool + use_derivatives: bool Whether the derivatives of the target are used in the model. The boundary conditions of the length-scale hyperparameter(s) will change with the use_derivatives. The use_derivatives will be updated when update_bounds is called. + seed: int (optional) + The random seed. + The seed can be an integer, RandomState, or Generator instance. + If not given, the default random number generator is used. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. """ self.update_arguments( bounds_dict=bounds_dict, scale=scale, - log=log, + use_log=use_log, max_length=max_length, use_derivatives=use_derivatives, + seed=seed, + dtype=dtype, **kwargs, ) + def set_use_derivatives(self, use_derivatives, **kwargs): + """ + Set whether to use derivatives for the targets. + + Parameters: + use_derivatives: bool + Use derivatives/gradients for targets. + + Returns: + self: The updated object itself. + """ + self.use_derivatives = use_derivatives + return self + def update_arguments( self, bounds_dict=None, scale=None, - log=None, + use_log=None, max_length=None, use_derivatives=None, + seed=None, + dtype=None, **kwargs, ): """ @@ -60,36 +98,46 @@ def update_arguments( The existing arguments are used if they are not given. Parameters: - bounds_dict : dict + bounds_dict: dict A dictionary with boundary conditions as numpy (H,2) arrays with two columns for each type of hyperparameter. - scale : float + scale: float Scale the boundary conditions. - log : bool + use_log: bool Whether to use hyperparameters in log-scale or not. - max_length : bool + max_length: bool Whether to use the maximum scaling for the length-scale or use a more reasonable scaling. - use_derivatives : bool + use_derivatives: bool Whether the derivatives of the target are used in the model. The boundary conditions of the length-scale hyperparameter(s) will change with the use_derivatives. The use_derivatives will be updated when update_bounds is called. + seed: int (optional) + The random seed. + The seed can be an integer, RandomState, or Generator instance. + If not given, the default random number generator is used. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. Returns: self: The updated object itself. """ - if bounds_dict is not None: - self.initiate_bounds_dict(bounds_dict) - if scale is not None: - self.scale = scale - if log is not None: - self.log = log + # Update the parameters of the parent class + super().update_arguments( + bounds_dict=bounds_dict, + scale=scale, + use_log=use_log, + seed=seed, + dtype=dtype, + ) + # Update the parameters of the class itself if max_length is not None: self.max_length = max_length if use_derivatives is not None: - self.use_derivatives = use_derivatives + self.set_use_derivatives(use_derivatives) return self def make_bounds(self, model, X, Y, parameters, parameters_set, **kwargs): @@ -100,10 +148,12 @@ def make_bounds(self, model, X, Y, parameters, parameters_set, **kwargs): if para == "length": bounds[para] = self.length_bound(X, parameters.count(para)) elif para in self.bounds_dict: - bounds[para] = self.bounds_dict[para].copy() + bounds[para] = array(self.bounds_dict[para], dtype=self.dtype) else: - bounds[para] = np.full( - (parameters.count(para), 2), [eps_lower, eps_upper] + bounds[para] = full( + (parameters.count(para), 2), + [eps_lower, eps_upper], + dtype=self.dtype, ) return bounds @@ -113,51 +163,53 @@ def length_bound(self, X, l_dim, **kwargs): in the educated guess regime within a scale. """ # Get the minimum and maximum machine precision for exponential terms - exp_lower = np.sqrt(-1 / np.log(np.finfo(float).eps)) / self.scale - exp_max = np.sqrt(-1 / np.log(1 - np.finfo(float).eps)) * self.scale + exp_lower = sqrt(-1.0 / log(self.eps)) / self.scale + exp_max = sqrt(-1.0 / log(1 - self.eps)) * self.scale # Use a smaller maximum boundary if only one length-scale is used if not self.max_length or l_dim == 1: exp_max = 2.0 * self.scale # Scale the convergence if derivatives of targets are used if self.use_derivatives: exp_lower = exp_lower * 0.05 - lengths = np.zeros((l_dim, 2)) + lengths = zeros((l_dim, 2), dtype=self.dtype) # If only one features is given then end if len(X) == 1: lengths[:, 0] = exp_lower lengths[:, 1] = exp_max - if self.log: - return np.log(lengths) + if self.use_log: + return log(lengths) return lengths # Ensure that the features are a matrix - if not isinstance(X[0], (list, np.ndarray)): - X = np.array([fp.get_vector() for fp in X]) + if not isinstance(X[0], (list, ndarray)): + X = asarray([fp.get_vector() for fp in X], dtype=self.dtype) for d in range(l_dim): # Calculate distances if l_dim == 1: dis = pdist(X) else: - dis = pdist(X[:, d : d + 1]) + d1 = d + 1 + dis = pdist(X[:, d:d1]) + dis = asarray(dis, dtype=self.dtype) # Calculate the maximum length-scale - dis_max = exp_max * np.max(dis) + dis_max = exp_max * dis.max() if dis_max == 0.0: dis_min, dis_max = exp_lower, exp_max else: # The minimum length-scale from the nearest neighbor distance - dis_min = exp_lower * np.median(self.nearest_neighbors(dis)) + dis_min = exp_lower * median(self.nearest_neighbors(dis)) if dis_min == 0.0: dis_min = exp_lower # Transform into log-scale if specified lengths[d, 0], lengths[d, 1] = dis_min, dis_max - if self.log: - return np.log(lengths) + if self.use_log: + return log(lengths) return lengths def nearest_neighbors(self, dis, **kwargs): "Nearest neighbor distance." dis_matrix = squareform(dis) - np.fill_diagonal(dis_matrix, np.inf) - return np.min(dis_matrix, axis=1) + fill_diagonal(dis_matrix, inf) + return dis_matrix.min(axis=1) def get_use_derivatives(self, model, **kwargs): "Get whether the derivatives of targets are used in the model." @@ -170,9 +222,11 @@ def get_arguments(self): arg_kwargs = dict( bounds_dict=self.bounds_dict, scale=self.scale, - log=self.log, + use_log=self.use_log, max_length=self.max_length, use_derivatives=self.use_derivatives, + seed=self.seed, + dtype=self.dtype, ) # Get the constants made within the class constant_kwargs = dict() diff --git a/catlearn/regression/gp/hpboundary/restricted.py b/catlearn/regression/gp/hpboundary/restricted.py index 3ceac3ae..d003dd89 100644 --- a/catlearn/regression/gp/hpboundary/restricted.py +++ b/catlearn/regression/gp/hpboundary/restricted.py @@ -1,4 +1,4 @@ -import numpy as np +from numpy import array, asarray, finfo, full, log, sqrt from .length import LengthBoundaries @@ -7,9 +7,11 @@ def __init__( self, bounds_dict={}, scale=1.0, - log=True, + use_log=True, max_length=True, use_derivatives=False, + seed=None, + dtype=float, **kwargs, ): """ @@ -19,29 +21,38 @@ def __init__( the rest of the hyperparameters not given in the dictionary. Parameters: - bounds_dict : dict + bounds_dict: dict A dictionary with boundary conditions as numpy (H,2) arrays with two columns for each type of hyperparameter. - scale : float + scale: float Scale the boundary conditions. - log : bool + use_log: bool Whether to use hyperparameters in log-scale or not. - max_length : bool + max_length: bool Whether to use the maximum scaling for the length-scale or use a more reasonable scaling. - use_derivatives : bool + use_derivatives: bool Whether the derivatives of the target are used in the model. The boundary conditions of the length-scale hyperparameter(s) will change with the use_derivatives. The use_derivatives will be updated when update_bounds is called. + seed: int (optional) + The random seed. + The seed can be an integer, RandomState, or Generator instance. + If not given, the default random number generator is used. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. """ super().__init__( bounds_dict=bounds_dict, scale=scale, - log=log, + use_log=use_log, max_length=max_length, use_derivatives=use_derivatives, + seed=seed, + dtype=dtype, **kwargs, ) @@ -55,31 +66,36 @@ def make_bounds(self, model, X, Y, parameters, parameters_set, **kwargs): elif para == "noise": if "noise_deriv" in parameters_set: bounds[para] = self.noise_bound( - Y[:, 0:1], eps_lower=eps_lower + Y[:, 0:1], + eps_lower=eps_lower, ) else: bounds[para] = self.noise_bound(Y, eps_lower=eps_lower) elif para == "noise_deriv": bounds[para] = self.noise_bound(Y[:, 1:], eps_lower=eps_lower) elif para in self.bounds_dict: - bounds[para] = self.bounds_dict[para].copy() + bounds[para] = array(self.bounds_dict[para], dtype=self.dtype) else: - bounds[para] = np.full( - (parameters.count(para), 2), [eps_lower, eps_upper] + bounds[para] = full( + (parameters.count(para), 2), + [eps_lower, eps_upper], + dtype=self.dtype, ) return bounds def noise_bound( self, Y, - eps_lower=10 * np.sqrt(2.0 * np.finfo(float).eps), + eps_lower=None, **kwargs, ): """ Get the minimum and maximum ranges of the noise in the educated guess regime within a scale. """ + if eps_lower is None: + eps_lower = 10.0 * sqrt(2.0 * finfo(self.dtype).eps) n_max = len(Y.reshape(-1)) * self.scale - if self.log: - return np.array([[eps_lower, np.log(n_max)]]) - return np.array([[eps_lower, n_max]]) + if self.use_log: + return asarray([[eps_lower, log(n_max)]], dtype=self.dtype) + return asarray([[eps_lower, n_max]], dtype=self.dtype) diff --git a/catlearn/regression/gp/hpboundary/strict.py b/catlearn/regression/gp/hpboundary/strict.py index 0a9c7aac..a1307cfd 100644 --- a/catlearn/regression/gp/hpboundary/strict.py +++ b/catlearn/regression/gp/hpboundary/strict.py @@ -1,4 +1,4 @@ -import numpy as np +from numpy import asarray, log, median, ndarray, zeros from scipy.spatial.distance import pdist from .educated import EducatedBoundaries @@ -8,9 +8,12 @@ def __init__( self, bounds_dict={}, scale=1.0, - log=True, + use_log=True, + max_length=True, use_derivatives=False, use_prior_mean=True, + seed=None, + dtype=float, **kwargs ): """ @@ -22,34 +25,44 @@ def __init__( other hyperparameters not given in the dictionary. Parameters: - bounds_dict : dict + bounds_dict: dict A dictionary with boundary conditions as numpy (H,2) arrays with two columns for each type of hyperparameter. - scale : float + scale: float Scale the boundary conditions. - log : bool + use_log: bool Whether to use hyperparameters in log-scale or not. - max_length : bool + max_length: bool Whether to use the maximum scaling for the length-scale or use a more reasonable scaling. - use_derivatives : bool + use_derivatives: bool Whether the derivatives of the target are used in the model. The boundary conditions of the length-scale hyperparameter(s) will change with the use_derivatives. The use_derivatives will be updated when update_bounds is called. - use_prior_mean : bool + use_prior_mean: bool Whether to use the prior mean to calculate the boundary of the prefactor hyperparameter. If use_prior_mean=False, the minimum and maximum target differences are used as the boundary conditions. + seed: int (optional) + The random seed. + The seed can be an integer, RandomState, or Generator instance. + If not given, the default random number generator is used. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. """ super().__init__( bounds_dict=bounds_dict, scale=scale, - log=log, + use_log=use_log, + max_length=max_length, use_derivatives=use_derivatives, use_prior_mean=use_prior_mean, + seed=seed, + dtype=dtype, **kwargs, ) @@ -64,34 +77,36 @@ def length_bound(self, X, l_dim, **kwargs): # Scale the convergence if derivatives of targets are used if self.use_derivatives: exp_lower = exp_lower * 0.05 - lengths = np.zeros((l_dim, 2)) + lengths = zeros((l_dim, 2), dtype=self.dtype) # If only one features is given then end if len(X) == 1: lengths[:, 0] = exp_lower lengths[:, 1] = exp_max - if self.log: - return np.log(lengths) + if self.use_log: + return log(lengths) return lengths # Ensure that the features are a matrix - if not isinstance(X[0], (list, np.ndarray)): - X = np.array([fp.get_vector() for fp in X]) + if not isinstance(X[0], (list, ndarray)): + X = asarray([fp.get_vector() for fp in X], dtype=self.dtype) for d in range(l_dim): # Calculate distances if l_dim == 1: dis = pdist(X) else: - dis = pdist(X[:, d : d + 1]) + d1 = d + 1 + dis = pdist(X[:, d:d1]) + dis = asarray(dis, dtype=self.dtype) # Calculate the maximum length-scale - dis_max = exp_max * np.median(dis) + dis_max = exp_max * median(dis) if dis_max == 0.0: dis_min, dis_max = exp_lower, exp_max else: # The minimum length-scale from the nearest neighbor distance - dis_min = exp_lower * np.median(self.nearest_neighbors(dis)) + dis_min = exp_lower * median(self.nearest_neighbors(dis)) if dis_min == 0.0: dis_min = exp_lower # Transform into log-scale if specified lengths[d, 0], lengths[d, 1] = dis_min, dis_max - if self.log: - return np.log(lengths) + if self.use_log: + return log(lengths) return lengths diff --git a/catlearn/regression/gp/hpboundary/updatebounds.py b/catlearn/regression/gp/hpboundary/updatebounds.py index 1fbab81f..4637f179 100644 --- a/catlearn/regression/gp/hpboundary/updatebounds.py +++ b/catlearn/regression/gp/hpboundary/updatebounds.py @@ -1,4 +1,4 @@ -import numpy as np +from numpy import array, asarray, sum as sum_, sqrt from .boundary import HPBoundaries @@ -10,6 +10,8 @@ def __init__( sol_var=0.5, bound_weight=4, min_solutions=4, + seed=None, + dtype=float, **kwargs, ): """ @@ -21,25 +23,37 @@ def __init__( the updated boundary conditions. Parameters: - bounds : Boundary condition class + bounds: Boundary condition class A Boundary condition class that make the boundaries of the hyperparameters. - sols : list of dict + sols: list of dict The solutions of the hyperparameters from previous optimizations. - sol_var : float + sol_var: float The known variance of the Normal distribution used for the solutions. - bound_weight : int + bound_weight: int The weight of the given boundary conditions in terms of number of solution samples. - min_solutions : int + min_solutions: int The minimum number of solutions before the boundary conditions are updated. + seed: int (optional) + The random seed. + The seed can be an integer, RandomState, or Generator instance. + If not given, the default random number generator is used. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. """ # Set the default boundary conditions if bounds is None: - bounds = HPBoundaries(bounds_dict={}, log=True) + bounds = HPBoundaries( + bounds_dict={}, + use_log=True, + seed=seed, + dtype=dtype, + ) # Set all the arguments self.update_arguments( bounds=bounds, @@ -47,6 +61,8 @@ def __init__( sol_var=sol_var, bound_weight=bound_weight, min_solutions=min_solutions, + seed=seed, + dtype=dtype, **kwargs, ) @@ -56,25 +72,25 @@ def update_bounds(self, model, X, Y, parameters, **kwargs): Therefore the variable transformation parameters are also updated. Parameters: - model : Model + model: Model The Machine Learning Model with kernel and prior that are optimized. - X : (N,D) array + X: (N,D) array Training features with N data points and D dimensions. - Y : (N,1) array or (N,D+1) array + Y: (N,1) array or (N,D+1) array Training targets with or without derivatives with N data points. - parameters : (H) list of strings + parameters: (H) list of strings A list of names of the hyperparameters. Returns: - self : The object itself. + self: The object itself. """ # Update the parameters used self.make_parameters_set(parameters) # Update the boundary conditions and get them self.bounds.update_bounds(model, X, Y, parameters) - bounds_dict = self.bounds.get_bounds(array=False) + bounds_dict = self.bounds.get_bounds(use_array=False) # Get length of the solution sol_len = len(self.sols) # If not enough solutions are given, then use given bounds @@ -87,16 +103,19 @@ def update_bounds(self, model, X, Y, parameters, **kwargs): self.bounds_dict = {} for para in bounds_dict.keys(): # Get the solutions - sol_means = np.array([sol["hp"][para] for sol in self.sols]) + sol_means = array( + [sol["hp"][para] for sol in self.sols], + dtype=self.dtype, + ) # The mean and variance from the boundary conditions - bound_mean = np.sum(bounds_dict[para], axis=-1) + bound_mean = sum_(bounds_dict[para], axis=-1) bound_var = (0.5 * (bounds_dict[para][:, 1] - bound_mean)) ** 2 # Calculate the middle of the boundary conditions mean = ( - np.sum(sol_means, axis=0) + (self.bound_weight * bound_mean) + sum_(sol_means, axis=0) + (self.bound_weight * bound_mean) ) / n_eff # Calculate the variance of the solutions - var_sols = np.sum((sol_means - mean) ** 2, axis=0) + ( + var_sols = sum_((sol_means - mean) ** 2, axis=0) + ( self.sol_var * sol_len ) # Calculate the variance of the boundary conditions @@ -104,13 +123,24 @@ def update_bounds(self, model, X, Y, parameters, **kwargs): self.bound_weight * bound_var ) # Calculate the distance to the boundaries from the middle - bound_dist = 2.0 * np.sqrt((var_sols + var_bound) / n_eff) + bound_dist = 2.0 * sqrt((var_sols + var_bound) / n_eff) # Store the boundary conditions - self.bounds_dict[para] = np.array( - [mean - bound_dist, mean + bound_dist] + self.bounds_dict[para] = asarray( + [mean - bound_dist, mean + bound_dist], + dtype=self.dtype, ).T return self + def set_dtype(self, dtype, **kwargs): + super().set_dtype(dtype, **kwargs) + self.bounds.set_dtype(dtype, **kwargs) + return self + + def set_seed(self, seed=None, **kwargs): + super().set_seed(seed, **kwargs) + self.bounds.set_seed(seed, **kwargs) + return self + def update_arguments( self, bounds=None, @@ -118,6 +148,8 @@ def update_arguments( sol_var=None, bound_weight=None, min_solutions=None, + seed=None, + dtype=None, **kwargs, ): """ @@ -125,25 +157,38 @@ def update_arguments( The existing arguments are used if they are not given. Parameters: - bounds : Boundary condition class + bounds: Boundary condition class A Boundary condition class that make the boundaries of the hyperparameters. - sols : list of dict + sols: list of dict The solutions of the hyperparameters from previous optimizations. - sol_var : float + sol_var: float The known variance of the Normal distribution used for the solutions. - bound_weight : int + bound_weight: int The weight of the given boundary conditions in terms of number of solution samples. - min_solutions : int + min_solutions: int The minimum number of solutions before the boundary conditions are updated. + seed: int (optional) + The random seed. + The seed can be an integer, RandomState, or Generator instance. + If not given, the default random number generator is used. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. Returns: self: The updated object itself. """ + # Set the seed + if seed is not None or not hasattr(self, "seed"): + self.set_seed(seed) + # Set the data type + if dtype is not None or not hasattr(self, "dtype"): + self.set_dtype(dtype) if bounds is not None: self.initiate_bounds_dict(bounds) if sols is not None: @@ -160,7 +205,7 @@ def initiate_bounds_dict(self, bounds, **kwargs): "Make and store the hyperparameter bounds." # Copy the boundary condition object self.bounds = bounds.copy() - self.bounds_dict = self.bounds.get_bounds(array=False) + self.bounds_dict = self.bounds.get_bounds(use_array=False) # Extract the hyperparameter names self.parameters_set = sorted(self.bounds_dict.keys()) self.parameters = sum( @@ -171,8 +216,8 @@ def initiate_bounds_dict(self, bounds, **kwargs): [], ) # Make sure log-scale of the hyperparameters are used - if self.bounds.log is False: - raise Exception( + if self.bounds.use_log is False: + raise ValueError( "The Updating Boundaries need to " "use boundary conditions in the log-scale!" ) @@ -187,6 +232,8 @@ def get_arguments(self): sol_var=self.sol_var, bound_weight=self.bound_weight, min_solutions=self.min_solutions, + seed=self.seed, + dtype=self.dtype, ) # Get the constants made within the class constant_kwargs = dict() diff --git a/catlearn/regression/gp/hpfitter/fbpmgp.py b/catlearn/regression/gp/hpfitter/fbpmgp.py index 530e3eb6..182d1551 100644 --- a/catlearn/regression/gp/hpfitter/fbpmgp.py +++ b/catlearn/regression/gp/hpfitter/fbpmgp.py @@ -2,6 +2,7 @@ asarray, append, argsort, + array, diag, einsum, empty, @@ -19,12 +20,12 @@ zeros, ) from numpy.linalg import eigh, LinAlgError -import numpy.random as random +from numpy.random import default_rng, Generator, RandomState from scipy.linalg import eigh as scipy_eigh from scipy.spatial.distance import pdist from scipy.optimize import OptimizeResult import logging -from .hpfitter import HyperparameterFitter +from .hpfitter import HyperparameterFitter, VariableTransformation class FBPMGP(HyperparameterFitter): @@ -33,10 +34,11 @@ def __init__( Q=None, n_test=50, ngrid=80, - bounds=None, + bounds=VariableTransformation(), get_prior_mean=False, round_hp=None, - dtype=None, + seed=None, + dtype=float, **kwargs, ): """ @@ -61,16 +63,16 @@ def __init__( round_hp: int (optional) The number of decimals to round the hyperparameters to. If None, the hyperparameters are not rounded. - dtype: type + seed: int (optional) + The random seed. + The seed can be an integer, RandomState, or Generator instance. + If not given, the default random number generator is used. + dtype: type (optional) The data type of the arrays. + If None, the default data type is used. """ # Set the default test points self.Q = None - # Set the default boundary conditions - if bounds is None: - from ..hpboundary.hptrans import VariableTransformation - - self.bounds = VariableTransformation(bounds=None) # Set the solution form self.update_arguments( Q=Q, @@ -79,13 +81,14 @@ def __init__( bounds=bounds, get_prior_mean=get_prior_mean, round_hp=round_hp, + seed=seed, dtype=dtype, **kwargs, ) - def fit(self, X, Y, model, hp=None, pdis=None, **kwargs): + def fit(self, X, Y, model, hp=None, pdis=None, retrain=True, **kwargs): # Copy the model so it is not changed outside of the optimization - model = self.copy_model(model) + model = self.copy_model(model, retrain=retrain) # Get hyperparameters hp, theta, parameters = self.get_hyperparams(hp, model) # Find FBMGP solution @@ -102,6 +105,49 @@ def fit(self, X, Y, model, hp=None, pdis=None, **kwargs): sol = self.get_full_hp(sol, model) return sol + def set_dtype(self, dtype, **kwargs): + """ + Set the data type of the arrays. + + Parameters: + dtype: type + The data type of the arrays. + + Returns: + self: The updated object itself. + """ + # Set the data type + self.dtype = dtype + # Set the data type in the bounds + self.bounds.set_dtype(dtype, **kwargs) + return self + + def set_seed(self, seed=None, **kwargs): + """ + Set the random seed. + + Parameters: + seed: int (optional) + The random seed. + The seed can be an integer, RandomState, or Generator instance. + If not given, the default random number generator is used. + + Returns: + self: The instance itself. + """ + if seed is not None: + self.seed = seed + if isinstance(seed, int): + self.rng = default_rng(self.seed) + elif isinstance(seed, Generator) or isinstance(seed, RandomState): + self.rng = seed + else: + self.seed = None + self.rng = default_rng() + # Set the seed in the bounds + self.bounds.set_seed(seed, **kwargs) + return self + def update_arguments( self, Q=None, @@ -110,6 +156,7 @@ def update_arguments( bounds=None, get_prior_mean=None, round_hp=None, + seed=None, dtype=None, **kwargs, ): @@ -134,8 +181,13 @@ def update_arguments( round_hp: int (optional) The number of decimals to round the hyperparameters to. If None, the hyperparameters are not rounded. - dtype: type + seed: int (optional) + The random seed. + The seed can be an integer, RandomState, or Generator instance. + If not given, the default random number generator is used. + dtype: type (optional) The data type of the arrays. + If None, the default data type is used. Returns: self: The updated object itself. @@ -152,8 +204,12 @@ def update_arguments( self.get_prior_mean = get_prior_mean if round_hp is not None or not hasattr(self, "round_hp"): self.round_hp = round_hp + # Set the seed + if seed is not None or not hasattr(self, "seed"): + self.set_seed(seed) + # Set the data type if dtype is not None or not hasattr(self, "dtype"): - self.dtype = dtype + self.set_dtype(dtype) return self def get_hp(self, theta, parameters, **kwargs): @@ -167,11 +223,13 @@ def get_hp(self, theta, parameters, **kwargs): } return hp, parameters_set - def numeric_limits(self, theta, dh=0.4 * log(finfo(float).max)): + def numeric_limits(self, theta, dh=None): """ Replace hyperparameters if they are outside of the numeric limits in log-space. """ + if dh is None: + dh = 0.4 * log(finfo(self.dtype).max) return where(-dh < theta, where(theta < dh, theta, dh), -dh) def update_model(self, model, hp, **kwargs): @@ -195,18 +253,19 @@ def add_correction(self, model, KXX, n_data, **kwargs): return KXX def y_prior(self, X, Y, model, L=None, low=None, **kwargs): - "Update prior and subtract target." - Y_p = Y.copy() + "Update prior and subtract to target." + Y_p = array(Y, dtype=self.dtype) model.update_priormean(X, Y_p, L=L, low=low, **kwargs) - use_derivatives = model.use_derivatives + get_derivatives = model.get_use_derivatives() pmean = model.get_priormean( X, Y_p, - get_derivatives=use_derivatives, + get_derivatives=get_derivatives, ) - if use_derivatives: - return (Y_p - pmean).T.reshape(-1, 1) - return (Y_p - pmean)[:, 0:1] + Y_p -= pmean + if get_derivatives: + return Y_p.T.reshape(-1, 1) + return Y_p[:, 0:1] def get_eig(self, model, X, Y, **kwargs): "Calculate the eigenvalues." @@ -332,7 +391,7 @@ def get_test_points(self, Q, X_tr, **kwargs): i_sort = argsort(pdist(X_tr))[: self.n_test] i_list, j_list = triu_indices(len(X_tr), k=1, m=None) i_list, j_list = i_list[i_sort], j_list[i_sort] - r = random.uniform(low=0.01, high=0.99, size=(2, len(i_list))) + r = self.rng.uniform(low=0.01, high=0.99, size=(2, len(i_list))) r = r / r.sum(axis=0) Q = asarray( [ @@ -561,7 +620,7 @@ def get_solution( # Get the analytic solution to the prefactor prefactor = ( (y2bar_ubar + (df["pred"] ** 2) - (2.0 * df["pred"] * ybar)) - / df["var"], + / df["var"] ).mean(axis=1) # Calculate all Kullback-Leibler divergences kl = 0.5 * ( diff --git a/catlearn/regression/gp/hpfitter/hpfitter.py b/catlearn/regression/gp/hpfitter/hpfitter.py index 1323eedd..0d68a00d 100644 --- a/catlearn/regression/gp/hpfitter/hpfitter.py +++ b/catlearn/regression/gp/hpfitter/hpfitter.py @@ -1,16 +1,20 @@ from numpy import asarray, round as round_ +from ..optimizers.optimizer import FunctionEvaluation +from ..hpboundary.hptrans import VariableTransformation +from ..pdistributions.update_pdis import update_pdis class HyperparameterFitter: def __init__( self, func, - optimizer=None, - bounds=None, + optimizer=FunctionEvaluation(jac=False), + bounds=VariableTransformation(), use_update_pdis=False, get_prior_mean=False, use_stored_sols=False, round_hp=None, + dtype=float, **kwargs, ): """ @@ -39,17 +43,10 @@ def __init__( round_hp: int (optional) The number of decimals to round the hyperparameters to. If None, the hyperparameters are not rounded. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. """ - # Set the default optimizer - if optimizer is None: - from ..optimizers.optimizer import FunctionEvaluation - - optimizer = FunctionEvaluation(jac=False) - # Set the default boundary conditions - if bounds is None: - from ..hpboundary.hptrans import VariableTransformation - - self.bounds = VariableTransformation(bounds=None) # Set all the arguments self.update_arguments( func=func, @@ -59,10 +56,11 @@ def __init__( get_prior_mean=get_prior_mean, use_stored_sols=use_stored_sols, round_hp=round_hp, + dtype=dtype, **kwargs, ) - def fit(self, X, Y, model, hp=None, pdis=None, **kwargs): + def fit(self, X, Y, model, hp=None, pdis=None, retrain=True, **kwargs): """ Optimize the hyperparameters. @@ -80,6 +78,9 @@ def fit(self, X, Y, model, hp=None, pdis=None, **kwargs): else the current set is used. pdis: dict A dict of prior distributions for each hyperparameter type. + retrain: bool + Whether to retrain the model after the optimization. + The model is not copied if retrain is True. Returns: dict: A solution dictionary with objective function value, @@ -87,7 +88,7 @@ def fit(self, X, Y, model, hp=None, pdis=None, **kwargs): and number of used evaluations. """ # Copy the model so it is not changed outside of the optimization - model = self.copy_model(model) + model = self.copy_model(model, retrain=retrain) # Always reset the solution in the objective function self.reset_func() # Get hyperparameters @@ -114,6 +115,41 @@ def fit(self, X, Y, model, hp=None, pdis=None, **kwargs): self.store_sol(sol) return sol + def set_dtype(self, dtype, **kwargs): + """ + Set the data type of the arrays. + + Parameters: + dtype: type + The data type of the arrays. + + Returns: + self: The updated object itself. + """ + self.dtype = dtype + self.func.set_dtype(dtype, **kwargs) + self.bounds.set_dtype(dtype, **kwargs) + self.optimizer.set_dtype(dtype, **kwargs) + return self + + def set_seed(self, seed, **kwargs): + """ + Set the random seed. + + Parameters: + seed: int (optional) + The random seed. + The seed can be an integer, RandomState, or Generator instance. + If not given, the default random number generator is used. + + Returns: + self: The instance itself. + """ + self.func.set_seed(seed, **kwargs) + self.bounds.set_seed(seed, **kwargs) + self.optimizer.set_seed(seed, **kwargs) + return self + def update_arguments( self, func=None, @@ -123,6 +159,7 @@ def update_arguments( get_prior_mean=None, use_stored_sols=None, round_hp=None, + dtype=None, **kwargs, ): """ @@ -151,6 +188,9 @@ def update_arguments( round_hp: int (optional) The number of decimals to round the hyperparameters to. If None, the hyperparameters are not rounded. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. Returns: self: The updated object itself. @@ -169,14 +209,20 @@ def update_arguments( self.use_stored_sols = use_stored_sols if round_hp is not None or not hasattr(self, "round_hp"): self.round_hp = round_hp + if dtype is not None or not hasattr(self, "dtype"): + self.set_dtype(dtype) # Empty the stored solutions self.sols = [] # Make sure that the objective function gets the prior mean parameters self.func.update_arguments(get_prior_mean=self.get_prior_mean) return self - def copy_model(self, model, **kwargs): + def copy_model(self, model, retrain=True, **kwargs): "Make a copy of the model, so it is not overwritten." + # Do not copy the model if retrain is True + if retrain: + return model + # Copy the model if retrain is False return model.copy() def reset_func(self, **kwargs): @@ -236,17 +282,21 @@ def update_pdis(self, pdis, model, X, Y, parameters, **kwargs): Update the prior distributions of the hyperparameters with the boundary conditions. """ - if self.use_update_pdis and pdis is not None: - from ..pdistributions.update_pdis import update_pdis - - pdis = update_pdis( - model, - parameters, - X, - Y, - bounds=self.bounds, - pdis=pdis, - ) + if pdis is not None: + pdis = { + para: pdis_p.set_dtype(self.dtype) + for para, pdis_p in pdis.items() + } + if self.use_update_pdis: + pdis = update_pdis( + model, + parameters, + X, + Y, + bounds=self.bounds, + pdis=pdis, + dtype=self.dtype, + ) return pdis def get_full_hp(self, sol, model, **kwargs): @@ -284,6 +334,7 @@ def get_arguments(self): get_prior_mean=self.get_prior_mean, use_stored_sols=self.use_stored_sols, round_hp=self.round_hp, + dtype=self.dtype, ) # Get the constants made within the class constant_kwargs = dict() diff --git a/catlearn/regression/gp/hpfitter/redhpfitter.py b/catlearn/regression/gp/hpfitter/redhpfitter.py index 07f7d51c..f8bc6028 100644 --- a/catlearn/regression/gp/hpfitter/redhpfitter.py +++ b/catlearn/regression/gp/hpfitter/redhpfitter.py @@ -1,19 +1,24 @@ from numpy import inf from scipy.optimize import OptimizeResult -from .hpfitter import HyperparameterFitter +from .hpfitter import ( + FunctionEvaluation, + HyperparameterFitter, + VariableTransformation, +) class ReducedHyperparameterFitter(HyperparameterFitter): def __init__( self, func, - optimizer=None, - bounds=None, + optimizer=FunctionEvaluation(jac=False), + bounds=VariableTransformation(), use_update_pdis=False, get_prior_mean=False, use_stored_sols=False, round_hp=None, opt_tr_size=50, + dtype=float, **kwargs, ): """ @@ -47,6 +52,9 @@ def __init__( opt_tr_size: int The maximum size of the training set before the hyperparameters are not optimized. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. """ super().__init__( func, @@ -57,14 +65,23 @@ def __init__( use_stored_sols=use_stored_sols, round_hp=round_hp, opt_tr_size=opt_tr_size, + dtype=dtype, **kwargs, ) - def fit(self, X, Y, model, hp=None, pdis=None, **kwargs): + def fit(self, X, Y, model, hp=None, pdis=None, retrain=True, **kwargs): # Check if optimization is needed if len(X) <= self.opt_tr_size: # Optimize the hyperparameters - return super().fit(X, Y, model, hp=hp, pdis=pdis, **kwargs) + return super().fit( + X, + Y, + model, + hp=hp, + pdis=pdis, + retrain=retrain, + **kwargs, + ) # Use existing hyperparameters hp, theta, parameters = self.get_hyperparams(hp, model) # Do not optimize hyperparameters @@ -92,6 +109,7 @@ def update_arguments( use_stored_sols=None, round_hp=None, opt_tr_size=None, + dtype=None, **kwargs, ): """ @@ -123,30 +141,25 @@ def update_arguments( opt_tr_size: int The maximum size of the training set before the hyperparameters are not optimized. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. Returns: self: The updated object itself. """ - if func is not None: - self.func = func.copy() - if optimizer is not None: - self.optimizer = optimizer.copy() - if bounds is not None: - self.bounds = bounds.copy() - if use_update_pdis is not None: - self.use_update_pdis = use_update_pdis - if get_prior_mean is not None: - self.get_prior_mean = get_prior_mean - if use_stored_sols is not None: - self.use_stored_sols = use_stored_sols - if round_hp is not None or not hasattr(self, "round_hp"): - self.round_hp = round_hp + super().update_arguments( + func=func, + optimizer=optimizer, + bounds=bounds, + use_update_pdis=use_update_pdis, + get_prior_mean=get_prior_mean, + use_stored_sols=use_stored_sols, + round_hp=round_hp, + dtype=dtype, + ) if opt_tr_size is not None: self.opt_tr_size = opt_tr_size - # Empty the stored solutions - self.sols = [] - # Make sure that the objective function gets the prior mean parameters - self.func.update_arguments(get_prior_mean=self.get_prior_mean) return self def get_arguments(self): @@ -161,6 +174,7 @@ def get_arguments(self): use_stored_sols=self.use_stored_sols, round_hp=self.round_hp, opt_tr_size=self.opt_tr_size, + dtype=self.dtype, ) # Get the constants made within the class constant_kwargs = dict() diff --git a/catlearn/regression/gp/kernel/kernel.py b/catlearn/regression/gp/kernel/kernel.py index 01fd9363..454cc940 100644 --- a/catlearn/regression/gp/kernel/kernel.py +++ b/catlearn/regression/gp/kernel/kernel.py @@ -1,4 +1,4 @@ -import numpy as np +from numpy import asarray, array, finfo from scipy.spatial.distance import pdist, cdist @@ -8,6 +8,7 @@ def __init__( use_derivatives=False, use_fingerprint=False, hp={}, + dtype=float, **kwargs, ): """ @@ -22,14 +23,18 @@ def __init__( A dictionary of the hyperparameters in the log-space. The hyperparameters should be given as flatten arrays, like hp=dict(length=np.array([-0.7])). + dtype: type + The data type of the arrays. + """ # Set the default hyperparameters - self.hp = dict(length=np.array([-0.7])) + self.hp = dict(length=asarray([-0.7], dtype=dtype)) # Set all the arguments self.update_arguments( use_derivatives=use_derivatives, use_fingerprint=use_fingerprint, hp=hp, + dtype=dtype, **kwargs, ) @@ -44,23 +49,23 @@ def __call__( Make the kernel matrix. Parameters: - features : (N,D) array or (N) list of fingerprint objects + features: (N,D) array or (N) list of fingerprint objects Features with N data points. - features2 : (M,D) array or (M) list of fingerprint objects + features2: (M,D) array or (M) list of fingerprint objects Features with M data points and D dimensions. If it is not given a squared kernel from features is generated. get_derivatives: bool Whether to predict derivatives of target. Returns: - KXX : array + KXX: array The symmetric kernel matrix if features2=None. The number of rows in the array is N, or N*(D+1) if get_derivatives=True. The number of columns in the array is N, or N*(D+1) if use_derivatives=True. or - KQX : array + KQX: array The kernel matrix if features2 is not None. The number of rows in the array is N, or N*(D+1) if get_derivatives=True. @@ -81,7 +86,7 @@ def diag(self, features, get_derivatives=True, **kwargs): Get the diagonal kernel vector. Parameters: - features : (N,D) array or (N) list of fingerprint objects + features: (N,D) array or (N) list of fingerprint objects Features with N data points. get_derivatives: bool Whether to predict derivatives of target. @@ -97,7 +102,7 @@ def diag_deriv(self, features, **kwargs): Get the derivative of the diagonal kernel vector wrt. the features. Parameters: - features : (N,D) array or (N) list of fingerprint objects + features: (N,D) array or (N) list of fingerprint objects Features with N data points. Returns: @@ -110,14 +115,14 @@ def get_gradients(self, features, hp, KXX, correction=True, **kwargs): Get the gradients of the kernel matrix wrt. to the hyperparameters. Parameters: - features : (N,D) array + features: (N,D) array Features with N data points and D dimensions. - hp : list + hp: list A list of the string names of the hyperparameters that are optimized. - KXX : (N,N) array + KXX: (N,N) array The kernel matrix of training data. - correction : bool + correction: bool Whether the noise correction is used. Returns: @@ -139,9 +144,9 @@ def set_hyperparams(self, new_params, **kwargs): self: The updated object itself. """ if "length" in new_params: - self.hp["length"] = np.array( + self.hp["length"] = array( new_params["length"], - dtype=float, + dtype=self.dtype, ).reshape(-1) return self @@ -159,7 +164,7 @@ def get_hp_dimension(self, features=None, **kwargs): Get the dimension of the length-scale hyperparameter. Parameters: - features : (N,D) array or (N) list of fingerprint objects or None + features: (N,D) array or (N) list of fingerprint objects or None Features with N data points. Returns: @@ -175,11 +180,59 @@ def get_use_fingerprint(self): "Get whether a fingerprint is used as the features." return self.use_fingerprint + def set_dtype(self, dtype, **kwargs): + """ + Set the data type of the arrays. + + Parameters: + dtype: type + The data type of the arrays. + + Returns: + self: The updated object itself. + """ + self.dtype = dtype + # Set the machine precision + self.eps = 1.1 * finfo(self.dtype).eps + # Set the data type of the hyperparameters + self.set_hyperparams(self.hp) + return self + + def set_use_derivatives(self, use_derivatives, **kwargs): + """ + Set whether to use the derivatives of the targets. + + Parameters: + use_derivatives: bool + Use derivatives/gradients for training and predictions. + + Returns: + self: The updated object itself. + """ + # Set whether to use derivatives for the target + self.use_derivatives = use_derivatives + return self + + def set_use_fingerprint(self, use_fingerprint, **kwargs): + """ + Set whether to use the fingerprint instance. + + Parameters: + use_fingerprint: bool + Use fingerprint instance as features. + + Returns: + self: The updated object itself. + """ + self.use_fingerprint = use_fingerprint + return self + def update_arguments( self, use_derivatives=None, use_fingerprint=None, hp=None, + dtype=None, **kwargs, ): """ @@ -195,14 +248,18 @@ def update_arguments( A dictionary of the hyperparameters in the log-space. The hyperparameters should be given as flatten arrays, like hp=dict(length=np.array([-0.7])). + dtype: type + The data type of the arrays. Returns: self: The updated object itself. """ if use_derivatives is not None: - self.use_derivatives = use_derivatives + self.set_use_derivatives(use_derivatives) if use_fingerprint is not None: - self.use_fingerprint = use_fingerprint + self.set_use_fingerprint(use_fingerprint) + if dtype is not None or not hasattr(self, "dtype"): + self.set_dtype(dtype=dtype) if hp is not None: self.set_hyperparams(hp) return self @@ -212,11 +269,11 @@ def get_KXX(self, features, **kwargs): Make the symmetric kernel matrix. Parameters: - features : (N,D) array or (N) list of fingerprint objects + features: (N,D) array or (N) list of fingerprint objects Features with N data points. Returns: - KXX : array + KXX: array The symmetric kernel matrix if features2=None. The number of rows in the array is N, or N*(D+1) if get_derivatives=True. @@ -230,16 +287,16 @@ def get_KQX(self, features, features2, get_derivatives=True, **kwargs): Make the kernel matrix. Parameters: - features : (N,D) array or (N) list of fingerprint objects + features: (N,D) array or (N) list of fingerprint objects Features with N data points. - features2 : (M,D) array or (M) list of fingerprint objects + features2: (M,D) array or (M) list of fingerprint objects Features with M data points and D dimensions. If it is not given a squared kernel from features is generated. get_derivatives: bool Whether to predict derivatives of target. Returns: - KQX : array + KQX: array The kernel matrix if features2 is not None. The number of rows in the array is N, or N*(D+1) if get_derivatives=True. @@ -250,10 +307,22 @@ def get_KQX(self, features, features2, get_derivatives=True, **kwargs): def get_arrays(self, features, features2=None, **kwargs): "Get the feature matrix from the fingerprint." - X = np.array([feature.get_vector() for feature in features]) + if self.use_fingerprint: + X = asarray( + [feature.get_vector() for feature in features], + dtype=self.dtype, + ) + else: + X = array(features, dtype=self.dtype) if features2 is None: return X - Q = np.array([feature.get_vector() for feature in features2]) + if self.use_fingerprint: + Q = asarray( + [feature.get_vector() for feature in features2], + dtype=self.dtype, + ) + else: + Q = array(features2, dtype=self.dtype) return X, Q def get_symmetric_absolute_distances( @@ -266,7 +335,8 @@ def get_symmetric_absolute_distances( Calculate the symmetric absolute distance matrix in (scaled) feature space. """ - return pdist(features, metric=metric) + D = pdist(features, metric=metric) + return asarray(D, dtype=self.dtype) def get_absolute_distances( self, @@ -276,7 +346,8 @@ def get_absolute_distances( **kwargs, ): "Calculate the absolute distance matrix in (scaled) feature space." - return cdist(features, features2, metric=metric) + D = cdist(features, features2, metric=metric) + return asarray(D, dtype=self.dtype) def get_feature_dimension(self, features, **kwargs): "Get the dimension of the features." @@ -287,10 +358,14 @@ def get_feature_dimension(self, features, **kwargs): def get_fp_deriv(self, features, dim=None, **kwargs): "Get the derivatives of all the fingerprints." if dim is None: - return np.array( - [fp.get_derivatives() for fp in features] + return asarray( + [fp.get_derivatives() for fp in features], + dtype=self.dtype, ).transpose((2, 0, 1)) - return np.array([fp.get_derivatives(dim) for fp in features]) + return asarray( + [fp.get_derivatives(dim) for fp in features], + dtype=self.dtype, + ) def get_derivative_dimension(self, features, **kwargs): "Get the dimension of the features." @@ -305,6 +380,7 @@ def get_arguments(self): use_derivatives=self.use_derivatives, use_fingerprint=self.use_fingerprint, hp=self.hp, + dtype=self.dtype, ) # Get the constants made within the class constant_kwargs = dict() diff --git a/catlearn/regression/gp/kernel/se.py b/catlearn/regression/gp/kernel/se.py index 3e55ed06..d85d10b1 100644 --- a/catlearn/regression/gp/kernel/se.py +++ b/catlearn/regression/gp/kernel/se.py @@ -1,4 +1,16 @@ -import numpy as np +from numpy import ( + append, + asarray, + einsum, + exp, + diag, + diagonal, + fill_diagonal, + ones, + tile, + transpose, + zeros, +) from scipy.spatial.distance import squareform from .kernel import Kernel @@ -9,6 +21,7 @@ def __init__( use_derivatives=False, use_fingerprint=False, hp={}, + dtype=float, **kwargs, ): """ @@ -24,25 +37,27 @@ def __init__( A dictionary of the hyperparameters in the log-space. The hyperparameters should be given as flatten arrays, like hp=dict(length=np.array([-0.7])). + dtype: type + The data type of the arrays. + """ super().__init__( use_derivatives=use_derivatives, use_fingerprint=use_fingerprint, hp=hp, + dtype=dtype, **kwargs, ) def get_KXX(self, features, **kwargs): # Scale features or fingerprints with their length-scales - if self.use_fingerprint: - X = self.get_arrays(features) * np.exp(-self.hp["length"][0]) - else: - X = features * np.exp(-self.hp["length"][0]) + X = self.get_arrays(features) + X *= exp(-self.hp["length"][0]) # Calculate the symmetric scaled distance matrix D = self.get_symmetric_absolute_distances(X, metric="sqeuclidean") # Calculate the normal covariance matrix - K = squareform(np.exp((-0.5) * D)) - np.fill_diagonal(K, 1.0) + K = squareform(exp((-0.5) * D)) + fill_diagonal(K, 1.0) # Whether to the extended covariance matrix for derivative of targets if self.use_derivatives: if self.use_fingerprint: @@ -52,16 +67,12 @@ def get_KXX(self, features, **kwargs): def get_KQX(self, features, features2, get_derivatives=True, **kwargs): # Scale features or fingerprints with their length-scales - length_scale = np.exp(-self.hp["length"][0]) - if self.use_fingerprint: - Q, X = self.get_arrays(features, features2) - Q = Q * length_scale - X = X * length_scale - else: - Q = features * length_scale - X = features2 * length_scale + length_scale = exp(-self.hp["length"][0]) + Q, X = self.get_arrays(features, features2) + Q *= length_scale + X *= length_scale D = self.get_absolute_distances(Q, X, metric="sqeuclidean") - K = np.exp((-0.5) * D) + K = exp((-0.5) * D) if get_derivatives or self.use_derivatives: if self.use_fingerprint: return self.get_KQX_ext_fp( @@ -99,15 +110,15 @@ def get_KXX_ext(self, features, X, D, K, **kwargs): The covariance matrix without derivatives of the features. Returns: - (N*D+N,N*D+N) array : The extended symmetric kernel matrix. + (N*D+N,N*D+N) array: The extended symmetric kernel matrix. """ # Get dimensions - nd1, xdim = np.shape(X) + nd1, xdim = X.shape nd1x = nd1 * xdim nd1x1 = nd1x + nd1 # Get the derivative and hessian of the scaled distance matrix dDpre, dD = self.get_distance_derivative(X, X, nd1, nd1, xdim, axis=0) - ddDpre = -2.0 * np.exp(-2 * self.hp["length"][0]) + ddDpre = -2.0 * exp(-2 * self.hp["length"][0]) # The first derivative of the kernel dKpre, dK = self.get_derivative_K(K) dKdD = (-dDpre * dKpre) * dK @@ -116,27 +127,27 @@ def get_KXX_ext(self, features, X, D, K, **kwargs): ddKdD = ((-dDpre * dDpre * ddKpre) * ddK) * dD dKddD = (ddDpre * dKpre) * dK # Calculate the full symmetric kernel matrix - Kext = np.zeros((nd1x1, nd1x1)) + Kext = zeros((nd1x1, nd1x1), dtype=self.dtype) Kext[:nd1, :nd1] = K.copy() # Derivative part - Kext[:nd1, nd1:] = np.transpose( + Kext[:nd1, nd1:] = transpose( dKdD * dD, (1, 0, 2), ).reshape(nd1, nd1x) Kext[nd1:, :nd1] = Kext[:nd1, nd1:].T # Hessian part + xdimm = xdim - 1 for d1 in range(1, xdim): nd1d1 = nd1 * d1 nd1d11 = nd1d1 + nd1 - Kext[nd1d1:nd1d11, nd1d11:] = np.transpose( - ddKdD[d1:] * dD[d1 - 1], + d1m = d1 - 1 + Kext[nd1d1:nd1d11, nd1d11:] = transpose( + ddKdD[d1:] * dD[d1m], (1, 0, 2), ).reshape(nd1, nd1 * (xdim - d1)) Kext[nd1d11:, nd1d1:nd1d11] = Kext[nd1d1:nd1d11, nd1d11:].T - Kext[nd1d1:nd1d11, nd1d1:nd1d11] = ( - ddKdD[d1 - 1] * dD[d1 - 1] + dKddD - ) - Kext[nd1x:nd1x1, nd1x:nd1x1] = (ddKdD[xdim - 1] * dD[xdim - 1]) + dKddD + Kext[nd1d1:nd1d11, nd1d1:nd1d11] = ddKdD[d1m] * dD[d1m] + dKddD + Kext[nd1x:nd1x1, nd1x:nd1x1] = (ddKdD[xdimm] * dD[xdimm]) + dKddD return Kext def get_KXX_ext_fp(self, features, X, D, K, **kwargs): @@ -154,7 +165,7 @@ def get_KXX_ext_fp(self, features, X, D, K, **kwargs): The covariance matrix without derivatives of the features. Returns: - (N*Dx+N,N*Dx+N) array : The extended symmetric kernel matrix. + (N*Dx+N,N*Dx+N) array: The extended symmetric kernel matrix. """ # Get dimensions nd1 = len(X) @@ -176,29 +187,30 @@ def get_KXX_ext_fp(self, features, X, D, K, **kwargs): dKdD = (-dDpre * dKpre) * dK # The hessian of the kernel ddKpre, ddK = self.get_hessian_K(K) - ddKdD = ((dDpre * dDpre * ddKpre) * ddK) * np.transpose(dD, (0, 2, 1)) + ddKdD = ((dDpre * dDpre * ddKpre) * ddK) * transpose(dD, (0, 2, 1)) dKddD = (ddDpre * dKpre) * dK # Calculate the full symmetric kernel matrix - Kext = np.zeros((nd1x1, nd1x1)) + Kext = zeros((nd1x1, nd1x1), dtype=self.dtype) Kext[:nd1, :nd1] = K.copy() # Derivative part - Kext[:nd1, nd1:] = np.transpose( + Kext[:nd1, nd1:] = transpose( dKdD * dD, (1, 0, 2), ).reshape(nd1, nd1x) Kext[nd1:, :nd1] = Kext[:nd1, nd1:].T # Hessian part + xdimm = xdim - 1 for d1 in range(1, xdim): nd1d1 = nd1 * d1 nd1d11 = nd1d1 + nd1 - Kext[nd1d1:nd1d11, nd1d1:] = np.transpose( - (ddKdD[d1 - 1] * dD[d1 - 1 :]) - + (dKddD * ddD[d1 - 1, d1 - 1 :]), + d1m = d1 - 1 + Kext[nd1d1:nd1d11, nd1d1:] = transpose( + (ddKdD[d1m] * dD[d1m:]) + (dKddD * ddD[d1m, d1m:]), (1, 0, 2), ).reshape(nd1, nd1 * (xdim - d1 + 1)) Kext[nd1d11:, nd1d1:nd1d11] = Kext[nd1d1:nd1d11, nd1d11:].T - Kext[nd1x:nd1x1, nd1x:nd1x1] = (ddKdD[xdim - 1] * dD[xdim - 1]) + ( - dKddD * ddD[xdim - 1, xdim - 1 :] + Kext[nd1x:nd1x1, nd1x:nd1x1] = (ddKdD[xdimm] * dD[xdimm]) + ( + dKddD * ddD[xdimm, xdimm:] ) return Kext @@ -233,15 +245,15 @@ def get_KQX_ext( Whether to predict derivatives of target. Returns: - (M*D+N,N*D+N) array : The extended kernel matrix. + (M*D+N,N*D+N) array: The extended kernel matrix. """ # Get dimensions nd1 = len(Q) - nd2, xdim = np.shape(X) + nd2, xdim = X.shape nrows = nd1 * (xdim + 1) if get_derivatives else nd1 ncol = nd2 * (xdim + 1) if self.use_derivatives else nd2 # The full kernel matrix - Kext = np.zeros((nrows, ncol)) + Kext = zeros((nrows, ncol), dtype=self.dtype) Kext[:nd1, :nd2] = K.copy() # Get the derivative of the scaled distance matrix dDpre, dD = self.get_distance_derivative(Q, X, nd1, nd2, xdim, axis=0) @@ -251,7 +263,7 @@ def get_KQX_ext( btensor = dKdD * dD if self.use_derivatives: # Derivative part of X - Kext[:nd1, nd2:] = np.transpose( + Kext[:nd1, nd2:] = transpose( btensor, (1, 0, 2), ).reshape(nd1, nd2 * xdim) @@ -262,11 +274,11 @@ def get_KQX_ext( if self.use_derivatives: ddKpre, ddK = self.get_hessian_K(K) ddKdD = ((-dDpre * dDpre * ddKpre) * ddK) * dD - ddDpre = -2.0 * np.exp(-2 * self.hp["length"][0]) + ddDpre = -2.0 * exp(-2 * self.hp["length"][0]) dKddD = (ddDpre * dKpre) * dK btensor = ddKdD[:, None, :, :] * dD btensor[range(xdim), range(xdim), :, :] += dKddD - Kext[nd1:, nd2:] = np.transpose( + Kext[nd1:, nd2:] = transpose( btensor, (0, 2, 1, 3), ).reshape(nd1 * xdim, nd2 * xdim) @@ -303,7 +315,7 @@ def get_KQX_ext_fp( Whether to predict derivatives of target. Returns: - (M*Dx+N,N*Dx+N) array : The extended kernel matrix. + (M*Dx+N,N*Dx+N) array: The extended kernel matrix. """ # Get dimensions nd1, nd2 = len(Q), len(X) @@ -311,7 +323,7 @@ def get_KQX_ext_fp( nrows = nd1 * (xdim + 1) if get_derivatives else nd1 ncol = nd2 * (xdim + 1) if self.use_derivatives else nd2 # The full kernel matrix - Kext = np.zeros((nrows, ncol)) + Kext = zeros((nrows, ncol), dtype=self.dtype) Kext[:nd1, :nd2] = K.copy() # The first derivative of the kernel dKpre, dK = self.get_derivative_K(K) @@ -326,7 +338,7 @@ def get_KQX_ext_fp( **kwargs, ) dKdD = (dDpre2 * dKpre) * dK - Kext[:nd1, nd2:] = np.transpose( + Kext[:nd1, nd2:] = transpose( dKdD * dD2, (1, 0, 2), ).reshape(nd1, nd2 * xdim) @@ -352,8 +364,8 @@ def get_KQX_ext_fp( ddKpre, ddK = self.get_hessian_K(K) ddKdD = ((dDpre1 * dDpre2 * ddKpre) * ddK) * dD1 dKddD = (ddDpre * dKpre) * dK - Kext[nd1:, nd2:] = np.transpose( - np.einsum("ijk,ljk->iljk", ddKdD, dD2, optimize=True) + Kext[nd1:, nd2:] = transpose( + einsum("ijk,ljk->iljk", ddKdD, dD2, optimize=True) + (ddD * dKddD), (0, 2, 1, 3), ).reshape(nd1 * xdim, nd2 * xdim) @@ -372,7 +384,7 @@ def get_derivative_K(self, K, **kwargs): The distance matrix contains one of the length scales. Parameters: - K : (N,M) array + K: (N,M) array The kernel matrix without derivatives. Returns: @@ -390,7 +402,7 @@ def get_hessian_K(self, K, **kwargs): The distance matrices contain one of the length scales. Parameters: - K : (N,M) array + K: (N,M) array The kernel matrix without derivatives. Returns: @@ -402,24 +414,24 @@ def get_hessian_K(self, K, **kwargs): def diag(self, features, get_derivatives=True, **kwargs): nd1 = len(features) - K_diag = np.ones(nd1) + K_diag = ones(nd1, dtype=self.dtype) if get_derivatives: if self.use_fingerprint: fp_deriv = self.get_fp_deriv(features) - Kdd_diag = np.einsum( + Kdd_diag = einsum( "dij,dij->di", fp_deriv, fp_deriv, optimize=True, ).reshape(-1) - return np.append( + return append( K_diag, - np.exp(-2.0 * self.hp["length"][0]) * Kdd_diag, + exp(-2.0 * self.hp["length"][0]) * Kdd_diag, ) - return np.append( + return append( K_diag, - np.exp(-2.0 * self.hp["length"][0]) - * np.ones(nd1 * len(features[0])), + exp(-2.0 * self.hp["length"][0]) + * ones(nd1 * len(features[0]), dtype=self.dtype), ) return K_diag @@ -429,10 +441,8 @@ def diag_deriv(self, features, **kwargs): def get_gradients(self, features, hp, KXX, correction=True, **kwargs): hp_deriv = {} if "length" in hp: - if self.use_fingerprint: - X = self.get_arrays(features) * np.exp(-self.hp["length"][0]) - else: - X = features * np.exp(-self.hp["length"][0]) + X = self.get_arrays(features) + X *= exp(-self.hp["length"][0]) D = squareform( self.get_symmetric_absolute_distances(X, metric="sqeuclidean") ) @@ -447,11 +457,11 @@ def get_gradients(self, features, hp, KXX, correction=True, **kwargs): nd1x1 = nd1x + nd1 # Get the gradient of the kernel K = KXX[:nd1, :nd1].copy() - K_diag = np.diag(KXX) + K_diag = diag(KXX) Kd = KXX.copy() Kd[:nd1, :nd1] = Kd[:nd1, :nd1] * D D2 = D - 2 - Kd[:nd1, nd1:] = Kd[:nd1, nd1:] * np.tile(D2, (1, xdim)) + Kd[:nd1, nd1:] *= tile(D2, (1, xdim)) Kd[nd1:, :nd1] = Kd[:nd1, nd1:].T ddKpre, ddK = self.get_hessian_K(K) if self.use_fingerprint: @@ -463,7 +473,7 @@ def get_gradients(self, features, hp, KXX, correction=True, **kwargs): axis=0, **kwargs, ) - ddKdD = ((dDpre * dDpre * ddKpre) * ddK) * np.transpose( + ddKdD = ((dDpre * dDpre * ddKpre) * ddK) * transpose( dD, (0, 2, 1), ) @@ -477,30 +487,29 @@ def get_gradients(self, features, hp, KXX, correction=True, **kwargs): axis=0, ) ddKdD = ((-dDpre * dDpre * ddKpre) * ddK) * dD + xdimm = xdim - 1 for d1 in range(1, xdim): nd1d1 = nd1 * d1 nd1d11 = nd1d1 + nd1 - ddKdDdD = 2 * np.transpose( - ddKdD[d1 - 1] * dD[d1 - 1 :], + d1m = d1 - 1 + ddKdDdD = 2.0 * transpose( + ddKdD[d1m] * dD[d1m:], (1, 0, 2), ).reshape(nd1, nd1 * (xdim - d1 + 1)) - Kd[nd1d1:nd1d11, nd1d1:] = ( - Kd[nd1d1:nd1d11, nd1d1:] - * np.tile(D2, (1, xdim - d1 + 1)) - ) - ddKdDdD + Kd[nd1d1:nd1d11, nd1d1:] *= tile(D2, (1, xdim - d1 + 1)) + Kd[nd1d1:nd1d11, nd1d1:] -= ddKdDdD Kd[nd1d11:, nd1d1:nd1d11] = Kd[nd1d1:nd1d11, nd1d11:].T - Kd[nd1x:nd1x1, nd1x:nd1x1] = Kd[ - nd1x:nd1x1, nd1x:nd1x1 - ] * D2 - (2 * ddKdD[xdim - 1] * dD[xdim - 1]) + Kd[nd1x:nd1x1, nd1x:nd1x1] *= D2 + Kd[nd1x:nd1x1, nd1x:nd1x1] -= 2.0 * ddKdD[xdimm] * dD[xdimm] if correction: Kd[range(nd1x), range(nd1x)] += ( - (1 / (1 / (2.3e-16) - (len(K_diag) ** 2))) - * (2 * np.sum(K_diag)) - * (-2 * np.sum(K_diag[nd1:])) + (1.0 / (1.0 / self.eps - (len(K_diag) ** 2))) + * (2.0 * K_diag.sum()) + * (-2.0 * K_diag[nd1:].sum()) ) else: Kd = D * KXX - hp_deriv["length"] = np.array([Kd]) + hp_deriv["length"] = asarray([Kd]) return hp_deriv def get_distance_derivative(self, Q, X, nd1, nd2, dim, axis=0, **kwargs): @@ -508,7 +517,7 @@ def get_distance_derivative(self, Q, X, nd1, nd2, dim, axis=0, **kwargs): Get the derivative of the scaled distance matrix wrt. the features/fingerprint. """ - dDpre = 2.0 * np.exp(-self.hp["length"][0]) + dDpre = 2.0 * exp(-self.hp["length"][0]) if axis != 0: dDpre = -dDpre return dDpre, Q.T.reshape(dim, nd1, 1) - X.T.reshape(dim, 1, nd2) @@ -525,20 +534,20 @@ def get_distance_derivative_fp( Get the derivative of the distance matrix wrt. the features/fingerprint. """ - dDpre = 2.0 * np.exp(-self.hp["length"][0]) + dDpre = 2.0 * exp(-self.hp["length"][0]) if axis != 0: dDpre = -dDpre if X is None: - Q_chain = np.einsum("lj,ikj->ilk", Q, fp_deriv) - dQ = Q_chain - np.diagonal(Q_chain, axis1=1, axis2=2)[:, None, :] + Q_chain = einsum("lj,ikj->ilk", Q, fp_deriv) + dQ = Q_chain - diagonal(Q_chain, axis1=1, axis2=2)[:, None, :] return dDpre, dQ if axis == 0: - Q_chain = np.einsum("kj,ikj->ik", Q, fp_deriv) - X_chain = np.einsum("lj,ikj->ikl", X, fp_deriv) + Q_chain = einsum("kj,ikj->ik", Q, fp_deriv) + X_chain = einsum("lj,ikj->ikl", X, fp_deriv) dQ = Q_chain[:, :, None] - X_chain return dDpre, dQ - Q_chain = np.einsum("lj,ikj->ilk", Q, fp_deriv) - X_chain = np.einsum("kj,ikj->ik", X, fp_deriv) + Q_chain = einsum("lj,ikj->ilk", Q, fp_deriv) + X_chain = einsum("kj,ikj->ik", X, fp_deriv) dQ = Q_chain - X_chain[:, None, :] return dDpre, dQ @@ -547,7 +556,7 @@ def get_distance_hessian(self, **kwargs): Get the derivative of the scaled distance matrix wrt. the features/fingerprint. """ - dDpre = -2.0 * np.exp(-2 * self.hp["length"][0]) + dDpre = -2.0 * exp(-2 * self.hp["length"][0]) return dDpre, 1.0 def get_distance_hessian_fp(self, fp_deriv1, fp_deriv2, **kwargs): @@ -555,8 +564,8 @@ def get_distance_hessian_fp(self, fp_deriv1, fp_deriv2, **kwargs): Get the derivative of the scaled distance matrix wrt. the features/fingerprint. """ - dDpre = -2.0 * np.exp(-2 * self.hp["length"][0]) - hes_fp = np.einsum( + dDpre = -2.0 * exp(-2 * self.hp["length"][0]) + hes_fp = einsum( "dji,eki->dejk", fp_deriv1, fp_deriv2, diff --git a/catlearn/regression/gp/means/constant.py b/catlearn/regression/gp/means/constant.py index 4b4a9e8c..dcc55e94 100644 --- a/catlearn/regression/gp/means/constant.py +++ b/catlearn/regression/gp/means/constant.py @@ -1,9 +1,9 @@ -import numpy as np +from numpy import full, zeros from .prior import Prior class Prior_constant(Prior): - def __init__(self, yp=0.0, add=0.0, **kwargs): + def __init__(self, yp=0.0, add=0.0, dtype=float, **kwargs): """ The prior mean of the targets. The prior mean is used as a baseline of the target values. @@ -12,37 +12,40 @@ def __init__(self, yp=0.0, add=0.0, **kwargs): A value can be added to the constant. Parameters: - yp : float + yp: float The prior mean constant - add : float + add: float A value added to the found prior mean from data. + dtype: type + The data type of the arrays. """ - self.update_arguments(yp=yp, add=add, **kwargs) + self.update_arguments(yp=yp, add=add, dtype=dtype, **kwargs) def get(self, features, targets, get_derivatives=True, **kwargs): if get_derivatives: - yp = np.zeros(targets.shape) + yp = zeros(targets.shape, dtype=self.dtype) yp[:, 0] = self.prior_mean return yp - return np.full(targets.shape, self.prior_mean) + return full(targets.shape, self.prior_mean, dtype=self.dtype) def get_parameters(self, **kwargs): return dict(yp=self.yp, add=self.add) - def update_arguments(self, yp=None, add=None, **kwargs): + def update_arguments(self, yp=None, add=None, dtype=None, **kwargs): """ Update the class with its arguments. The existing arguments are used if they are not given. Parameters: - yp : float + yp: float The prior mean constant - add : float + add: float A value added to the found prior mean from data. Returns: self: The updated object itself. """ + super().update_arguments(dtype=dtype) if add is not None: self.add = add if yp is not None: @@ -53,7 +56,7 @@ def update_arguments(self, yp=None, add=None, **kwargs): def get_arguments(self): "Get the arguments of the class itself." # Get the arguments given to the class in the initialization - arg_kwargs = dict(yp=self.yp, add=self.add) + arg_kwargs = dict(yp=self.yp, add=self.add, dtype=self.dtype) # Get the constants made within the class constant_kwargs = dict() # Get the objects made within the class diff --git a/catlearn/regression/gp/means/first.py b/catlearn/regression/gp/means/first.py index 690bd867..851f162e 100644 --- a/catlearn/regression/gp/means/first.py +++ b/catlearn/regression/gp/means/first.py @@ -2,7 +2,7 @@ class Prior_first(Prior_constant): - def __init__(self, yp=0.0, add=0.0, **kwargs): + def __init__(self, yp=0.0, add=0.0, dtype=float, **kwargs): """ The prior mean of the targets. The prior mean is used as a baseline of the target values. @@ -10,12 +10,14 @@ def __init__(self, yp=0.0, add=0.0, **kwargs): A value can be added to the constant. Parameters: - yp : float + yp: float The prior mean constant - add : float + add: float A value added to the found prior mean from data. + dtype: type + The data type of the arrays. """ - self.update_arguments(yp=yp, add=add, **kwargs) + self.update_arguments(yp=yp, add=add, dtype=dtype, **kwargs) def update(self, features, targets, **kwargs): self.update_arguments(yp=targets.item(0)) diff --git a/catlearn/regression/gp/means/max.py b/catlearn/regression/gp/means/max.py index 947d7197..97ee368f 100644 --- a/catlearn/regression/gp/means/max.py +++ b/catlearn/regression/gp/means/max.py @@ -1,9 +1,8 @@ -import numpy as np from .constant import Prior_constant class Prior_max(Prior_constant): - def __init__(self, yp=0.0, add=0.0, **kwargs): + def __init__(self, yp=0.0, add=0.0, dtype=float, **kwargs): """ The prior mean of the targets. The prior mean is used as a baseline of the target values. @@ -11,13 +10,15 @@ def __init__(self, yp=0.0, add=0.0, **kwargs): A value can be added to the constant. Parameters: - yp : float + yp: float The prior mean constant - add : float + add: float A value added to the found prior mean from data. + dtype: type + The data type of the arrays. """ - self.update_arguments(yp=yp, add=add, **kwargs) + self.update_arguments(yp=yp, add=add, dtype=dtype, **kwargs) def update(self, features, targets, **kwargs): - self.update_arguments(yp=np.max(targets[:, 0])) + self.update_arguments(yp=targets[:, 0].max()) return self diff --git a/catlearn/regression/gp/means/mean.py b/catlearn/regression/gp/means/mean.py index f518ef32..9f29aad0 100644 --- a/catlearn/regression/gp/means/mean.py +++ b/catlearn/regression/gp/means/mean.py @@ -1,9 +1,8 @@ -import numpy as np from .constant import Prior_constant class Prior_mean(Prior_constant): - def __init__(self, yp=0.0, add=0.0, **kwargs): + def __init__(self, yp=0.0, add=0.0, dtype=float, **kwargs): """ The prior mean of the targets. The prior mean is used as a baseline of the target values. @@ -11,13 +10,15 @@ def __init__(self, yp=0.0, add=0.0, **kwargs): A value can be added to the constant. Parameters: - yp : float + yp: float The prior mean constant - add : float + add: float A value added to the found prior mean from data. + dtype: type + The data type of the arrays. """ - self.update_arguments(yp=yp, add=add, **kwargs) + self.update_arguments(yp=yp, add=add, dtype=dtype, **kwargs) def update(self, features, targets, **kwargs): - self.update_arguments(yp=np.mean(targets[:, 0])) + self.update_arguments(yp=targets[:, 0].mean()) return self diff --git a/catlearn/regression/gp/means/median.py b/catlearn/regression/gp/means/median.py index 63df8803..c280bd6d 100644 --- a/catlearn/regression/gp/means/median.py +++ b/catlearn/regression/gp/means/median.py @@ -1,9 +1,9 @@ -import numpy as np +from numpy import median from .constant import Prior_constant class Prior_median(Prior_constant): - def __init__(self, yp=0.0, add=0.0, **kwargs): + def __init__(self, yp=0.0, add=0.0, dtype=float, **kwargs): """ The prior mean of the targets. The prior mean is used as a baseline of the target values. @@ -12,13 +12,13 @@ def __init__(self, yp=0.0, add=0.0, **kwargs): A value can be added to the constant. Parameters: - yp : float + yp: float The prior mean constant - add : float + add: float A value added to the found prior mean from data. """ - self.update_arguments(yp=yp, add=add, **kwargs) + self.update_arguments(yp=yp, add=add, dtype=dtype, **kwargs) def update(self, features, targets, **kwargs): - self.update_arguments(yp=np.median(targets[:, 0])) + self.update_arguments(yp=median(targets[:, 0])) return self diff --git a/catlearn/regression/gp/means/min.py b/catlearn/regression/gp/means/min.py index 12cf0aee..f77052f4 100644 --- a/catlearn/regression/gp/means/min.py +++ b/catlearn/regression/gp/means/min.py @@ -1,9 +1,8 @@ -import numpy as np from .constant import Prior_constant class Prior_min(Prior_constant): - def __init__(self, yp=0.0, add=0.0, **kwargs): + def __init__(self, yp=0.0, add=0.0, dtype=float, **kwargs): """ The prior mean of the targets. The prior mean is used as a baseline of the target values. @@ -12,13 +11,15 @@ def __init__(self, yp=0.0, add=0.0, **kwargs): A value can be added to the constant. Parameters: - yp : float + yp: float The prior mean constant - add : float + add: float A value added to the found prior mean from data. + dtype: type + The data type of the arrays. """ - self.update_arguments(yp=yp, add=add, **kwargs) + self.update_arguments(yp=yp, add=add, dtype=dtype, **kwargs) def update(self, features, targets, **kwargs): - self.update_arguments(yp=np.min(targets[:, 0])) + self.update_arguments(yp=targets[:, 0].min()) return self diff --git a/catlearn/regression/gp/means/prior.py b/catlearn/regression/gp/means/prior.py index 2cc2fc88..6c9307fb 100644 --- a/catlearn/regression/gp/means/prior.py +++ b/catlearn/regression/gp/means/prior.py @@ -1,19 +1,23 @@ class Prior: - def __init__(self, **kwargs): + def __init__(self, dtype=float, **kwargs): """ The prior mean of the targets. The prior mean is used as a baseline of the target values. + + Parameters: + dtype: type + The data type of the arrays. """ - self.update_arguments(**kwargs) + self.update_arguments(dtype=dtype, **kwargs) def get(self, features, targets, get_derivatives=True, **kwargs): """ Get the prior mean of the targets. Parameters: - features : (N,D) array or (N) list of fingerprint objects + features: (N,D) array or (N) list of fingerprint objects Training features with N data points. - targets : (N,1) array or (N,1+D) array + targets: (N,1) array or (N,1+D) array Training targets with N data points. If get_derivatives=True, the training targets is in first column and derivatives is in the next columns. @@ -27,9 +31,9 @@ def update(self, features, targets, **kwargs): Update the prior mean with the given data. Parameters: - features : (N,D) array or (N) list of fingerprint objects + features: (N,D) array or (N) list of fingerprint objects Training features with N data points. - targets : (N,1) array or (N,1+D) array + targets: (N,1) array or (N,1+D) array Training targets with N data points. If get_derivatives=True, the training targets is in first column and derivatives is in the next columns. @@ -49,20 +53,41 @@ def get_parameters(self, **kwargs): """ return dict() - def update_arguments(self, **kwargs): + def set_dtype(self, dtype, **kwargs): + """ + Set the data type of the arrays. + + Parameters: + dtype: type + The data type of the arrays. + + Returns: + self: The updated object itself. + """ + # Set the data type + self.dtype = dtype + return self + + def update_arguments(self, dtype=None, **kwargs): """ Update the class with its arguments. The existing arguments are used if they are not given. + Parameters: + dtype: type + The data type of the arrays. + Returns: self: The updated object itself. """ + if dtype is not None or not hasattr(self, "dtype"): + self.set_dtype(dtype=dtype) return self def get_arguments(self): "Get the arguments of the class itself." # Get the arguments given to the class in the initialization - arg_kwargs = dict() + arg_kwargs = dict(dtype=self.dtype) # Get the constants made within the class constant_kwargs = dict() # Get the objects made within the class diff --git a/catlearn/regression/gp/models/gp.py b/catlearn/regression/gp/models/gp.py index e507bdbd..62bc4432 100644 --- a/catlearn/regression/gp/models/gp.py +++ b/catlearn/regression/gp/models/gp.py @@ -1,16 +1,23 @@ -import numpy as np -from .model import ModelProcess +from numpy import asarray, array, diag, empty, exp, full +from .model import ( + ModelProcess, + Prior_mean, + SE, + HyperparameterFitter, + LogLikelihood, +) class GaussianProcess(ModelProcess): def __init__( self, - prior=None, - kernel=None, - hpfitter=None, + prior=Prior_mean(), + kernel=SE(use_derivatives=False, use_fingerprint=False), + hpfitter=HyperparameterFitter(func=LogLikelihood()), hp={}, use_derivatives=False, use_correction=True, + dtype=float, **kwargs ): """ @@ -31,35 +38,24 @@ def __init__( The hyperparameters are used in the log-space. use_derivatives: bool Use derivatives/gradients for training and predictions. - use_correction : bool + use_correction: bool Use the noise correction on the covariance matrix. + dtype: type + The data type of the arrays. """ # Set default descriptors self.trained_model = False self.corr = 0.0 self.features = [] - self.L = np.array([]) + self.L = empty(0, dtype=dtype) self.low = False - self.coef = np.array([]) + self.coef = empty(0, dtype=dtype) self.prefactor = 1.0 # Set default hyperparameters - self.hp = {"noise": np.array([-8.0]), "prefactor": np.array([0.0])} - # Set the default prior mean class - if prior is None: - from ..means.mean import Prior_mean - - prior = Prior_mean() - # Set the default kernel class - if kernel is None: - from ..kernel import SE - - kernel = SE(use_derivatives=use_derivatives, use_fingerprint=False) - # The default hyperparameter optimization method - if hpfitter is None: - from ..hpfitter import HyperparameterFitter - from ..objectivefunctions.gp.likelihood import LogLikelihood - - hpfitter = HyperparameterFitter(func=LogLikelihood()) + self.hp = { + "noise": asarray([-8.0], dtype=dtype), + "prefactor": asarray([0.0], dtype=dtype), + } # Set all the arguments self.update_arguments( prior=prior, @@ -68,53 +64,54 @@ def __init__( hp=hp, use_derivatives=use_derivatives, use_correction=use_correction, + dtype=dtype, **kwargs ) def set_hyperparams(self, new_params, **kwargs): - self.kernel.set_hyperparams(new_params) - # Prefactor and relative-noise hyperparameter is always in the GP + # Set the hyperparameters in the parent class + super().set_hyperparams(new_params, **kwargs) + # Set the prefactor hyperparameter if "prefactor" in new_params: - self.hp["prefactor"] = np.array( - new_params["prefactor"], dtype=float + self.hp["prefactor"] = array( + new_params["prefactor"], + dtype=self.dtype, ).reshape(-1) self.prefactor = self.calculate_prefactor() - if "noise" in new_params: - self.hp["noise"] = np.array( - new_params["noise"], dtype=float - ).reshape(-1) - if "noise_deriv" in new_params: - self.hp["noise_deriv"] = np.array( - new_params["noise_deriv"], dtype=float - ).reshape(-1) return self def get_gradients(self, features, hp, KXX, **kwargs): hp_deriv = {} n_data, m_data = len(features), len(KXX) if "prefactor" in hp: - hp_deriv["prefactor"] = np.array( + hp_deriv["prefactor"] = asarray( [ 2.0 - * np.exp(2.0 * self.hp["prefactor"][0]) + * exp(2.0 * self.hp["prefactor"][0]) * self.add_regularization(KXX, n_data, overwrite=False) - ] + ], ) if "noise" in hp: - K_deriv = np.full(m_data, 2.0 * np.exp(2.0 * self.hp["noise"][0])) + K_deriv = full( + m_data, + 2.0 * exp(2.0 * self.hp["noise"][0]), + dtype=self.dtype, + ) if "noise_deriv" in self.hp: K_deriv[n_data:] = 0.0 - hp_deriv["noise"] = np.array([np.diag(K_deriv)]) + hp_deriv["noise"] = asarray([diag(K_deriv)]) else: - hp_deriv["noise"] = np.array([np.diag(K_deriv)]) + hp_deriv["noise"] = asarray([diag(K_deriv)]) if "noise_deriv" in hp: - K_deriv = np.full( - m_data, 2.0 * np.exp(2.0 * self.hp["noise_deriv"][0]) + K_deriv = full( + m_data, + 2.0 * exp(2.0 * self.hp["noise_deriv"][0]), + dtype=self.dtype, ) K_deriv[:n_data] = 0.0 - hp_deriv["noise_deriv"] = np.array([np.diag(K_deriv)]) + hp_deriv["noise_deriv"] = asarray([diag(K_deriv)]) hp_deriv.update(self.kernel.get_gradients(features, hp, KXX=KXX)) return hp_deriv def calculate_prefactor(self, features=None, targets=None, **kwargs): - return np.exp(2.0 * self.hp["prefactor"][0]) + return exp(2.0 * self.hp["prefactor"][0]) diff --git a/catlearn/regression/gp/models/model.py b/catlearn/regression/gp/models/model.py index 0825163f..088a937d 100644 --- a/catlearn/regression/gp/models/model.py +++ b/catlearn/regression/gp/models/model.py @@ -1,16 +1,33 @@ -import numpy as np +from numpy import ( + array, + asarray, + diag, + einsum, + empty, + exp, + finfo, + inf, + matmul, + nan_to_num, +) from scipy.linalg import cho_factor, cho_solve +import pickle +from ..means.mean import Prior_mean +from ..kernel import SE +from ..hpfitter import HyperparameterFitter +from ..objectivefunctions.gp.likelihood import LogLikelihood class ModelProcess: def __init__( self, - prior=None, - kernel=None, - hpfitter=None, + prior=Prior_mean(), + kernel=SE(use_derivatives=False, use_fingerprint=False), + hpfitter=HyperparameterFitter(func=LogLikelihood()), hp={}, use_derivatives=False, use_correction=True, + dtype=float, **kwargs, ): """ @@ -20,47 +37,33 @@ def __init__( The hyperparameters can be optimized. Parameters: - prior : Prior class + prior: Prior class The prior mean given for the data. - kernel : Kernel class + kernel: Kernel class The kernel function used for the kernel matrix. - hpfitter : HyperparameterFitter class + hpfitter: HyperparameterFitter class A class to optimize hyperparameters - hp : dictionary + hp: dictionary A dictionary of hyperparameters like noise and length scale. The hyperparameters are used in the log-space. - use_derivatives : bool + use_derivatives: bool Use derivatives/gradients of the targets for training and predictions. - use_correction : bool + use_correction: bool Use the noise correction on the covariance matrix. + dtype: type + The data type of the arrays. """ # Set default descriptors self.trained_model = False self.corr = 0.0 self.features = [] - self.L = np.array([]) + self.L = empty(0, dtype=dtype) self.low = False - self.coef = np.array([]) + self.coef = empty(0, dtype=dtype) self.prefactor = 1.0 # Set default relative-noise hyperparameter - self.hp = {"noise": np.array([-8.0])} - # Set the default prior mean class - if prior is None: - from ..means.mean import Prior_mean - - prior = Prior_mean() - # Set the default kernel class - if kernel is None: - from ..kernel import SE - - kernel = SE(use_derivatives=use_derivatives, use_fingerprint=False) - # The default hyperparameter optimization method - if hpfitter is None: - from ..hpfitter import HyperparameterFitter - from ..objectivefunctions.gp.likelihood import LogLikelihood - - hpfitter = HyperparameterFitter(func=LogLikelihood()) + self.hp = {"noise": asarray([-8.0], dtype=dtype)} # Set all the arguments self.update_arguments( prior=prior, @@ -69,6 +72,7 @@ def __init__( hp=hp, use_derivatives=use_derivatives, use_correction=use_correction, + dtype=dtype, **kwargs, ) @@ -77,9 +81,9 @@ def train(self, features, targets, **kwargs): Train the model with training features and targets. Parameters: - features : (N,D) array or (N) list of fingerprint objects + features: (N,D) array or (N) list of fingerprint objects Training features with N data points. - targets : (N,1) array or (N,1+D) array + targets: (N,1) array or (N,1+D) array Training targets with N data points. If use_derivatives=True, the training targets is in first column and derivatives is in the next columns. @@ -94,11 +98,11 @@ def train(self, features, targets, **kwargs): # Make the kernel matrix decomposition self.L, self.low = self.calculate_kernel_decomposition(features) # Modify the targets with the prior mean and rearrangement - targets = self.modify_targets(features, targets) + targets_mod = self.modify_targets(features, targets) # Calculate the coefficients - self.coef = self.calculate_coefficients(features, targets) + self.coef = self.calculate_coefficients(features, targets_mod) # Calculate the prefactor for variance predictions - self.prefactor = self.calculate_prefactor(features, targets) + self.prefactor = self.calculate_prefactor(features, targets_mod) return self def optimize( @@ -115,23 +119,23 @@ def optimize( Optimize the hyperparameter of the model and its kernel. Parameters: - features : (N,D) array or (N) list of fingerprint objects + features: (N,D) array or (N) list of fingerprint objects Training features with N data points. - targets : (N,1) array or (N,D+1) array + targets: (N,1) array or (N,D+1) array Training targets with or without derivatives with N data points. - retrain : bool + retrain: bool Whether to retrain the model after the optimization. - hp : dict + hp: dict Use a set of hyperparameters to optimize from else the current set is used. The hyperparameters are used in the log-space. - maxiter : int + maxiter: int Maximum number of iterations used by local or global optimization method. - pdis : dict + pdis: dict A dict of prior distributions for each hyperparameter type. - verbose : bool + verbose: bool Print the optimized hyperparameters and the object function value. @@ -141,7 +145,7 @@ def optimize( """ # Ensure the targets are in the right format if not self.use_derivatives: - targets = targets[:, 0:1].copy() + targets = array(targets[:, 0:1], dtype=self.dtype) # Optimize the hyperparameters sol = self.hpfitter.fit( features, @@ -149,6 +153,7 @@ def optimize( model=self, hp=hp, pdis=pdis, + retrain=retrain, **kwargs, ) # Print the solution @@ -177,33 +182,33 @@ def predict( coefficients from training data. Parameters: - features : (M,D) array or (M) list of fingerprint objects + features: (M,D) array or (M) list of fingerprint objects Test features with M data points. - get_derivatives : bool + get_derivatives: bool Whether to predict the derivatives of the prediction mean. - get_variance : bool + get_variance: bool Whether to predict the variance of the targets. - include_noise : bool + include_noise: bool Whether to include the noise of data in the predicted variance. - get_derivtives_var : bool + get_derivtives_var: bool Whether to predict the variance of the derivatives of the targets. - get_var_derivatives : bool + get_var_derivatives: bool Whether to calculate the derivatives of the predicted variance of the targets. Returns: - Y_predict : (M,1) or (M,1+D) array + Y_predict: (M,1) or (M,1+D) array The predicted mean values with or without derivatives. - var : (M,1) or (M,1+D) array + var: (M,1) or (M,1+D) array The predicted variance of the targets with or without derivatives. - var_deriv : (M,D) array + var_deriv: (M,D) array The derivatives of the predicted variance of the targets. """ # Check if the model is trained if not self.trained_model: - raise Exception("The model is not trained!") + raise AttributeError("The model is not trained!") # Calculate the kernel matrix of test and training data if ( get_derivatives @@ -256,25 +261,25 @@ def predict_mean( coefficients from training data. Parameters: - features : (M,D) array or (M) list of fingerprint objects + features: (M,D) array or (M) list of fingerprint objects Test features with M data points. - KQX : (M,N) or (M,N+N*D) or (M+M*D,N+N*D) array + KQX: (M,N) or (M,N+N*D) or (M+M*D,N+N*D) array The kernel matrix of the test and training features. If KQX=None, it is calculated. - get_derivatives : bool + get_derivatives: bool Whether to predict the derivatives of the prediction mean. Returns: - Y_predict : (M,1) array + Y_predict: (M,1) array The predicted mean values if get_derivatives=False. or - Y_predict : (M,1+D) array + Y_predict: (M,1+D) array The predicted mean values and its derivatives if get_derivatives=True. """ # Check if the model is trained if not self.trained_model: - raise Exception("The model is not trained!") + raise AttributeError("The model is not trained!") # Get the number of test points m_data = len(features) # Calculate the kernel of test and training data if it is not given @@ -288,11 +293,11 @@ def predict_mean( if not get_derivatives: KQX = KQX[:m_data] # Calculate the prediction mean - Y_predict = np.matmul(KQX, self.coef) + Y_predict = matmul(KQX, self.coef) # Rearrange prediction Y_predict = Y_predict.reshape(m_data, -1, order="F") # Add the prior mean - Y_predict = Y_predict + self.get_priormean( + Y_predict += self.get_priormean( features, Y_predict, get_derivatives=get_derivatives, @@ -311,29 +316,29 @@ def predict_variance( Calculate the predicted variance of the test targets. Parameters: - features : (M,D) array or (M) list of fingerprint objects + features: (M,D) array or (M) list of fingerprint objects Test features with M data points. - KQX : (M,N) or (M,N+N*D) or (M+M*D,N+N*D) array + KQX: (M,N) or (M,N+N*D) or (M+M*D,N+N*D) array The kernel matrix of the test and training features. If KQX=None, it is calculated. - get_derivatives : bool + get_derivatives: bool Whether to predict the uncertainty of the derivatives of the targets. - include_noise : bool + include_noise: bool Whether to include the noise of data in the predicted variance Returns: - var : (M,1) array + var: (M,1) array The predicted variance of the targets if get_derivatives=False. or - var : (M,1+D) array + var: (M,1+D) array The predicted variance of the targets and its derivatives if get_derivatives=True. """ # Check if the model is trained if not self.trained_model: - raise Exception("The model is not trained!") + raise AttributeError("The model is not trained!") # Get the number of test points m_data = len(features) # Calculate the kernel of test and training data if it is not given @@ -355,7 +360,7 @@ def predict_variance( ) # Calculate predicted variance var = ( - k - np.einsum("ij,ji->i", KQX, self.calculate_CinvKQX(KQX)) + k - einsum("ij,ji->i", KQX, self.calculate_CinvKQX(KQX)) ).reshape(-1, 1) # Scale prediction variance with the prefactor var = var * self.prefactor @@ -368,19 +373,19 @@ def calculate_variance_derivatives(self, features, KQX=None, **kwargs): the test targets. Parameters: - features : (M,D) array or (M) list of fingerprint objects + features: (M,D) array or (M) list of fingerprint objects Test features with M data points. - KQX : (M,N) or (M,N+N*D) or (M+M*D,N+N*D) array + KQX: (M,N) or (M,N+N*D) or (M+M*D,N+N*D) array The kernel matrix of the test and training features. If KQX=None, it is calculated. Returns: - var_deriv : (M,D) array + var_deriv: (M,D) array The derivatives of the predicted variance of the targets. """ # Check if the model is trained if not self.trained_model: - raise Exception("The model is not trained!") + raise AttributeError("The model is not trained!") # Get the number of test points m_data = len(features) # Calculate the kernel matrix of test and training data @@ -393,8 +398,10 @@ def calculate_variance_derivatives(self, features, KQX=None, **kwargs): # Calculate derivative of the diagonal wrt. the test features k_deriv = self.kernel_deriv_diag(features) # Calculate derivative of the predicted variance - var_deriv = k_deriv - 2.0 * np.einsum( - "ij,ji->i", KQX[m_data:], self.calculate_CinvKQX(KQX[:m_data]) + var_deriv = k_deriv - 2.0 * einsum( + "ij,ji->i", + KQX[m_data:], + self.calculate_CinvKQX(KQX[:m_data]), ).reshape(-1, 1) # Scale prediction variance with the prefactor var_deriv = var_deriv * self.prefactor @@ -406,23 +413,25 @@ def set_hyperparams(self, new_params, **kwargs): Set or update the hyperparameters for the model. Parameters: - new_params : dictionary + new_params: dictionary A dictionary of hyperparameters that are added or updated. The hyperparameters are used in the log-space. Returns: self: The object itself with the new hyperparameters. """ + # Set the hyperparameters in the kernel self.kernel.set_hyperparams(new_params) + # Set the relative-noise hyperparameter if "noise" in new_params: - self.hp["noise"] = np.array( + self.hp["noise"] = array( new_params["noise"], - dtype=float, + dtype=self.dtype, ).reshape(-1) if "noise_deriv" in new_params: - self.hp["noise_deriv"] = np.array( + self.hp["noise_deriv"] = array( new_params["noise_deriv"], - dtype=float, + dtype=self.dtype, ).reshape(-1) return self @@ -449,23 +458,23 @@ def get_kernel( Make the kernel matrix. Parameters: - features : (N,D) array or (N) list of fingerprint objects + features: (N,D) array or (N) list of fingerprint objects Features with N data points. - features2 : (M,D) array or (M) list of fingerprint objects + features2: (M,D) array or (M) list of fingerprint objects Features with M data points and D dimensions. If it is not given a squared kernel from features is generated. get_derivatives: bool Whether to predict derivatives of target. Returns: - KXX : array + KXX: array The symmetric kernel matrix if features2=None. The number of rows in the array is N, or N*(D+1) if get_derivatives=True. The number of columns in the array is N, or N*(D+1) if use_derivatives=True. or - KQX : array + KQX: array The kernel matrix if features2 is not None. The number of rows in the array is N, or N*(D+1) if get_derivatives=True. @@ -493,9 +502,9 @@ def update_priormean(self, features, targets, **kwargs): Update the prior mean with the data. Parameters: - features : (N,D) array or (N) list of fingerprint objects + features: (N,D) array or (N) list of fingerprint objects Training features with N data points. - targets : (N,1) array or (N,1+D) array + targets: (N,1) array or (N,1+D) array Training targets with N data points. If use_derivatives=True, the training targets is in first column and derivatives is in the next columns. @@ -517,13 +526,13 @@ def get_priormean( Get the prior mean for the given features. Parameters: - features : (N,D) array or (N) list of fingerprint objects + features: (N,D) array or (N) list of fingerprint objects Features with N data points. - targets : (N,1) array or (N,1+D) array + targets: (N,1) array or (N,1+D) array Targets with N data points. If get_derivatives=True, the targets is in first column and derivatives is in the next columns. - get_derivatives : bool + get_derivatives: bool Whether to give the prior mean of the derivatives of targets. Returns: @@ -551,11 +560,11 @@ def get_gradients(self, features, hp, KXX, **kwargs): wrt.the hyperparameters. Parameters: - features : (N,D) array + features: (N,D) array Features with N data points and D dimensions. - hp : list + hp: list A list with elements of the hyperparameters that are optimized. - KXX : (N,N) array + KXX: (N,N) array The kernel matrix of training data. Returns: @@ -572,19 +581,88 @@ def get_use_fingerprint(self): "Get whether a fingerprint is used as the features." return self.kernel.get_use_fingerprint() + def set_dtype(self, dtype, **kwargs): + """ + Set the data type of the arrays. + + Parameters: + dtype: type + The data type of the arrays. + + Returns: + self: The updated object itself. + """ + # Set the data type + self.dtype = dtype + # Set the machine precision + self.eps = 1.1 * finfo(self.dtype).eps + # Set the data type of the attributes + self.prior.set_dtype(dtype=dtype, **kwargs) + self.kernel.set_dtype(dtype=dtype, **kwargs) + self.hpfitter.set_dtype(dtype=dtype, **kwargs) + # Set the data type of the hyperparameters + self.set_hyperparams(self.hp) + return self + + def set_seed(self, seed, **kwargs): + """ + Set the random seed. + + Parameters: + seed: int (optional) + The random seed. + The seed can be an integer, RandomState, or Generator instance. + If not given, the default random number generator is used. + + Returns: + self: The instance itself. + """ + self.hpfitter.set_seed(seed) + return self + + def set_use_derivatives(self, use_derivatives, **kwargs): + """ + Set whether to use derivatives/gradients for training and predictions. + + Parameters: + use_derivatives: bool + Use derivatives/gradients for training and predictions. + + Returns: + self: The updated object itself. + """ + # Set whether to use derivatives for the target + self.use_derivatives = use_derivatives + # Set whether to use derivatives for the kernel + self.kernel.set_use_derivatives(use_derivatives) + return self + + def set_use_fingerprint(self, use_fingerprint, **kwargs): + """ + Set whether to use a fingerprint as the features. + + Parameters: + use_fingerprint: bool + Use a fingerprint as the features. + + Returns: + self: The updated object itself. + """ + # Set whether to use a fingerprint for the features + self.kernel.set_use_fingerprint(use_fingerprint) + return self + def save_model(self, filename="model.pkl", **kwargs): """ Save the model object to a file. Parameters: - filename : str + filename: str The name of the file where the object is saved. Returns: self: The object itself. """ - import pickle - with open(filename, "wb") as file: pickle.dump(self, file) return self @@ -594,14 +672,12 @@ def load_model(self, filename="model.pkl", **kwargs): Load the model object from a file. Parameters: - filename : str + filename: str The name of the file where the object is saved. Returns: model: The loaded model object. """ - import pickle - with open(filename, "rb") as file: model = pickle.load(file) return model @@ -614,6 +690,7 @@ def update_arguments( hp={}, use_derivatives=None, use_correction=None, + dtype=None, **kwargs, ): """ @@ -621,19 +698,21 @@ def update_arguments( The existing arguments are used if they are not given. Parameters: - prior : Prior class + prior: Prior class The prior given for new data. - kernel : Kernel class + kernel: Kernel class The kernel function used for the kernel matrix. - hpfitter : HyperparameterFitter class + hpfitter: HyperparameterFitter class A class to optimize hyperparameters - hp : dictionary + hp: dictionary A dictionary of hyperparameters like noise and length scale. The hyperparameters are used in the log-space. - use_derivatives : bool + use_derivatives: bool Use derivatives/gradients for training and predictions. - use_correction : bool + use_correction: bool Use the noise correction on the covariance matrix. + dtype: type + The data type of the arrays. Returns: self: The updated instance itself. @@ -646,13 +725,16 @@ def update_arguments( self.kernel = kernel.copy() # Set whether to use derivatives for the target if use_derivatives is not None: - self.use_derivatives = use_derivatives + self.set_use_derivatives(use_derivatives) # Set noise correction if use_correction is not None: self.use_correction = use_correction # The hyperparameter optimization method if hpfitter is not None: self.hpfitter = hpfitter.copy() + # Set the data type + if dtype is not None or not hasattr(self, "dtype"): + self.set_dtype(dtype=dtype) # Set hyperparameters self.set_hyperparams(hp) # Check if the attributes agree @@ -670,27 +752,20 @@ def add_regularization(self, K, n_data, overwrite=True, **kwargs): K = K.copy() m_data = len(K) # Calculate the correction, so the kernel matrix is invertible - self.corr = self.get_correction(np.diag(K)) + self.corr = self.get_correction(diag(K)) + add_v = self.inf_to_num(exp(2.0 * self.hp["noise"][0])) + self.corr if "noise_deriv" in self.hp: - add_v = ( - self.inf_to_num(np.exp(2 * self.hp["noise"][0])) + self.corr - ) K[range(n_data), range(n_data)] += add_v - add_v = ( - self.inf_to_num(np.exp(2 * self.hp["noise_deriv"][0])) - + self.corr - ) + add_v = self.inf_to_num(exp(2.0 * self.hp["noise_deriv"][0])) + add_v += self.corr K[range(n_data, m_data), range(n_data, m_data)] += add_v else: - add_v = ( - self.inf_to_num(np.exp(2 * self.hp["noise"][0])) + self.corr - ) K[range(m_data), range(m_data)] += add_v return K def inf_to_num(self, value, replacing=1e300): "Check if a value is infinite and then replace it with a large number." - if value == np.inf: + if value == inf: return replacing return value @@ -700,9 +775,9 @@ def get_correction(self, K_diag, **kwargs): is always invertible. """ if self.use_correction: - K_sum = np.sum(K_diag) + K_sum = K_diag.sum() n = len(K_diag) - corr = (K_sum**2) * (1.0 / ((1.0 / 2.3e-16) - (n**2))) + corr = (K_sum**2) * (1.0 / ((1.0 / self.eps) - (n**2))) else: corr = 0.0 return corr @@ -718,19 +793,19 @@ def calculate_kernel_decomposition(self, features, **kwargs): def modify_targets(self, features, targets, **kwargs): "Modify the targets with the prior mean and rearrangement." # Subtracting prior mean from target - targets = targets.copy() - self.update_priormean(features, targets, L=self.L, low=self.low) - targets = targets - self.get_priormean( + targets_mod = array(targets, dtype=self.dtype) + self.update_priormean(features, targets_mod, L=self.L, low=self.low) + targets_mod -= self.get_priormean( features, targets, get_derivatives=self.use_derivatives, ) # Rearrange targets if derivatives are used if self.use_derivatives: - targets = targets.T.reshape(-1, 1) + targets_mod = targets_mod.T.reshape(-1, 1) else: - targets = targets[:, 0:1].copy() - return targets + targets_mod = targets_mod[:, 0:1] + return targets_mod def calculate_coefficients(self, features, targets, **kwargs): "Calculate the coefficients for the prediction mean." @@ -755,12 +830,12 @@ def kernel_diag( k = self.kernel.diag(features, get_derivatives=get_derivatives) # Add noise to the kernel elements if include_noise: - noise = np.nan_to_num(np.exp(2.0 * self.hp["noise"][0])) + noise = nan_to_num(exp(2.0 * self.hp["noise"][0])) noise += self.corr if get_derivatives and "noise_deriv" in self.hp: k[:m_data] += noise k[m_data:] += ( - np.nan_to_num(np.exp(2.0 * self.hp["noise_deriv"][0])) + nan_to_num(exp(2.0 * self.hp["noise_deriv"][0])) + self.corr ) else: @@ -781,7 +856,7 @@ def calculate_CinvKQX(self, KQX, **kwargs): def check_attributes(self): "Check if all attributes agree between the class and subclasses." if self.use_derivatives != self.kernel.get_use_derivatives(): - raise Exception( + raise ValueError( "The Model and the Kernel do not agree " "whether to use derivatives!" ) @@ -797,6 +872,7 @@ def get_arguments(self): hp=self.get_hyperparams(), use_derivatives=self.use_derivatives, use_correction=self.use_correction, + dtype=self.dtype, ) # Get the constants made within the class constant_kwargs = dict( diff --git a/catlearn/regression/gp/models/tp.py b/catlearn/regression/gp/models/tp.py index 6eb42763..a85f8164 100644 --- a/catlearn/regression/gp/models/tp.py +++ b/catlearn/regression/gp/models/tp.py @@ -1,18 +1,25 @@ -import numpy as np -from .model import ModelProcess +from numpy import asarray, diag, dot, empty, exp, full +from .model import ( + ModelProcess, + Prior_mean, + SE, + HyperparameterFitter, +) +from ..objectivefunctions.tp.likelihood import LogLikelihood class TProcess(ModelProcess): def __init__( self, - prior=None, - kernel=None, - hpfitter=None, + prior=Prior_mean(), + kernel=SE(use_derivatives=False, use_fingerprint=False), + hpfitter=HyperparameterFitter(func=LogLikelihood()), hp={}, use_derivatives=False, use_correction=True, a=1e-20, b=1e-20, + dtype=float, **kwargs, ): """ @@ -33,7 +40,7 @@ def __init__( The hyperparameters are used in the log-space. use_derivatives: bool Use derivatives/gradients for training and predictions. - use_correction : bool + use_correction: bool Use the noise correction on the covariance matrix. a: float Hyperprior shape parameter for the inverse-gamma distribution @@ -41,35 +48,19 @@ def __init__( b: float Hyperprior scale parameter for the inverse-gamma distribution of the prefactor. + dtype: type + The data type of the arrays. """ # Set default descriptors self.trained_model = False self.corr = 0.0 self.features = [] - self.L = np.array([]) + self.L = empty(0, dtype=dtype) self.low = False - self.coef = np.array([]) + self.coef = empty(0, dtype=dtype) self.prefactor = 1.0 - # Set default relative-noise hyperparameters - self.hp = {"noise": np.array([-8.0])} - # Set the default prior mean class - if prior is None: - from ..means.mean import Prior_mean - - prior = Prior_mean() - # Set the default kernel class - if kernel is None: - from ..kernel import SE - - kernel = SE(use_derivatives=use_derivatives, use_fingerprint=False) - # The default hyperparameter optimization method - if hpfitter is None: - from ..hpfitter import HyperparameterFitter - from ..objectivefunctions.tp.likelihood import LogLikelihood - - hpfitter = HyperparameterFitter(func=LogLikelihood()) - # Set noise hyperparameters - self.hp = {"noise": np.array([-8.0])} + # Set default hyperparameters + self.hp = {"noise": asarray([-8.0], dtype=dtype)} # Set all the arguments self.update_arguments( prior=prior, @@ -80,22 +71,10 @@ def __init__( use_correction=use_correction, a=a, b=b, + dtype=dtype, **kwargs, ) - def set_hyperparams(self, new_params={}, **kwargs): - self.kernel.set_hyperparams(new_params) - # Noise is always in the TP - if "noise" in new_params: - self.hp["noise"] = np.array( - new_params["noise"], dtype=float - ).reshape(-1) - if "noise_deriv" in new_params: - self.hp["noise_deriv"] = np.array( - new_params["noise_deriv"], dtype=float - ).reshape(-1) - return self - def update_arguments( self, prior=None, @@ -106,6 +85,7 @@ def update_arguments( use_correction=None, a=None, b=None, + dtype=None, **kwargs, ): """ @@ -113,18 +93,18 @@ def update_arguments( The existing arguments are used if they are not given. Parameters: - prior : Prior class + prior: Prior class The prior given for new data. - kernel : Kernel class + kernel: Kernel class The kernel function used for the kernel matrix. - hpfitter : HyperparameterFitter class + hpfitter: HyperparameterFitter class A class to optimize hyperparameters - hp : dictionary + hp: dictionary A dictionary of hyperparameters like noise and length scale. The hyperparameters are used in the log-space. - use_derivatives : bool + use_derivatives: bool Use derivatives/gradients for training and predictions. - use_correction : bool + use_correction: bool Use the noise correction on the covariance matrix. a: float Hyperprior shape parameter for the inverse-gamma distribution @@ -132,53 +112,52 @@ def update_arguments( b: float Hyperprior scale parameter for the inverse-gamma distribution of the prefactor. + dtype: type + The data type of the arrays. Returns: self: The updated object itself. """ - # Set the prior mean class - if prior is not None: - self.prior = prior.copy() - # Set the kernel class - if kernel is not None: - self.kernel = kernel.copy() - # Set whether to use derivatives for the target - if use_derivatives is not None: - self.use_derivatives = use_derivatives - # Set noise correction - if use_correction is not None: - self.use_correction = use_correction - # The hyperparameter optimization method - if hpfitter is not None: - self.hpfitter = hpfitter.copy() + super().update_arguments( + prior=prior, + kernel=kernel, + hpfitter=hpfitter, + hp=hp, + use_derivatives=use_derivatives, + use_correction=use_correction, + dtype=dtype, + **kwargs, + ) # The hyperprior shape parameter if a is not None: self.a = float(a) # The hyperprior scale parameter if b is not None: self.b = float(b) - # Set hyperparameters - self.set_hyperparams(hp) - # Check if the attributes agree - self.check_attributes() return self def get_gradients(self, features, hp, KXX, **kwargs): hp_deriv = {} n_data, m_data = len(features), len(KXX) if "noise" in hp: - K_deriv = np.full(m_data, 2.0 * np.exp(2.0 * self.hp["noise"][0])) + K_deriv = full( + m_data, + 2.0 * exp(2.0 * self.hp["noise"][0]), + dtype=self.dtype, + ) if "noise_deriv" in self.hp: K_deriv[n_data:] = 0.0 - hp_deriv["noise"] = np.array([np.diag(K_deriv)]) + hp_deriv["noise"] = asarray([diag(K_deriv)]) else: - hp_deriv["noise"] = np.array([np.diag(K_deriv)]) + hp_deriv["noise"] = asarray([diag(K_deriv)]) if "noise_deriv" in hp: - K_deriv = np.full( - m_data, 2.0 * np.exp(2.0 * self.hp["noise_deriv"][0]) + K_deriv = full( + m_data, + 2.0 * exp(2.0 * self.hp["noise_deriv"][0]), + dtype=self.dtype, ) K_deriv[:n_data] = 0.0 - hp_deriv["noise_deriv"] = np.array([np.diag(K_deriv)]) + hp_deriv["noise_deriv"] = asarray([diag(K_deriv)]) hp_deriv.update(self.kernel.get_gradients(features, hp, KXX=KXX)) return hp_deriv @@ -188,7 +167,7 @@ def get_hyperprior_parameters(self, **kwargs): def calculate_prefactor(self, features, targets, **kwargs): n2 = float(len(targets) - 2) if len(targets) > 1 else 0.0 - tcoef = np.matmul(targets.T, self.coef).item(0) + tcoef = dot(targets.reshape(-1), self.coef.reshape(-1)) return (2.0 * self.b + tcoef) / (2.0 * self.a + n2) def get_arguments(self): @@ -203,6 +182,7 @@ def get_arguments(self): use_correction=self.use_correction, a=self.a, b=self.b, + dtype=self.dtype, ) # Get the constants made within the class constant_kwargs = dict( diff --git a/catlearn/regression/gp/objectivefunctions/batch.py b/catlearn/regression/gp/objectivefunctions/batch.py index 0979c3cb..104148ca 100644 --- a/catlearn/regression/gp/objectivefunctions/batch.py +++ b/catlearn/regression/gp/objectivefunctions/batch.py @@ -1,4 +1,11 @@ -import numpy as np +from numpy import ( + append, + arange, + array_split, + concatenate, + tile, +) +from numpy.random import default_rng, Generator, RandomState from .objectivefunction import ObjectiveFuction from ..means.constant import Prior_constant @@ -11,7 +18,8 @@ def __init__( batch_size=25, equal_size=False, use_same_prior_mean=True, - seed=1, + seed=None, + dtype=float, **kwargs, ): """ @@ -24,22 +32,24 @@ def __init__( noise optimized objective functions! Parameters: - func : ObjectiveFunction class + func: ObjectiveFunction class A class with the objective function used to optimize the hyperparameters. - get_prior_mean : bool + get_prior_mean: bool Whether to get the parameters of the prior mean in the solution. - equal_size : bool + equal_size: bool Whether the clusters are forced to have the same size. - use_same_prior_mean : bool + use_same_prior_mean: bool Whether to use the same prior mean for all models. - seed : int (optional) - The random seed used to permute the indicies. - If seed=None or False or 0, a random seed is not used. + seed: int (optional) + The random seed. + The seed can be an integer, RandomState, or Generator instance. + If not given, the default random number generator is used. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. """ - # Set a random seed - self.seed = seed # Set the arguments self.update_arguments( func=func, @@ -48,6 +58,7 @@ def __init__( equal_size=equal_size, use_same_prior_mean=use_same_prior_mean, seed=seed, + dtype=dtype, **kwargs, ) @@ -79,12 +90,12 @@ def function( self.sol = self.func.sol return output # Update the model with hyperparameters and prior mean - hp, parameters_set = self.make_hp(theta, parameters) + hp, _ = self.make_hp(theta, parameters) model = self.update_model(model, hp) self.set_same_prior_mean(model, X, Y) # Calculate the number of batches n_batches = self.get_number_batches(n_data) - indicies = np.arange(n_data) + indicies = arange(n_data) i_batches = self.randomized_batches( indicies, n_data, @@ -134,6 +145,38 @@ def function( self.update_solution(fvalue, theta, hp, model, jac=False) return fvalue + def set_dtype(self, dtype, **kwargs): + super().set_dtype(dtype=dtype) + # Set the dtype of the objective function + self.func.set_dtype(dtype=dtype) + return self + + def set_seed(self, seed=None, **kwargs): + """ + Set the random seed. + + Parameters: + seed: int (optional) + The random seed. + The seed can be an integer, RandomState, or Generator instance. + If not given, the default random number generator is used. + + Returns: + self: The instance itself. + """ + if seed is not None: + self.seed = seed + if isinstance(seed, int): + self.rng = default_rng(self.seed) + elif isinstance(seed, Generator) or isinstance(seed, RandomState): + self.rng = seed + else: + self.seed = None + self.rng = default_rng() + # Set the seed of the objective function + self.func.set_seed(seed=self.seed) + return self + def update_arguments( self, func=None, @@ -142,6 +185,7 @@ def update_arguments( equal_size=None, use_same_prior_mean=None, seed=None, + dtype=None, **kwargs, ): """ @@ -149,19 +193,23 @@ def update_arguments( The existing arguments are used if they are not given. Parameters: - func : ObjectiveFunction class + func: ObjectiveFunction class A class with the objective function used to optimize the hyperparameters. - get_prior_mean : bool + get_prior_mean: bool Whether to get the parameters of the prior mean in the solution. - equal_size : bool + equal_size: bool Whether the clusters are forced to have the same size. - use_same_prior_mean : bool + use_same_prior_mean: bool Whether to use the same prior mean for all models. - seed : int (optional) - The random seed used to permute the indicies. - If seed=None or False or 0, a random seed is not used. + seed: int (optional) + The random seed. + The seed can be an integer, RandomState, or Generator instance. + If not given, the default random number generator is used. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. Returns: self: The updated object itself. @@ -171,21 +219,23 @@ def update_arguments( # Set descriptor of the objective function self.use_analytic_prefactor = func.use_analytic_prefactor self.use_optimized_noise = func.use_optimized_noise - if get_prior_mean is not None: - self.get_prior_mean = get_prior_mean if batch_size is not None: self.batch_size = int(batch_size) if equal_size is not None: self.equal_size = equal_size if use_same_prior_mean is not None: self.use_same_prior_mean = use_same_prior_mean - if seed is not None: - self.seed = seed # Update the objective function if len(kwargs.keys()): self.func.update_arguments(**kwargs) - # Always reset the solution when the objective function is changed - self.reset_solution() + # Set the seed + if seed is not None or not hasattr(self, "seed"): + self.set_seed(seed=seed) + # Update the arguments of the parent class + super().update_arguments( + get_prior_mean=get_prior_mean, + dtype=dtype, + ) return self def update_solution( @@ -200,7 +250,7 @@ def update_solution( ): if fun < self.sol["fun"]: self.sol["fun"] = fun - self.sol["x"] = np.concatenate( + self.sol["x"] = concatenate( [hp[para] for para in sorted(hp.keys())] ) self.sol["hp"] = hp.copy() @@ -249,15 +299,12 @@ def randomized_batches(self, indicies, n_data, n_batches, **kwargs): # Ensure equal sizes of batches if chosen if self.equal_size: i_perm = self.ensure_equal_sizes(i_perm, n_data, n_batches) - i_batches = np.array_split(i_perm, n_batches) + i_batches = array_split(i_perm, n_batches) return i_batches def get_permutation(self, indicies): "Permute the indicies" - if self.seed: - rng = np.random.default_rng(seed=self.seed) - return rng.permutation(indicies) - return np.random.permutation(indicies) + return self.rng.permutation(indicies) def ensure_equal_sizes(self, i_perm, n_data, n_batches, **kwargs): "Extend the permuted indicies so the clusters have equal sizes." @@ -266,12 +313,12 @@ def ensure_equal_sizes(self, i_perm, n_data, n_batches, **kwargs): # Extend the permuted indicies if n_missing > 0: if n_missing > n_data: - i_perm = np.append( + i_perm = append( i_perm, - np.tile(i_perm, (n_missing // n_data) + 1)[:n_missing], + tile(i_perm, (n_missing // n_data) + 1)[:n_missing], ) else: - i_perm = np.append(i_perm, i_perm[:n_missing]) + i_perm = append(i_perm, i_perm[:n_missing]) return i_perm def get_arguments(self): @@ -284,6 +331,7 @@ def get_arguments(self): equal_size=self.equal_size, use_same_prior_mean=self.use_same_prior_mean, seed=self.seed, + dtype=self.dtype, ) # Get the constants made within the class constant_kwargs = dict() diff --git a/catlearn/regression/gp/objectivefunctions/best_batch.py b/catlearn/regression/gp/objectivefunctions/best_batch.py index 2a63acd3..d7426a7c 100644 --- a/catlearn/regression/gp/objectivefunctions/best_batch.py +++ b/catlearn/regression/gp/objectivefunctions/best_batch.py @@ -1,4 +1,4 @@ -import numpy as np +from numpy import arange, inf from .batch import BatchFuction @@ -10,7 +10,8 @@ def __init__( batch_size=25, equal_size=False, use_same_prior_mean=True, - seed=1, + seed=None, + dtype=float, **kwargs, ): """ @@ -23,19 +24,23 @@ def __init__( BestBatchFuction is not recommended for gradient-based optimization! Parameters: - func : ObjectiveFunction class + func: ObjectiveFunction class A class with the objective function used to optimize the hyperparameters. - get_prior_mean : bool + get_prior_mean: bool Whether to get the parameters of the prior mean in the solution. - equal_size : bool + equal_size: bool Whether the clusters are forced to have the same size. - use_same_prior_mean : bool + use_same_prior_mean: bool Whether to use the same prior mean for all models. - seed : int (optional) - The random seed used to permute the indicies. - If seed=None or False or 0, a random seed is not used. + seed: int (optional) + The random seed. + The seed can be an integer, RandomState, or Generator instance. + If not given, the default random number generator is used. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. """ # Set the arguments super().__init__( @@ -45,6 +50,7 @@ def __init__( equal_size=equal_size, use_same_prior_mean=use_same_prior_mean, seed=seed, + dtype=dtype, **kwargs, ) @@ -76,17 +82,17 @@ def function( self.sol = self.func.sol return output # Update the model with hyperparameters and prior mean - hp, parameters_set = self.make_hp(theta, parameters) + hp, _ = self.make_hp(theta, parameters) model = self.update_model(model, hp) self.set_same_prior_mean(model, X, Y) # Calculate the number of batches n_batches = self.get_number_batches(n_data) - indicies = np.arange(n_data) + indicies = arange(n_data) i_batches = self.randomized_batches( indicies, n_data, n_batches, **kwargs ) # Sum function values together from batches - fvalue = np.inf + fvalue = inf deriv = None for i_batch in i_batches: # Get the feature and target batch diff --git a/catlearn/regression/gp/objectivefunctions/gp/factorized_gpp.py b/catlearn/regression/gp/objectivefunctions/gp/factorized_gpp.py index 753fc33f..6adafd24 100644 --- a/catlearn/regression/gp/objectivefunctions/gp/factorized_gpp.py +++ b/catlearn/regression/gp/objectivefunctions/gp/factorized_gpp.py @@ -1,5 +1,20 @@ -import numpy as np -from .factorized_likelihood import FactorizedLogLikelihood +from numpy import ( + append, + asarray, + concatenate, + diag, + einsum, + empty, + exp, + log, + matmul, + pi, + zeros, +) +from .factorized_likelihood import ( + FactorizedLogLikelihood, + VariableTransformation, +) class FactorizedGPP(FactorizedLogLikelihood): @@ -8,8 +23,9 @@ def __init__( get_prior_mean=False, modification=False, ngrid=80, - bounds=None, + bounds=VariableTransformation(), noise_optimizer=None, + dtype=float, **kwargs, ): """ @@ -35,9 +51,12 @@ def __init__( bounds: Boundary_conditions class A class of the boundary conditions of the relative-noise hyperparameter. - noise_optimizer : Noise line search optimizer class + noise_optimizer: Noise line search optimizer class A line search optimization method for the relative-noise hyperparameter. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. """ super().__init__( get_prior_mean=get_prior_mean, @@ -45,6 +64,7 @@ def __init__( ngrid=ngrid, bounds=bounds, noise_optimizer=noise_optimizer, + dtype=dtype, **kwargs, ) @@ -134,29 +154,27 @@ def derivative( n_data, **kwargs, ): - gpp_deriv = np.array([]) - D_n = D + np.exp(2 * noise) + gpp_deriv = empty(0, dtype=self.dtype) + D_n = D + exp(2.0 * noise) UDn = U / D_n - KXX_inv = np.matmul(UDn, U.T) - K_inv_diag = np.diag(KXX_inv) - prefactor2 = np.mean( - (np.matmul(UDn, UTY).reshape(-1) ** 2) / K_inv_diag - ) - hp["prefactor"] = np.array([0.5 * np.log(prefactor2)]) - hp["noise"] = np.array([noise]) - coef_re = np.matmul(KXX_inv, Y_p).reshape(-1) + KXX_inv = matmul(UDn, U.T) + K_inv_diag = diag(KXX_inv) + prefactor2 = ((matmul(UDn, UTY).reshape(-1) ** 2) / K_inv_diag).mean() + hp["prefactor"] = asarray([0.5 * log(prefactor2)]) + hp["noise"] = asarray([noise]) + coef_re = matmul(KXX_inv, Y_p).reshape(-1) co_Kinv = coef_re / K_inv_diag for para in parameters_set: if para == "prefactor": - gpp_d = np.zeros((len(hp[para]))) + gpp_d = zeros((len(hp[para])), dtype=self.dtype) else: K_deriv = self.get_K_deriv(model, para, X=X, KXX=KXX) r_j, s_j = self.get_r_s_derivatives(K_deriv, KXX_inv, coef_re) gpp_d = ( - np.mean(co_Kinv * (2.0 * r_j + co_Kinv * s_j), axis=-1) + (co_Kinv * (2.0 * r_j + co_Kinv * s_j)).mean(axis=-1) / prefactor2 - ) + np.mean(s_j / K_inv_diag, axis=-1) - gpp_deriv = np.append(gpp_deriv, gpp_d) + ) + (s_j / K_inv_diag).mean(axis=-1) + gpp_deriv = append(gpp_deriv, gpp_d) gpp_deriv = gpp_deriv - self.logpriors(hp, pdis, jac=True) / n_data return gpp_deriv @@ -165,27 +183,22 @@ def get_r_s_derivatives(self, K_deriv, KXX_inv, coef): Get the r and s vector that are products of the inverse and derivative covariance matrix """ - r_j = np.einsum("ji,di->dj", KXX_inv, np.matmul(K_deriv, -coef)) - s_j = np.einsum("ji,dji->di", KXX_inv, np.matmul(K_deriv, KXX_inv)) + r_j = einsum("ji,di->dj", KXX_inv, matmul(K_deriv, -coef)) + s_j = einsum("ji,dji->di", KXX_inv, matmul(K_deriv, KXX_inv)) return r_j, s_j def get_eig_fun(self, noise, hp, pdis, U, UTY, D, n_data, **kwargs): "Calculate GPP from Eigendecomposition for a noise value." - D_n = D + np.exp(2.0 * noise) + D_n = D + exp(2.0 * noise) UDn = U / D_n - K_inv_diag = np.einsum("ij,ji->i", UDn, U.T) - prefactor = 0.5 * np.log( - np.mean((np.matmul(UDn, UTY).reshape(-1) ** 2) / K_inv_diag) - ) - gpp_v = ( - 1 - - np.mean(np.log(K_inv_diag)) - + 2.0 * prefactor - + np.log(2.0 * np.pi) + K_inv_diag = einsum("ij,ji->i", UDn, U.T) + prefactor = 0.5 * log( + ((matmul(UDn, UTY).reshape(-1) ** 2) / K_inv_diag).mean() ) + gpp_v = 1.0 - log(K_inv_diag).mean() + 2.0 * prefactor + log(2.0 * pi) if pdis is not None: - hp["prefactor"] = np.array([prefactor]) - hp["noise"] = np.array([noise]).reshape(-1) + hp["prefactor"] = asarray([prefactor]) + hp["noise"] = asarray([noise]).reshape(-1) return gpp_v - self.logpriors(hp, pdis, jac=False) / n_data def get_all_eig_fun(self, noises, hp, pdis, U, UTY, D, n_data, **kwargs): @@ -193,21 +206,20 @@ def get_all_eig_fun(self, noises, hp, pdis, U, UTY, D, n_data, **kwargs): Calculate GPP from Eigendecompositions for all noise values from the list. """ - D_n = D + np.exp(2.0 * noises) + D_n = D + exp(2.0 * noises) UDn = U / D_n[:, None, :] - K_inv_diag = np.einsum("dij,ji->di", UDn, U.T, optimize=True) - prefactor = 0.5 * np.log( - np.mean( - (np.matmul(UDn, UTY).reshape((len(noises), n_data)) ** 2) - / K_inv_diag, - axis=1, - ) + K_inv_diag = einsum("dij,ji->di", UDn, U.T, optimize=True) + prefactor = 0.5 * log( + ( + (matmul(UDn, UTY).reshape((len(noises), n_data)) ** 2) + / K_inv_diag + ).mean(axis=1) ) gpp_v = ( 1.0 - - np.mean(np.log(K_inv_diag), axis=1) + - log(K_inv_diag).mean(axis=1) + 2.0 * prefactor - + np.log(2.0 * np.pi) + + log(2.0 * pi) ) if pdis is not None: hp["prefactor"] = prefactor.reshape(-1, 1) @@ -273,21 +285,21 @@ def update_solution( and numerically, respectively. """ if fun < self.sol["fun"]: - D_n = D + np.exp(2.0 * noise) + D_n = D + exp(2.0 * noise) UDn = U / D_n - K_inv_diag = np.einsum("ij,ji->i", UDn, U.T) - prefactor2 = np.mean( - (np.matmul(UDn, UTY).reshape(-1) ** 2) / K_inv_diag - ) + K_inv_diag = einsum("ij,ji->i", UDn, U.T) + prefactor2 = ( + (matmul(UDn, UTY).reshape(-1) ** 2) / K_inv_diag + ).mean() if self.modification: prefactor2 = ( (n_data / (n_data - len(theta))) * prefactor2 if n_data - len(theta) > 0 else prefactor2 ) - hp["prefactor"] = np.array([0.5 * np.log(prefactor2)]) - hp["noise"] = np.array([noise]) - self.sol["x"] = np.concatenate( + hp["prefactor"] = asarray([0.5 * log(prefactor2)]) + hp["noise"] = asarray([noise]) + self.sol["x"] = concatenate( [hp[para] for para in sorted(hp.keys())] ) self.sol["hp"] = hp.copy() diff --git a/catlearn/regression/gp/objectivefunctions/gp/factorized_likelihood.py b/catlearn/regression/gp/objectivefunctions/gp/factorized_likelihood.py index 00b6a4e4..d8cdd70d 100644 --- a/catlearn/regression/gp/objectivefunctions/gp/factorized_likelihood.py +++ b/catlearn/regression/gp/objectivefunctions/gp/factorized_likelihood.py @@ -1,5 +1,17 @@ -import numpy as np +from numpy import ( + append, + asarray, + concatenate, + empty, + exp, + log, + matmul, + pi, + zeros, +) from ..objectivefunction import ObjectiveFuction +from ...hpboundary.hptrans import VariableTransformation +from ...optimizers.noisesearcher import NoiseFineGridSearch class FactorizedLogLikelihood(ObjectiveFuction): @@ -8,8 +20,9 @@ def __init__( get_prior_mean=False, modification=False, ngrid=80, - bounds=None, + bounds=VariableTransformation(), noise_optimizer=None, + dtype=float, **kwargs, ): """ @@ -35,22 +48,18 @@ def __init__( bounds: Boundary_conditions class A class of the boundary conditions of the relative-noise hyperparameter. - noise_optimizer : Noise line search optimizer class + noise_optimizer: Noise line search optimizer class A line search optimization method for the relative-noise hyperparameter. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. """ # Set descriptor of the objective function self.use_analytic_prefactor = True self.use_optimized_noise = True - # Set default bounds - if bounds is None: - from ...hpboundary.hptrans import VariableTransformation - - bounds = VariableTransformation(bounds=None) # Set default noise line optimizer if noise_optimizer is None: - from ...optimizers.noisesearcher import NoiseFineGridSearch - noise_optimizer = NoiseFineGridSearch( maxiter=1000, tol=1e-5, @@ -58,6 +67,7 @@ def __init__( multiple_min=False, ngrid=ngrid, loops=2, + dtype=dtype, ) # Set the arguments self.update_arguments( @@ -66,9 +76,21 @@ def __init__( ngrid=ngrid, bounds=bounds, noise_optimizer=noise_optimizer, + dtype=dtype, **kwargs, ) + def set_dtype(self, dtype, **kwargs): + super().set_dtype(dtype=dtype, **kwargs) + self.bounds.set_dtype(dtype=dtype, **kwargs) + self.noise_optimizer.set_dtype(dtype=dtype, **kwargs) + return self + + def set_seed(self, seed, **kwargs): + self.bounds.set_seed(seed, **kwargs) + self.noise_optimizer.set_seed(seed) + return self + def update_arguments( self, get_prior_mean=None, @@ -76,6 +98,7 @@ def update_arguments( ngrid=None, bounds=None, noise_optimizer=None, + dtype=None, **kwargs, ): """ @@ -96,15 +119,16 @@ def update_arguments( bounds: Boundary_conditions class A class of the boundary conditions of the relative-noise hyperparameter. - noise_optimizer : Noise line search optimizer class + noise_optimizer: Noise line search optimizer class A line search optimization method for the relative-noise hyperparameter. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. Returns: self: The updated object itself. """ - if get_prior_mean is not None: - self.get_prior_mean = get_prior_mean if modification is not None: self.modification = modification if ngrid is not None: @@ -113,8 +137,11 @@ def update_arguments( self.bounds = bounds.copy() if noise_optimizer is not None: self.noise_optimizer = noise_optimizer.copy() - # Always reset the solution when the objective function is changed - self.reset_solution() + # Set the arguments of the parent class + super().update_arguments( + get_prior_mean=get_prior_mean, + dtype=dtype, + ) return self def function( @@ -198,40 +225,40 @@ def derivative( pdis, **kwargs, ): - nlp_deriv = np.array([]) - D_n = D + np.exp(2.0 * noise) - prefactor2 = np.mean(UTY / D_n) - hp["prefactor"] = np.array([0.5 * np.log(prefactor2)]) - hp["noise"] = np.array([noise]) - KXX_inv = np.matmul(U / D_n, U.T) - coef = np.matmul(KXX_inv, Y_p) + nlp_deriv = empty(0, dtype=self.dtype) + D_n = D + exp(2.0 * noise) + prefactor2 = (UTY / D_n).mean() + hp["prefactor"] = asarray([0.5 * log(prefactor2)]) + hp["noise"] = asarray([noise]) + KXX_inv = matmul(U / D_n, U.T) + coef = matmul(KXX_inv, Y_p) for para in parameters_set: if para == "prefactor": - nlp_d = np.zeros((len(hp[para]))) + nlp_d = zeros((len(hp[para])), dtype=self.dtype) else: K_deriv = self.get_K_deriv(model, para, X=X, KXX=KXX) K_deriv_cho = self.get_K_inv_deriv(K_deriv, KXX_inv) nlp_d = ( (-0.5 / prefactor2) - * np.matmul(coef.T, np.matmul(K_deriv, coef)).reshape(-1) + * matmul(coef.T, matmul(K_deriv, coef)).reshape(-1) ) + (0.5 * K_deriv_cho) - nlp_deriv = np.append(nlp_deriv, nlp_d) + nlp_deriv = append(nlp_deriv, nlp_d) nlp_deriv = nlp_deriv - self.logpriors(hp, pdis, jac=True) return nlp_deriv def get_eig_fun(self, noise, hp, pdis, UTY, D, n_data, **kwargs): "Calculate log-likelihood from Eigendecomposition for a noise value." - D_n = D + np.exp(2.0 * noise) - prefactor = 0.5 * np.log(np.mean(UTY / D_n)) + D_n = D + exp(2.0 * noise) + prefactor = 0.5 * log((UTY / D_n).mean()) nlp = ( - 0.5 * n_data * (1 + np.log(2.0 * np.pi)) + 0.5 * n_data * (1 + log(2.0 * pi)) + (n_data * prefactor) - + 0.5 * np.sum(np.log(D_n)) + + 0.5 * log(D_n).sum() ) if pdis is not None: - hp["prefactor"] = np.array([prefactor]) - hp["noise"] = np.array([noise]).reshape(-1) + hp["prefactor"] = asarray([prefactor], dtype=self.dtype) + hp["noise"] = asarray([noise], dtype=self.dtype).reshape(-1) return nlp - self.logpriors(hp, pdis, jac=False) def get_all_eig_fun(self, noises, hp, pdis, UTY, D, n_data, **kwargs): @@ -239,10 +266,10 @@ def get_all_eig_fun(self, noises, hp, pdis, UTY, D, n_data, **kwargs): Calculate log-likelihood from Eigendecompositions for all noise values from the list. """ - D_n = D + np.exp(2.0 * noises) - prefactor = 0.5 * np.log(np.mean(UTY / D_n, axis=1)) - nlp = (0.5 * n_data * (1 + np.log(2.0 * np.pi))) + ( - (n_data * prefactor) + (0.5 * np.sum(np.log(D_n), axis=1)) + D_n = D + exp(2.0 * noises) + prefactor = 0.5 * log((UTY / D_n).mean(axis=1)) + nlp = (0.5 * n_data * (1 + log(2.0 * pi))) + ( + (n_data * prefactor) + (0.5 * log(D_n).sum(axis=1)) ) if pdis is not None: hp["prefactor"] = prefactor.reshape(-1, 1) @@ -252,7 +279,8 @@ def get_all_eig_fun(self, noises, hp, pdis, UTY, D, n_data, **kwargs): def make_noise_list(self, model, X, Y, **kwargs): "Make the list of noises." return self.bounds.make_single_line( - parameter="noise", ngrid=self.ngrid + parameter="noise", + ngrid=self.ngrid, ).reshape(-1, 1) def maximize_noise( @@ -274,7 +302,14 @@ def maximize_noise( func_args = (hp.copy(), pdis, UTY, D, n_data) # Calculate function values for line coordinates sol = self.noise_optimizer.run( - self, noises, ["noise"], model, X, Y, pdis, func_args=func_args + self, + noises, + ["noise"], + model, + X, + Y, + pdis, + func_args=func_args, ) # Find the minimum value return sol["x"][0], sol["fun"] @@ -305,14 +340,16 @@ def update_solution( and numerically, respectively. """ if fun < self.sol["fun"]: - D_n = D + np.exp(2.0 * noise) - prefactor2 = np.mean(UTY / D_n) + D_n = D + exp(2.0 * noise) + prefactor2 = (UTY / D_n).mean() if self.modification: if n_data - len(theta) > 0: prefactor2 = (n_data / (n_data - len(theta))) * prefactor2 - hp["prefactor"] = np.array([0.5 * np.log(prefactor2)]) - hp["noise"] = np.array([noise]) - self.sol["x"] = np.concatenate( + hp["prefactor"] = asarray( + [0.5 * log(prefactor2)], + ) + hp["noise"] = asarray([noise]) + self.sol["x"] = concatenate( [hp[para] for para in sorted(hp.keys())] ) self.sol["hp"] = hp.copy() @@ -332,6 +369,7 @@ def get_arguments(self): ngrid=self.ngrid, bounds=self.bounds, noise_optimizer=self.noise_optimizer, + dtype=self.dtype, ) # Get the constants made within the class constant_kwargs = dict() diff --git a/catlearn/regression/gp/objectivefunctions/gp/factorized_likelihood_svd.py b/catlearn/regression/gp/objectivefunctions/gp/factorized_likelihood_svd.py index fd409558..46411422 100644 --- a/catlearn/regression/gp/objectivefunctions/gp/factorized_likelihood_svd.py +++ b/catlearn/regression/gp/objectivefunctions/gp/factorized_likelihood_svd.py @@ -1,6 +1,9 @@ -import numpy as np -from .factorized_likelihood import FactorizedLogLikelihood +from numpy import matmul from numpy.linalg import svd +from .factorized_likelihood import ( + FactorizedLogLikelihood, + VariableTransformation, +) class FactorizedLogLikelihoodSVD(FactorizedLogLikelihood): @@ -9,8 +12,9 @@ def __init__( get_prior_mean=False, modification=False, ngrid=80, - bounds=None, + bounds=VariableTransformation(), noise_optimizer=None, + dtype=float, **kwargs, ): """ @@ -36,9 +40,12 @@ def __init__( bounds: Boundary_conditions class A class of the boundary conditions of the relative-noise hyperparameter. - noise_optimizer : Noise line search optimizer class + noise_optimizer: Noise line search optimizer class A line search optimization method for the relative-noise hyperparameter. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. """ super().__init__( get_prior_mean=get_prior_mean, @@ -46,6 +53,7 @@ def __init__( ngrid=ngrid, bounds=bounds, noise_optimizer=noise_optimizer, + dtype=dtype, **kwargs, ) @@ -57,5 +65,5 @@ def get_eig(self, model, X, Y): U, D, Vt = svd(KXX, hermitian=True) # Subtract the prior mean to the training target Y_p = self.y_prior(X, Y, model, D=D, U=U) - UTY = np.matmul(Vt, Y_p).reshape(-1) ** 2 + UTY = matmul(Vt, Y_p).reshape(-1) ** 2 return D, U, Y_p, UTY, KXX, n_data diff --git a/catlearn/regression/gp/objectivefunctions/gp/gpe.py b/catlearn/regression/gp/objectivefunctions/gp/gpe.py index 2cf3714a..0a5c03b2 100644 --- a/catlearn/regression/gp/objectivefunctions/gp/gpe.py +++ b/catlearn/regression/gp/objectivefunctions/gp/gpe.py @@ -1,9 +1,9 @@ -import numpy as np +from numpy import append, empty from .loo import LOO class GPE(LOO): - def __init__(self, get_prior_mean=False, **kwargs): + def __init__(self, get_prior_mean=False, dtype=float, **kwargs): """ The Geissers predictive mean square error objective function as a function of the hyperparameters. @@ -12,6 +12,9 @@ def __init__(self, get_prior_mean=False, **kwargs): get_prior_mean: bool Whether to save the parameters of the prior mean in the solution. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. """ # Set descriptor of the objective function self.use_analytic_prefactor = False @@ -19,6 +22,7 @@ def __init__(self, get_prior_mean=False, **kwargs): # Set the arguments self.update_arguments( get_prior_mean=get_prior_mean, + dtype=dtype, **kwargs, ) @@ -35,7 +39,7 @@ def function( ): hp, parameters_set = self.make_hp(theta, parameters) model = self.update_model(model, hp) - coef, L, low, Y_p, KXX, n_data = self.coef_cholesky(model, X, Y) + coef, L, low, _, KXX, n_data = self.coef_cholesky(model, X, Y) KXX_inv, K_inv_diag, coef_re, co_Kinv = self.get_co_Kinv( L, low, @@ -44,7 +48,7 @@ def function( ) K_inv_diag_rev = 1.0 / K_inv_diag prefactor2 = self.get_prefactor2(model) - gpe_v = np.mean(co_Kinv**2) + prefactor2 * np.mean(K_inv_diag_rev) + gpe_v = (co_Kinv**2).mean() + prefactor2 * K_inv_diag_rev.mean() gpe_v = gpe_v - self.logpriors(hp, pdis, jac=False) / n_data if jac: return gpe_v, self.derivative( @@ -80,22 +84,47 @@ def derivative( pdis, **kwargs, ): - gpe_deriv = np.array([]) + gpe_deriv = empty(0, dtype=self.dtype) for para in parameters_set: if para == "prefactor": - gpe_d = 2.0 * prefactor2 * np.mean(K_inv_diag_rev) + gpe_d = 2.0 * prefactor2 * K_inv_diag_rev.mean() else: K_deriv = self.get_K_deriv(model, para, X=X, KXX=KXX) r_j, s_j = self.get_r_s_derivatives(K_deriv, KXX_inv, coef_re) - gpe_d = 2 * np.mean( - (co_Kinv * K_inv_diag_rev) * (r_j + s_j * co_Kinv), axis=-1 - ) + prefactor2 * np.mean( - s_j * (K_inv_diag_rev * K_inv_diag_rev), axis=-1 + gpe_d = 2.0 * ( + (co_Kinv * K_inv_diag_rev) * (r_j + s_j * co_Kinv) + ).mean(axis=-1) + prefactor2 * ( + s_j * (K_inv_diag_rev * K_inv_diag_rev) + ).mean( + axis=-1 ) - gpe_deriv = np.append(gpe_deriv, gpe_d) + gpe_deriv = append(gpe_deriv, gpe_d) gpe_deriv = gpe_deriv - self.logpriors(hp, pdis, jac=True) / n_data return gpe_deriv + def update_arguments(self, get_prior_mean=None, dtype=None, **kwargs): + """ + Update the objective function with its arguments. + The existing arguments are used if they are not given. + + Parameters: + get_prior_mean: bool + Whether to get the parameters of the prior mean + in the solution. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. + + Returns: + self: The updated object itself. + """ + # Set the arguments of the parent class + super().update_arguments( + get_prior_mean=get_prior_mean, + dtype=dtype, + ) + return self + def update_solution( self, fun, diff --git a/catlearn/regression/gp/objectivefunctions/gp/gpp.py b/catlearn/regression/gp/objectivefunctions/gp/gpp.py index e07999ed..ddf6c3b6 100644 --- a/catlearn/regression/gp/objectivefunctions/gp/gpp.py +++ b/catlearn/regression/gp/objectivefunctions/gp/gpp.py @@ -1,9 +1,9 @@ -import numpy as np +from numpy import append, asarray, concatenate, empty, log, pi, zeros from .loo import LOO class GPP(LOO): - def __init__(self, get_prior_mean=False, **kwargs): + def __init__(self, get_prior_mean=False, dtype=float, **kwargs): """ The Geissers surrogate predictive probability objective function as a function of the hyperparameters. @@ -14,6 +14,9 @@ def __init__(self, get_prior_mean=False, **kwargs): get_prior_mean: bool Whether to save the parameters of the prior mean in the solution. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. """ # Set descriptor of the objective function self.use_analytic_prefactor = True @@ -21,6 +24,7 @@ def __init__(self, get_prior_mean=False, **kwargs): # Set the arguments self.update_arguments( get_prior_mean=get_prior_mean, + dtype=dtype, **kwargs, ) @@ -37,22 +41,17 @@ def function( ): hp, parameters_set = self.make_hp(theta, parameters) model = self.update_model(model, hp) - coef, L, low, Y_p, KXX, n_data = self.coef_cholesky(model, X, Y) + coef, L, low, _, KXX, n_data = self.coef_cholesky(model, X, Y) KXX_inv, K_inv_diag, coef_re, co_Kinv = self.get_co_Kinv( L, low, n_data, coef, ) - prefactor2 = np.mean(co_Kinv * coef_re) - prefactor = 0.5 * np.log(prefactor2) - hp["prefactor"] = np.array([prefactor]) - gpp_v = ( - 1.0 - - np.mean(np.log(K_inv_diag)) - + 2.0 * prefactor - + np.log(2.0 * np.pi) - ) + prefactor2 = (co_Kinv * coef_re).mean() + prefactor = 0.5 * log(prefactor2) + hp["prefactor"] = asarray([prefactor], dtype=self.dtype) + gpp_v = 1.0 - log(K_inv_diag).mean() + 2.0 * prefactor + log(2.0 * pi) gpp_v = gpp_v - self.logpriors(hp, pdis, jac=False) / n_data if jac: deriv = self.derivative( @@ -106,41 +105,50 @@ def derivative( pdis, **kwargs, ): - gpp_deriv = np.array([]) + gpp_deriv = empty(0, dtype=self.dtype) hp.update( - dict(prefactor=np.array([0.5 * np.log(prefactor2)]).reshape(-1)) + dict( + prefactor=asarray( + [0.5 * log(prefactor2)], + dtype=self.dtype, + ).reshape(-1) + ) ) for para in parameters_set: if para == "prefactor": - gpp_d = np.zeros((len(hp[para]))) + gpp_d = zeros((len(hp[para])), dtype=self.dtype) else: K_deriv = self.get_K_deriv(model, para, X=X, KXX=KXX) r_j, s_j = self.get_r_s_derivatives(K_deriv, KXX_inv, coef_re) gpp_d = ( - np.mean(co_Kinv * (2.0 * r_j + co_Kinv * s_j), axis=-1) + (co_Kinv * (2.0 * r_j + co_Kinv * s_j)).mean(axis=-1) / prefactor2 - ) + np.mean(s_j / K_inv_diag, axis=-1) - gpp_deriv = np.append(gpp_deriv, gpp_d) + ) + (s_j / K_inv_diag).mean(axis=-1) + gpp_deriv = append(gpp_deriv, gpp_d) gpp_deriv = gpp_deriv - self.logpriors(hp, pdis, jac=True) / n_data return gpp_deriv - def update_arguments(self, get_prior_mean=None, **kwargs): + def update_arguments(self, get_prior_mean=None, dtype=None, **kwargs): """ Update the objective function with its arguments. The existing arguments are used if they are not given. Parameters: - get_prior_mean : bool + get_prior_mean: bool Whether to get the parameters of the prior mean in the solution. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. Returns: self: The updated object itself. """ - if get_prior_mean is not None: - self.get_prior_mean = get_prior_mean - # Always reset the solution when the objective function is changed - self.reset_solution() + # Set the arguments of the parent class + super().update_arguments( + get_prior_mean=get_prior_mean, + dtype=dtype, + ) return self def update_solution( @@ -164,7 +172,7 @@ def update_solution( than the input since it is optimized analytically. """ if fun < self.sol["fun"]: - self.sol["x"] = np.concatenate( + self.sol["x"] = concatenate( [hp[para] for para in sorted(hp.keys())] ) self.sol["hp"] = hp.copy() @@ -178,7 +186,7 @@ def update_solution( def get_arguments(self): "Get the arguments of the class itself." # Get the arguments given to the class in the initialization - arg_kwargs = dict(get_prior_mean=self.get_prior_mean) + arg_kwargs = dict(get_prior_mean=self.get_prior_mean, dtype=self.dtype) # Get the constants made within the class constant_kwargs = dict() # Get the objects made within the class diff --git a/catlearn/regression/gp/objectivefunctions/gp/likelihood.py b/catlearn/regression/gp/objectivefunctions/gp/likelihood.py index dc870c36..c264ddb6 100644 --- a/catlearn/regression/gp/objectivefunctions/gp/likelihood.py +++ b/catlearn/regression/gp/objectivefunctions/gp/likelihood.py @@ -1,10 +1,9 @@ -import numpy as np -from scipy.linalg import cho_solve +from numpy import append, diagonal, empty, log, matmul, pi from ..objectivefunction import ObjectiveFuction class LogLikelihood(ObjectiveFuction): - def __init__(self, get_prior_mean=False, **kwargs): + def __init__(self, get_prior_mean=False, dtype=float, **kwargs): """ The log-likelihood objective function that is used to optimize the hyperparameters. @@ -13,8 +12,11 @@ def __init__(self, get_prior_mean=False, **kwargs): get_prior_mean: bool Whether to save the parameters of the prior mean in the solution. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. """ - super().__init__(get_prior_mean=get_prior_mean, **kwargs) + super().__init__(get_prior_mean=get_prior_mean, dtype=dtype, **kwargs) def function( self, @@ -31,11 +33,11 @@ def function( model = self.update_model(model, hp) coef, L, low, Y_p, KXX, n_data = self.coef_cholesky(model, X, Y) prefactor2 = self.get_prefactor2(model) - nlp = ( - 0.5 * np.matmul(Y_p.T, coef).item(0) / prefactor2 - + 0.5 * n_data * np.log(prefactor2) - + np.sum(np.log(np.diagonal(L))) - + 0.5 * n_data * np.log(2.0 * np.pi) + nlp = 0.5 * ( + matmul(Y_p.T, coef).item(0) / prefactor2 + + n_data * log(prefactor2) + + 2.0 * log(diagonal(L)).sum() + + n_data * log(2.0 * pi) ) nlp = nlp - self.logpriors(hp, pdis, jac=False) if jac: @@ -72,18 +74,18 @@ def derivative( pdis, **kwargs, ): - nlp_deriv = np.array([]) - KXX_inv = cho_solve((L, low), np.identity(n_data), check_finite=False) + nlp_deriv = empty(0, dtype=self.dtype) + KXX_inv = self.get_cinv(L=L, low=low, n_data=n_data) for para in parameters_set: if para == "prefactor": - nlp_d = -np.matmul(Y_p.T, coef).item(0) / prefactor2 + n_data + nlp_d = -matmul(Y_p.T, coef).item(0) / prefactor2 + n_data else: K_deriv = self.get_K_deriv(model, para, X=X, KXX=KXX) K_deriv_cho = self.get_K_inv_deriv(K_deriv, KXX_inv) nlp_d = ( (-0.5 / prefactor2) - * np.matmul(coef.T, np.matmul(K_deriv, coef)).reshape(-1) + * matmul(coef.T, matmul(K_deriv, coef)).reshape(-1) ) + (0.5 * K_deriv_cho) - nlp_deriv = np.append(nlp_deriv, nlp_d) + nlp_deriv = append(nlp_deriv, nlp_d) nlp_deriv = nlp_deriv - self.logpriors(hp, pdis, jac=True) return nlp_deriv diff --git a/catlearn/regression/gp/objectivefunctions/gp/loo.py b/catlearn/regression/gp/objectivefunctions/gp/loo.py index 88d7c6b4..c71fd2ef 100644 --- a/catlearn/regression/gp/objectivefunctions/gp/loo.py +++ b/catlearn/regression/gp/objectivefunctions/gp/loo.py @@ -1,5 +1,15 @@ -import numpy as np -from scipy.linalg import cho_solve +from numpy import ( + append, + asarray, + concatenate, + diag, + einsum, + empty, + log, + matmul, + sqrt, + zeros, +) from ..objectivefunction import ObjectiveFuction @@ -8,6 +18,7 @@ def __init__( self, get_prior_mean=False, use_analytic_prefactor=True, + dtype=float, **kwargs, ): """ @@ -20,6 +31,10 @@ def __init__( in the solution. use_analytic_prefactor: bool Whether to calculate the analytical prefactor value in the end. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. + """ # Set descriptor of the objective function self.use_optimized_noise = False @@ -27,6 +42,7 @@ def __init__( self.update_arguments( get_prior_mean=get_prior_mean, use_analytic_prefactor=use_analytic_prefactor, + dtype=dtype, **kwargs, ) @@ -43,14 +59,14 @@ def function( ): hp, parameters_set = self.make_hp(theta, parameters) model = self.update_model(model, hp) - coef, L, low, Y_p, KXX, n_data = self.coef_cholesky(model, X, Y) + coef, L, low, _, KXX, n_data = self.coef_cholesky(model, X, Y) KXX_inv, K_inv_diag, coef_re, co_Kinv = self.get_co_Kinv( L, low, n_data, coef, ) - loo_v = np.mean(co_Kinv**2) + loo_v = (co_Kinv**2).mean() loo_v = loo_v - self.logpriors(hp, pdis, jac=False) / n_data if jac: deriv = self.derivative( @@ -106,18 +122,17 @@ def derivative( pdis, **kwargs, ): - loo_deriv = np.array([]) + loo_deriv = empty(0, dtype=self.dtype) for para in parameters_set: if para == "prefactor": - loo_d = np.zeros((len(hp[para]))) + loo_d = zeros((len(hp[para])), dtype=self.dtype) else: K_deriv = self.get_K_deriv(model, para, X=X, KXX=KXX) r_j, s_j = self.get_r_s_derivatives(K_deriv, KXX_inv, coef_re) - loo_d = 2.0 * np.mean( - (co_Kinv / K_inv_diag) * (r_j + s_j * co_Kinv), - axis=-1, - ) - loo_deriv = np.append(loo_deriv, loo_d) + loo_d = 2.0 * ( + (co_Kinv / K_inv_diag) * (r_j + s_j * co_Kinv) + ).mean(axis=-1) + loo_deriv = append(loo_deriv, loo_d) loo_deriv = loo_deriv - self.logpriors(hp, pdis, jac=True) / n_data return loo_deriv @@ -125,6 +140,7 @@ def update_arguments( self, get_prior_mean=None, use_analytic_prefactor=None, + dtype=None, **kwargs, ): """ @@ -137,16 +153,20 @@ def update_arguments( in the solution. use_analytic_prefactor: bool Whether to calculate the analytical prefactor value in the end. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. Returns: self: The updated object itself. """ - if get_prior_mean is not None: - self.get_prior_mean = get_prior_mean if use_analytic_prefactor is not None: self.use_analytic_prefactor = use_analytic_prefactor - # Always reset the solution when the objective function is changed - self.reset_solution() + # Set the arguments of the parent class + super().update_arguments( + get_prior_mean=get_prior_mean, + dtype=dtype, + ) return self def update_solution( @@ -174,11 +194,11 @@ def update_solution( """ if fun < self.sol["fun"]: if self.use_analytic_prefactor: - prefactor2 = np.mean(co_Kinv * coef_re) - ( - np.mean(coef_re / np.sqrt(K_inv_diag)) ** 2 + prefactor2 = (co_Kinv * coef_re).mean() - ( + (coef_re / sqrt(K_inv_diag)).mean() ** 2 ) - hp["prefactor"] = np.array([0.5 * np.log(prefactor2)]) - self.sol["x"] = np.concatenate( + hp["prefactor"] = asarray([0.5 * log(prefactor2)]) + self.sol["x"] = concatenate( [hp[para] for para in sorted(hp.keys())] ) else: @@ -193,8 +213,8 @@ def update_solution( def get_co_Kinv(self, L, low, n_data, coef): "Get the inverse covariance matrix and diagonal products." - KXX_inv = cho_solve((L, low), np.identity(n_data), check_finite=False) - K_inv_diag = np.diag(KXX_inv) + KXX_inv = self.get_cinv(L=L, low=low, n_data=n_data) + K_inv_diag = diag(KXX_inv) coef_re = coef.reshape(-1) co_Kinv = coef_re / K_inv_diag return KXX_inv, K_inv_diag, coef_re, co_Kinv @@ -204,8 +224,8 @@ def get_r_s_derivatives(self, K_deriv, KXX_inv, coef): Get the r and s vector that are products of the inverse and derivative covariance matrix """ - r_j = np.einsum("ji,di->dj", KXX_inv, np.matmul(K_deriv, -coef)) - s_j = np.einsum("ji,dji->di", KXX_inv, np.matmul(K_deriv, KXX_inv)) + r_j = einsum("ji,di->dj", KXX_inv, matmul(K_deriv, -coef)) + s_j = einsum("ji,dji->di", KXX_inv, matmul(K_deriv, KXX_inv)) return r_j, s_j def get_arguments(self): @@ -214,6 +234,7 @@ def get_arguments(self): arg_kwargs = dict( get_prior_mean=self.get_prior_mean, use_analytic_prefactor=self.use_analytic_prefactor, + dtype=self.dtype, ) # Get the constants made within the class constant_kwargs = dict() diff --git a/catlearn/regression/gp/objectivefunctions/gp/mle.py b/catlearn/regression/gp/objectivefunctions/gp/mle.py index 9f661952..a499f7e6 100644 --- a/catlearn/regression/gp/objectivefunctions/gp/mle.py +++ b/catlearn/regression/gp/objectivefunctions/gp/mle.py @@ -1,10 +1,26 @@ -import numpy as np -from scipy.linalg import cho_solve +from numpy import ( + append, + asarray, + concatenate, + diagonal, + dot, + empty, + matmul, + log, + pi, + zeros, +) from ..objectivefunction import ObjectiveFuction class MaximumLogLikelihood(ObjectiveFuction): - def __init__(self, get_prior_mean=False, modification=False, **kwargs): + def __init__( + self, + get_prior_mean=False, + modification=False, + dtype=float, + **kwargs, + ): """ The Maximum log-likelihood objective function as a function of the hyperparameters. @@ -19,6 +35,9 @@ def __init__(self, get_prior_mean=False, modification=False, **kwargs): Whether to modify the analytical prefactor value in the end. The prefactor hyperparameter becomes larger if modification=True. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. """ # Set descriptor of the objective function self.use_analytic_prefactor = True @@ -27,6 +46,7 @@ def __init__(self, get_prior_mean=False, modification=False, **kwargs): self.update_arguments( get_prior_mean=get_prior_mean, modification=modification, + dtype=dtype, **kwargs, ) @@ -44,13 +64,13 @@ def function( hp, parameters_set = self.make_hp(theta, parameters) model = self.update_model(model, hp) coef, L, low, Y_p, KXX, n_data = self.coef_cholesky(model, X, Y) - prefactor2 = np.matmul(Y_p.T, coef).item(0) / n_data - prefactor = 0.5 * np.log(prefactor2) - hp["prefactor"] = np.array([prefactor]) + prefactor2 = dot(Y_p.reshape(-1), coef.reshape(-1)) / n_data + prefactor = 0.5 * log(prefactor2) + hp["prefactor"] = asarray([prefactor], dtype=self.dtype) nlp = ( - 0.5 * n_data * (1 + np.log(2.0 * np.pi)) + 0.5 * n_data * (1 + log(2.0 * pi)) + n_data * prefactor - + np.sum(np.log(np.diagonal(L))) + + log(diagonal(L)).sum() ) nlp = nlp - self.logpriors(hp, pdis, jac=False) if jac: @@ -105,19 +125,19 @@ def derivative( pdis, **kwargs, ): - nlp_deriv = np.array([]) - KXX_inv = cho_solve((L, low), np.identity(n_data), check_finite=False) + nlp_deriv = empty(0, dtype=self.dtype) + KXX_inv = self.get_cinv(L=L, low=low, n_data=n_data) for para in parameters_set: if para == "prefactor": - nlp_d = np.zeros((len(hp[para]))) + nlp_d = zeros((len(hp[para])), dtype=self.dtype) else: K_deriv = self.get_K_deriv(model, para, X=X, KXX=KXX) K_deriv_cho = self.get_K_inv_deriv(K_deriv, KXX_inv) nlp_d = ( (-0.5 / prefactor2) - * np.matmul(coef.T, np.matmul(K_deriv, coef)).reshape(-1) + * matmul(coef.T, matmul(K_deriv, coef)).reshape(-1) ) + (0.5 * K_deriv_cho) - nlp_deriv = np.append(nlp_deriv, nlp_d) + nlp_deriv = append(nlp_deriv, nlp_d) nlp_deriv = nlp_deriv - self.logpriors(hp, pdis, jac=True) return nlp_deriv @@ -125,6 +145,7 @@ def update_arguments( self, get_prior_mean=None, modification=None, + dtype=None, **kwargs, ): """ @@ -139,16 +160,21 @@ def update_arguments( Whether to modify the analytical prefactor value in the end. The prefactor hyperparameter becomes larger if modification=True. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. Returns: self: The updated object itself. """ - if get_prior_mean is not None: - self.get_prior_mean = get_prior_mean if modification is not None: self.modification = modification - # Always reset the solution when the objective function is changed - self.reset_solution() + # Set the arguments of the parent class + super().update_arguments( + get_prior_mean=get_prior_mean, + dtype=dtype, + **kwargs, + ) return self def update_solution( @@ -176,8 +202,8 @@ def update_solution( if self.modification: if n_data - len(theta) > 0: prefactor2 = (n_data / (n_data - len(theta))) * prefactor2 - hp["prefactor"] = np.array([0.5 * np.log(prefactor2)]) - self.sol["x"] = np.concatenate( + hp["prefactor"] = asarray([0.5 * log(prefactor2)]) + self.sol["x"] = concatenate( [hp[para] for para in sorted(hp.keys())] ) self.sol["hp"] = hp.copy() @@ -194,6 +220,7 @@ def get_arguments(self): arg_kwargs = dict( get_prior_mean=self.get_prior_mean, modification=self.modification, + dtype=self.dtype, ) # Get the constants made within the class constant_kwargs = dict() diff --git a/catlearn/regression/gp/objectivefunctions/objectivefunction.py b/catlearn/regression/gp/objectivefunctions/objectivefunction.py index d1c12c80..f44eee20 100644 --- a/catlearn/regression/gp/objectivefunctions/objectivefunction.py +++ b/catlearn/regression/gp/objectivefunctions/objectivefunction.py @@ -1,23 +1,45 @@ -import numpy as np -from scipy.linalg import cho_factor, cho_solve -from numpy.linalg import eigh +from numpy import ( + array, + asarray, + append, + diag, + einsum, + empty, + finfo, + identity, + inf, + log, + matmul, + where, + zeros, +) +from numpy.linalg import eigh, LinAlgError +from scipy.linalg import cho_factor, cho_solve, eigh as scipy_eigh +import logging class ObjectiveFuction: - def __init__(self, get_prior_mean=False, **kwargs): + def __init__(self, get_prior_mean=False, dtype=float, **kwargs): """ The objective function that is used to optimize the hyperparameters. Parameters: - get_prior_mean : bool + get_prior_mean: bool Whether to get the parameters of the prior mean in the solution. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. """ # Set descriptor of the objective function self.use_analytic_prefactor = False self.use_optimized_noise = False # Set the arguments - self.update_arguments(get_prior_mean=get_prior_mean, **kwargs) + self.update_arguments( + get_prior_mean=get_prior_mean, + dtype=dtype, + **kwargs, + ) def function( self, @@ -67,19 +89,56 @@ def derivative(self, **kwargs): """ raise NotImplementedError() - def update_arguments(self, get_prior_mean=None, **kwargs): + def set_dtype(self, dtype, **kwargs): + """ + Set the data type of the arrays. + + Parameters: + dtype: type + The data type of the arrays. + + Returns: + self: The updated object itself. + """ + # Set the data type + self.dtype = dtype + return self + + def set_seed(self, seed, **kwargs): + """ + Set the random seed. + + Parameters: + seed: int (optional) + The random seed. + The seed can be an integer, RandomState, or Generator instance. + If not given, the default random number generator is used. + + Returns: + self: The instance itself. + """ + return self + + def update_arguments(self, get_prior_mean=None, dtype=None, **kwargs): """ Update the objective function with its arguments. The existing arguments are used if they are not given. Parameters: - get_prior_mean : bool + get_prior_mean: bool Whether to get the parameters of the prior mean in the solution. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. Returns: self: The updated object itself. """ + # Set the data type + if dtype is not None or not hasattr(self, "dtype"): + self.set_dtype(dtype=dtype) + # Set the get_prior_mean if get_prior_mean is not None: self.get_prior_mean = get_prior_mean # Always reset the solution when the objective function is changed @@ -91,7 +150,7 @@ def reset_solution(self): Reset the solution of the optimization in terms of the hyperparameters and model. """ - self.sol = {"fun": np.inf, "x": np.array([]), "hp": {}} + self.sol = {"fun": inf, "x": empty(0, dtype=self.dtype), "hp": {}} return self def update_solution( @@ -149,8 +208,9 @@ def get_stored_solution(self, **kwargs): def make_hp(self, theta, parameters, **kwargs): "Make hyperparameter dictionary from lists" - theta, parameters = np.array(theta), np.array(parameters) + theta = asarray(theta) parameters_set = sorted(set(parameters)) + parameters = asarray(parameters) hp = { para_s: self.numeric_limits(theta[parameters == para_s]) for para_s in parameters_set @@ -161,12 +221,14 @@ def get_hyperparams(self, model, **kwargs): "Get the hyperparameters for the model and the kernel." return model.get_hyperparams() - def numeric_limits(self, array, dh=0.1 * np.log(np.finfo(float).max)): + def numeric_limits(self, a, dh=None): """ Replace hyperparameters if they are outside of the numeric limits in log-space. """ - return np.where(-dh < array, np.where(array < dh, array, dh), -dh) + if dh is None: + dh = 0.1 * log(finfo(self.dtype).max) + return where(-dh < a, where(a < dh, a, dh), -dh) def update_model(self, model, hp, **kwargs): "Update the the machine learning model with the hyperparameters." @@ -189,24 +251,25 @@ def kxx_corr(self, model, X, **kwargs): def add_correction(self, model, KXX, n_data, **kwargs): "Add noise correction to covariance matrix." - corr = model.get_correction(np.diag(KXX)) + corr = model.get_correction(diag(KXX)) if corr > 0.0: KXX[range(n_data), range(n_data)] += corr return KXX def y_prior(self, X, Y, model, L=None, low=None, **kwargs): "Update prior and subtract to target." - Y_p = Y.copy() + Y_p = array(Y, dtype=self.dtype) model.update_priormean(X, Y_p, L=L, low=low, **kwargs) - get_derivatives = model.use_derivatives + get_derivatives = model.get_use_derivatives() pmean = model.get_priormean( X, Y_p, get_derivatives=get_derivatives, ) + Y_p -= pmean if get_derivatives: - return (Y_p - pmean).T.reshape(-1, 1) - return (Y_p - pmean)[:, 0:1] + return Y_p.T.reshape(-1, 1) + return Y_p[:, 0:1] def coef_cholesky(self, model, X, Y, **kwargs): "Calculate the coefficients by using Cholesky decomposition." @@ -227,24 +290,38 @@ def get_eig(self, model, X, Y, **kwargs): # Eigendecomposition try: D, U = eigh(KXX) - except Exception as e: - import logging - import scipy.linalg - + except LinAlgError as e: logging.error("An error occurred: %s", str(e)) # More robust but slower eigendecomposition - D, U = scipy.linalg.eigh(KXX, driver="ev") + D, U = scipy_eigh(KXX, driver="ev") # Subtract the prior mean to the training target Y_p = self.y_prior(X, Y, model, D=D, U=U) - UTY = (np.matmul(U.T, Y_p)).reshape(-1) ** 2 + UTY = matmul(U.T, Y_p).reshape(-1) ** 2 return D, U, Y_p, UTY, KXX, n_data - def get_cinv(self, model, X, Y, **kwargs): - "Get the inverse covariance matrix." + def get_cinv_model(self, model, X, Y, check_finite=False, **kwargs): + "Get the inverse covariance matrix from the model." coef, L, low, Y_p, KXX, n_data = self.coef_cholesky(model, X, Y) - cinv = cho_solve((L, low), np.identity(n_data), check_finite=False) + cinv = self.get_cinv( + L, + low, + n_data, + check_finite=check_finite, + **kwargs, + ) return coef, cinv, Y_p, KXX, n_data + def get_cinv(self, L, low, n_data, check_finite=False, **kwargs): + "Get the inverse covariance matrix." + return cho_solve( + (L, low), + identity( + n_data, + dtype=self.dtype, + ), + check_finite=check_finite, + ) + def logpriors(self, hp, pdis=None, jac=False, **kwargs): "Log of the prior distribution value for the hyperparameters." # If no prior distribution is used for the hyperparameters @@ -260,15 +337,21 @@ def logpriors(self, hp, pdis=None, jac=False, **kwargs): return lprior return lprior.reshape(-1) # Derivate of the log probability wrt. the hyperparameters - lprior_deriv = np.array([]) + lprior_deriv = empty(0, dtype=self.dtype) for para, value in hp.items(): if para in pdis.keys(): - lprior_deriv = np.append( + lprior_deriv = append( lprior_deriv, - np.array(pdis[para].ln_deriv(value)).reshape(-1), + asarray( + pdis[para].ln_deriv(value), + dtype=self.dtype, + ).reshape(-1), ) else: - lprior_deriv = np.append(lprior_deriv, np.zeros((len(value)))) + lprior_deriv = append( + lprior_deriv, + zeros((len(value)), dtype=self.dtype), + ) return lprior_deriv def get_K_inv_deriv(self, K_deriv, KXX_inv, **kwargs): @@ -276,7 +359,7 @@ def get_K_inv_deriv(self, K_deriv, KXX_inv, **kwargs): Get the diagonal elements of the matrix product of the inverse and derivative covariance matrix. """ - return np.einsum("ij,dji->d", KXX_inv, K_deriv) + return einsum("ij,dji->d", KXX_inv, K_deriv) def get_K_deriv(self, model, parameter, X, KXX, **kwargs): "Get the gradient of the covariance matrix wrt. the hyperparameter." @@ -294,7 +377,7 @@ def get_prior_parameters(self, model, **kwargs): def get_arguments(self): "Get the arguments of the class itself." # Get the arguments given to the class in the initialization - arg_kwargs = dict(get_prior_mean=self.get_prior_mean) + arg_kwargs = dict(get_prior_mean=self.get_prior_mean, dtype=self.dtype) # Get the constants made within the class constant_kwargs = dict() # Get the objects made within the class diff --git a/catlearn/regression/gp/objectivefunctions/tp/factorized_likelihood.py b/catlearn/regression/gp/objectivefunctions/tp/factorized_likelihood.py index 436958ab..80b67ec2 100644 --- a/catlearn/regression/gp/objectivefunctions/tp/factorized_likelihood.py +++ b/catlearn/regression/gp/objectivefunctions/tp/factorized_likelihood.py @@ -1,5 +1,17 @@ -import numpy as np -from ..gp.factorized_likelihood import FactorizedLogLikelihood +from numpy import ( + append, + asarray, + concatenate, + empty, + exp, + log, + matmul, +) +from ..gp.factorized_likelihood import ( + FactorizedLogLikelihood, + VariableTransformation, +) +from ...optimizers.noisesearcher import NoiseFineGridSearch class FactorizedLogLikelihood(FactorizedLogLikelihood): @@ -7,8 +19,9 @@ def __init__( self, get_prior_mean=False, ngrid=80, - bounds=None, + bounds=VariableTransformation(), noise_optimizer=None, + dtype=float, **kwargs, ): """ @@ -28,22 +41,18 @@ def __init__( bounds: Boundary_conditions class A class of the boundary conditions of the relative-noise hyperparameter. - noise_optimizer : Noise line search optimizer class + noise_optimizer: Noise line search optimizer class A line search optimization method for the relative-noise hyperparameter. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. """ # Set descriptor of the objective function self.use_analytic_prefactor = False self.use_optimized_noise = True - # Set default bounds - if bounds is None: - from ...hpboundary.hptrans import VariableTransformation - - bounds = VariableTransformation(bounds=None) # Set default noise line optimizer if noise_optimizer is None: - from ...optimizers.noisesearcher import NoiseFineGridSearch - noise_optimizer = NoiseFineGridSearch( maxiter=1000, tol=1e-5, @@ -58,6 +67,7 @@ def __init__( ngrid=ngrid, bounds=bounds, noise_optimizer=noise_optimizer, + dtype=dtype, **kwargs, ) @@ -131,33 +141,33 @@ def derivative( n_data, **kwargs, ): - nlp_deriv = np.array([]) - D_n = D + np.exp(2 * noise) - hp["noise"] = np.array([noise]) - KXX_inv = np.matmul(U / D_n, U.T) - coef = np.matmul(KXX_inv, Y_p) + nlp_deriv = empty(0, dtype=self.dtype) + D_n = D + exp(2.0 * noise) + hp["noise"] = asarray([noise]) + KXX_inv = matmul(U / D_n, U.T) + coef = matmul(KXX_inv, Y_p) a, b = self.get_hyperprior_parameters(model) - ycoef = 1.0 + np.sum(UTY / D_n) / (2.0 * b) + ycoef = 1.0 + (UTY / D_n).sum() / (2.0 * b) for para in parameters_set: K_deriv = self.get_K_deriv(model, para, X=X, KXX=KXX) K_deriv_cho = self.get_K_inv_deriv(K_deriv, KXX_inv) nlp_d = ( (-0.5 / ycoef) * ((a + 0.5 * n_data) / b) - * np.matmul(coef.T, np.matmul(K_deriv, coef)).reshape(-1) + * matmul(coef.T, matmul(K_deriv, coef)).reshape(-1) ) + 0.5 * K_deriv_cho - nlp_deriv = np.append(nlp_deriv, nlp_d) + nlp_deriv = append(nlp_deriv, nlp_d) nlp_deriv = nlp_deriv - self.logpriors(hp, pdis, jac=True) return nlp_deriv def get_eig_fun(self, noise, hp, pdis, UTY, D, n_data, a, b, **kwargs): "Calculate log-likelihood from Eigendecomposition for a noise value." - D_n = D + np.exp(2.0 * noise) - nlp = 0.5 * np.sum(np.log(D_n)) + 0.5 * (2.0 * a + n_data) * np.log( - 1.0 + np.sum(UTY / D_n) / (2.0 * b) + D_n = D + exp(2.0 * noise) + nlp = 0.5 * log(D_n).sum() + 0.5 * (2.0 * a + n_data) * log( + 1.0 + (UTY / D_n).sum() / (2.0 * b) ) if pdis is not None: - hp["noise"] = np.array([noise]).reshape(-1) + hp["noise"] = asarray([noise]).reshape(-1) return nlp - self.logpriors(hp, pdis, jac=False) def get_all_eig_fun( @@ -176,10 +186,10 @@ def get_all_eig_fun( Calculate log-likelihood from Eigendecompositions for all noise values from the list. """ - D_n = D + np.exp(2.0 * noises) - nlp = 0.5 * np.sum(np.log(D_n), axis=1) + 0.5 * ( - 2.0 * a + n_data - ) * np.log(1.0 + np.sum(UTY / D_n, axis=1) / (2.0 * b)) + D_n = D + exp(2.0 * noises) + nlp = 0.5 * log(D_n).sum(axis=1) + 0.5 * (2.0 * a + n_data) * log( + 1.0 + (UTY / D_n).sum(axis=1) / (2.0 * b) + ) if pdis is not None: hp["noise"] = noises return nlp - self.logpriors(hp, pdis, jac=False) @@ -243,8 +253,8 @@ def update_solution( than the input since they are optimized numerically. """ if fun < self.sol["fun"]: - hp["noise"] = np.array([noise]) - self.sol["x"] = np.concatenate( + hp["noise"] = asarray([noise]) + self.sol["x"] = concatenate( [hp[para] for para in sorted(hp.keys())] ) self.sol["hp"] = hp.copy() @@ -263,6 +273,7 @@ def get_arguments(self): ngrid=self.ngrid, bounds=self.bounds, noise_optimizer=self.noise_optimizer, + dtype=self.dtype, ) # Get the constants made within the class constant_kwargs = dict() diff --git a/catlearn/regression/gp/objectivefunctions/tp/factorized_likelihood_svd.py b/catlearn/regression/gp/objectivefunctions/tp/factorized_likelihood_svd.py index 8e986e22..760b5120 100644 --- a/catlearn/regression/gp/objectivefunctions/tp/factorized_likelihood_svd.py +++ b/catlearn/regression/gp/objectivefunctions/tp/factorized_likelihood_svd.py @@ -1,6 +1,9 @@ -import numpy as np -from .factorized_likelihood import FactorizedLogLikelihood +from numpy import matmul from numpy.linalg import svd +from .factorized_likelihood import ( + FactorizedLogLikelihood, + VariableTransformation, +) class FactorizedLogLikelihoodSVD(FactorizedLogLikelihood): @@ -8,8 +11,9 @@ def __init__( self, get_prior_mean=False, ngrid=80, - bounds=None, + bounds=VariableTransformation(), noise_optimizer=None, + dtype=float, **kwargs ): """ @@ -29,15 +33,19 @@ def __init__( bounds: Boundary_conditions class A class of the boundary conditions of the relative-noise hyperparameter. - noise_optimizer : Noise line search optimizer class + noise_optimizer: Noise line search optimizer class A line search optimization method for the relative-noise hyperparameter. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. """ super().__init__( get_prior_mean=get_prior_mean, ngrid=ngrid, bounds=bounds, noise_optimizer=noise_optimizer, + dtype=dtype, **kwargs ) @@ -49,5 +57,5 @@ def get_eig(self, model, X, Y): U, D, Vt = svd(KXX, hermitian=True) # Subtract the prior mean to the training target Y_p = self.y_prior(X, Y, model, D=D, U=U) - UTY = np.matmul(Vt, Y_p).reshape(-1) ** 2 + UTY = matmul(Vt, Y_p).reshape(-1) ** 2 return D, U, Y_p, UTY, KXX, n_data diff --git a/catlearn/regression/gp/objectivefunctions/tp/likelihood.py b/catlearn/regression/gp/objectivefunctions/tp/likelihood.py index c6a7d067..8f04afa3 100644 --- a/catlearn/regression/gp/objectivefunctions/tp/likelihood.py +++ b/catlearn/regression/gp/objectivefunctions/tp/likelihood.py @@ -1,10 +1,15 @@ -import numpy as np -from scipy.linalg import cho_solve +from numpy import ( + append, + empty, + diagonal, + log, + matmul, +) from ..objectivefunction import ObjectiveFuction class LogLikelihood(ObjectiveFuction): - def __init__(self, get_prior_mean=False, **kwargs): + def __init__(self, get_prior_mean=False, dtype=float, **kwargs): """ The log-likelihood objective function that is used to optimize the hyperparameters. @@ -13,11 +18,11 @@ def __init__(self, get_prior_mean=False, **kwargs): get_prior_mean: bool Whether to save the parameters of the prior mean in the solution. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. """ - super().__init__(get_prior_mean=get_prior_mean, **kwargs) - # Set descriptor of the objective function - self.use_analytic_prefactor = False - self.use_optimized_noise = False + super().__init__(get_prior_mean=get_prior_mean, dtype=dtype, **kwargs) def function( self, @@ -34,10 +39,8 @@ def function( model = self.update_model(model, hp) a, b = self.get_hyperprior_parameters(model) coef, L, low, Y_p, KXX, n_data = self.coef_cholesky(model, X, Y) - ycoef = 1.0 + (np.matmul(Y_p.T, coef).item(0) / (2.0 * b)) - nlp = np.sum(np.log(np.diagonal(L))) + 0.5 * ( - 2.0 * a + n_data - ) * np.log(ycoef) + ycoef = 1.0 + (matmul(Y_p.T, coef).item(0) / (2.0 * b)) + nlp = log(diagonal(L)).sum() + 0.5 * (2.0 * a + n_data) * log(ycoef) nlp = nlp - self.logpriors(hp, pdis, jac=False) if jac: return nlp, self.derivative( @@ -77,17 +80,17 @@ def derivative( pdis, **kwargs, ): - nlp_deriv = np.array([]) - KXX_inv = cho_solve((L, low), np.identity(n_data), check_finite=False) + nlp_deriv = empty(0, dtype=self.dtype) + KXX_inv = self.get_cinv(L=L, low=low, n_data=n_data) for para in parameters_set: K_deriv = self.get_K_deriv(model, para, X=X, KXX=KXX) K_deriv_cho = self.get_K_inv_deriv(K_deriv, KXX_inv) nlp_d = ( (-0.5 / ycoef) * ((a + 0.5 * n_data) / b) - * np.matmul(coef.T, np.matmul(K_deriv, coef)).reshape(-1) + * matmul(coef.T, matmul(K_deriv, coef)).reshape(-1) ) + 0.5 * K_deriv_cho - nlp_deriv = np.append(nlp_deriv, nlp_d) + nlp_deriv = append(nlp_deriv, nlp_d) nlp_deriv = nlp_deriv - self.logpriors(hp, pdis, jac=True) return nlp_deriv diff --git a/catlearn/regression/gp/optimizers/__init__.py b/catlearn/regression/gp/optimizers/__init__.py index 351792f3..b6f4f287 100644 --- a/catlearn/regression/gp/optimizers/__init__.py +++ b/catlearn/regression/gp/optimizers/__init__.py @@ -4,6 +4,7 @@ RandomSamplingOptimizer, GridOptimizer, IterativeLineOptimizer, + ScipyGlobalOptimizer, BasinOptimizer, AnneallingOptimizer, AnneallingTransOptimizer, @@ -35,6 +36,7 @@ "RandomSamplingOptimizer", "GridOptimizer", "IterativeLineOptimizer", + "ScipyGlobalOptimizer", "BasinOptimizer", "AnneallingOptimizer", "AnneallingTransOptimizer", diff --git a/catlearn/regression/gp/optimizers/globaloptimizer.py b/catlearn/regression/gp/optimizers/globaloptimizer.py index 90f9e16e..36351d2b 100644 --- a/catlearn/regression/gp/optimizers/globaloptimizer.py +++ b/catlearn/regression/gp/optimizers/globaloptimizer.py @@ -1,13 +1,35 @@ +from numpy import ( + append, + array, + asarray, + concatenate, + nanargmin, + ndarray, + sort, + sum as sum_, + tile, + unique, + where, +) +from scipy import __version__ as scipy_version +from scipy.optimize import basinhopping, dual_annealing +from ase.parallel import world from .optimizer import Optimizer -import numpy as np +from .linesearcher import GoldenSearch +from .localoptimizer import ScipyOptimizer +from ..hpboundary import EducatedBoundaries, VariableTransformation class GlobalOptimizer(Optimizer): def __init__( self, local_optimizer=None, - bounds=None, + bounds=VariableTransformation(), maxiter=5000, + jac=False, + parallel=False, + seed=None, + dtype=float, **kwargs, ): """ @@ -17,27 +39,31 @@ def __init__( boundary conditions of the hyperparameters. Parameters: - local_optimizer : Local optimizer class + local_optimizer: Local optimizer class A local optimization method. - bounds : HPBoundaries class + bounds: HPBoundaries class A class of the boundary conditions of the hyperparameters. - maxiter : int + maxiter: int The maximum number of evaluations or iterations the global optimizer can use. + jac: bool + Whether to use the gradient of the objective function + wrt. the hyperparameters. + This is not implemented for this method. + parallel: bool + Whether to calculate the grid points in parallel + over multiple CPUs. + This is not implemented for this method. + seed: int (optional) + The random seed. + The seed can be an integer, RandomState, or Generator instance. + If not given, the default random number generator is used. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. """ - # This global optimizer can not be parallelized - self.parallel = False - # The gradients of the function are unused by the global optimizer - self.jac = False - # Set default bounds - if bounds is None: - from ..hpboundary.hptrans import VariableTransformation - - bounds = VariableTransformation(bounds=None) # Set default local optimizer if local_optimizer is None: - from .localoptimizer import ScipyOptimizer - local_optimizer = ScipyOptimizer( maxiter=maxiter, bounds=bounds, @@ -48,17 +74,66 @@ def __init__( local_optimizer=local_optimizer, bounds=bounds, maxiter=maxiter, + jac=jac, + parallel=parallel, + seed=seed, + dtype=dtype, **kwargs, ) - def run(self, func, theta, parameters, model, X, Y, pdis, **kwargs): - raise NotImplementedError() + def set_dtype(self, dtype, **kwargs): + # Set the dtype for the global optimizer + super().set_dtype(dtype=dtype, **kwargs) + # Set the data type of the bounds + if self.bounds is not None and hasattr(self.bounds, "set_dtype"): + self.bounds.set_dtype(dtype=dtype, **kwargs) + # Set the dtype for the local optimizer + if self.local_optimizer is not None and hasattr( + self.local_optimizer, "set_dtype" + ): + self.local_optimizer.set_dtype(dtype=dtype) + return self + + def set_seed(self, seed=None, **kwargs): + # Set the seed for the global optimizer + super().set_seed(seed=seed, **kwargs) + # Set the random seed of the bounds + if self.bounds is not None and hasattr(self.bounds, "set_seed"): + self.bounds.set_seed(seed=seed, **kwargs) + # Set the seed for the local optimizer + if self.local_optimizer is not None and hasattr( + self.local_optimizer, + "set_seed", + ): + self.local_optimizer.set_seed(seed=seed, **kwargs) + return self + + def set_maxiter(self, maxiter, **kwargs): + super().set_maxiter(maxiter, **kwargs) + # Set the maxiter for the local optimizer + if self.local_optimizer is not None: + self.local_optimizer.update_arguments(maxiter=maxiter) + return self + + def set_jac(self, jac=True, **kwargs): + # The gradients of the function are unused by the global optimizer + self.jac = False + return self + + def set_parallel(self, parallel=False, **kwargs): + # This global optimizer can not be parallelized + self.parallel = False + return self def update_arguments( self, local_optimizer=None, bounds=None, maxiter=None, + jac=None, + parallel=None, + seed=None, + dtype=None, **kwargs, ): """ @@ -66,25 +141,52 @@ def update_arguments( The existing arguments are used if they are not given. Parameters: - local_optimizer : Local optimizer class + local_optimizer: Local optimizer class A local optimization method. - bounds : HPBoundaries class + bounds: HPBoundaries class A class of the boundary conditions of the hyperparameters. - maxiter : int + maxiter: int The maximum number of evaluations or iterations the global optimizer can use. + jac: bool + Whether to use the gradient of the objective function + wrt. the hyperparameters. + This is not implemented for this method. + parallel: bool + Whether to calculate the grid points in parallel + over multiple CPUs. + This is not implemented for this method. + seed: int (optional) + The random seed. + The seed can be an integer, RandomState, or Generator instance. + If not given, the default random number generator is used. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. Returns: self: The updated object itself. """ + # Set the local optimizer if local_optimizer is not None: self.local_optimizer = local_optimizer.copy() + elif not hasattr(self, "local_optimizer"): + self.local_optimizer = None + # Set the bounds if bounds is not None: self.bounds = bounds.copy() # Use the same boundary conditions in the local optimizer self.local_optimizer.update_arguments(bounds=self.bounds) - if maxiter is not None: - self.maxiter = int(maxiter) + elif not hasattr(self, "bounds"): + self.bounds = None + # Set the arguments for the parent class + super().update_arguments( + maxiter=maxiter, + jac=jac, + parallel=parallel, + seed=seed, + dtype=dtype, + ) return self def run_local_opt( @@ -118,11 +220,11 @@ def make_lines(self, parameters, ngrid, **kwargs): **kwargs, ) - def make_bounds(self, parameters, array=True, **kwargs): + def make_bounds(self, parameters, use_array=True, **kwargs): "Make the boundary conditions of the hyperparameters." return self.bounds.get_bounds( parameters=parameters, - array=array, + use_array=use_array, **kwargs, ) @@ -148,6 +250,10 @@ def get_arguments(self): local_optimizer=self.local_optimizer, bounds=self.bounds, maxiter=self.maxiter, + jac=self.jac, + parallel=self.parallel, + seed=self.seed, + dtype=self.dtype, ) # Get the constants made within the class constant_kwargs = dict() @@ -160,10 +266,13 @@ class RandomSamplingOptimizer(GlobalOptimizer): def __init__( self, local_optimizer=None, - bounds=None, + bounds=VariableTransformation(), maxiter=5000, - npoints=40, + jac=False, parallel=False, + npoints=40, + seed=None, + dtype=float, **kwargs, ): """ @@ -174,31 +283,33 @@ def __init__( and optimize all samples with the local optimizer. Parameters: - local_optimizer : Local optimizer class + local_optimizer: Local optimizer class A local optimization method. - bounds : HPBoundaries class + bounds: HPBoundaries class A class of the boundary conditions of the hyperparameters. - maxiter : int + maxiter: int The maximum number of evaluations or iterations the global optimizer can use. - npoints : int - The number of hyperparameter points samled from - the boundary conditions. - parallel : bool + jac: bool + Whether to use the gradient of the objective function + wrt. the hyperparameters. + This is not implemented for this method. + parallel: bool Whether to calculate the grid points in parallel over multiple CPUs. + npoints: int + The number of hyperparameter points samled from + the boundary conditions. + seed: int (optional) + The random seed. + The seed can be an integer, RandomState, or Generator instance. + If not given, the default random number generator is used. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. """ - # The gradients of the function are unused by the global optimizer - self.jac = False - # Set default bounds - if bounds is None: - from ..hpboundary.hptrans import VariableTransformation - - bounds = VariableTransformation(bounds=None) # Set default local optimizer if local_optimizer is None: - from .localoptimizer import ScipyOptimizer - local_optimizer = ScipyOptimizer( maxiter=int(maxiter / npoints), bounds=bounds, @@ -209,20 +320,23 @@ def __init__( local_optimizer=local_optimizer, bounds=bounds, maxiter=maxiter, - npoints=npoints, + jac=jac, parallel=parallel, + npoints=npoints, + seed=seed, + dtype=dtype, **kwargs, ) def run(self, func, theta, parameters, model, X, Y, pdis, **kwargs): # Draw random hyperparameter samples - thetas = np.array([theta]) + thetas = array([theta], dtype=self.dtype) if self.npoints > 1: thetas = self.sample_thetas( parameters, npoints=int(self.npoints - 1), ) - thetas = np.append(thetas, thetas, axis=0) + thetas = append(thetas, thetas, axis=0) # Make empty solution and lists sol = self.get_empty_solution() # Perform the local optimization for random samples @@ -239,13 +353,20 @@ def run(self, func, theta, parameters, model, X, Y, pdis, **kwargs): ) return sol + def set_parallel(self, parallel=False, **kwargs): + self.parallel = parallel + return self + def update_arguments( self, local_optimizer=None, bounds=None, maxiter=None, - npoints=None, + jac=None, parallel=None, + npoints=None, + seed=None, + dtype=None, **kwargs, ): """ @@ -253,37 +374,47 @@ def update_arguments( The existing arguments are used if they are not given. Parameters: - local_optimizer : Local optimizer class + local_optimizer: Local optimizer class A local optimization method. - bounds : HPBoundaries class + bounds: HPBoundaries class A class of the boundary conditions of the hyperparameters. - maxiter : int + maxiter: int The maximum number of evaluations or iterations the global optimizer can use. - npoints : int - The number of hyperparameter points samled from - the boundary conditions. - parallel : bool + jac: bool + Whether to use the gradient of the objective function + wrt. the hyperparameters. + This is not implemented for this method. + parallel: bool Whether to calculate the grid points in parallel over multiple CPUs. + npoints: int + The number of hyperparameter points samled from + the boundary conditions. + seed: int (optional) + The random seed. + The seed can be an integer, RandomState, or Generator instance. + If not given, the default random number generator is used. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. Returns: self: The updated object itself. """ - if local_optimizer is not None: - self.local_optimizer = local_optimizer.copy() - if bounds is not None: - self.bounds = bounds.copy() - # Use the same boundary conditions in the local optimizer - self.local_optimizer.update_arguments(bounds=self.bounds) - if maxiter is not None: - self.maxiter = int(maxiter) - if parallel is not None: - self.parallel = parallel + # Set the arguments for the parent class + super().update_arguments( + local_optimizer=local_optimizer, + bounds=bounds, + maxiter=maxiter, + jac=jac, + parallel=parallel, + seed=seed, + dtype=dtype, + ) + # Set the number of points if npoints is not None: if self.parallel: - from ase.parallel import world - self.npoints = self.get_optimal_npoints(npoints, world.size) else: self.npoints = int(npoints) @@ -358,8 +489,6 @@ def optimize_samples_parallel( **kwargs, ): "Perform the local optimization of the random samples in parallel." - from ase.parallel import world - rank, size = world.rank, world.size for t, theta in enumerate(thetas): if rank == t % size: @@ -399,7 +528,11 @@ def get_arguments(self): local_optimizer=self.local_optimizer, bounds=self.bounds, maxiter=self.maxiter, + jac=self.jac, + parallel=self.parallel, npoints=self.npoints, + seed=self.seed, + dtype=self.dtype, ) # Get the constants made within the class constant_kwargs = dict() @@ -412,11 +545,14 @@ class GridOptimizer(GlobalOptimizer): def __init__( self, local_optimizer=None, - bounds=None, + bounds=VariableTransformation(), maxiter=5000, + jac=False, + parallel=False, n_each_dim=None, optimize=True, - parallel=False, + seed=None, + dtype=float, **kwargs, ): """ @@ -428,49 +564,52 @@ def __init__( with the local optimizer. Parameters: - local_optimizer : Local optimizer class + local_optimizer: Local optimizer class A local optimization method. - bounds : HPBoundaries class + bounds: HPBoundaries class A class of the boundary conditions of the hyperparameters. - maxiter : int + maxiter: int The maximum number of evaluations or iterations the global optimizer can use. - n_each_dim : int or (H) list + jac: bool + Whether to use the gradient of the objective function + wrt. the hyperparameters. + This is not implemented for this method. + parallel: bool + Whether to calculate the grid points in parallel + over multiple CPUs. + n_each_dim: int or (H) list An integer or a list with number of grid points in each dimension of the hyperparameters. - optimize : bool + optimize: bool Whether to perform a local optimization on the best found solution. - parallel : bool - Whether to calculate the grid points in parallel - over multiple CPUs. + seed: int (optional) + The random seed. + The seed can be an integer, RandomState, or Generator instance. + If not given, the default random number generator is used. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. """ - # The gradients of the function are unused by the global optimizer - self.jac = False - # Set default bounds - if bounds is None: - from ..hpboundary.hptrans import VariableTransformation - - bounds = VariableTransformation(bounds=None) # Set default local optimizer if local_optimizer is None: - from .localoptimizer import ScipyOptimizer - local_optimizer = ScipyOptimizer( maxiter=maxiter, bounds=bounds, use_bounds=False, ) - # Set n_each_dim as default - self.n_each_dim = None # Set all the arguments self.update_arguments( local_optimizer=local_optimizer, bounds=bounds, maxiter=maxiter, + jac=jac, + parallel=parallel, n_each_dim=n_each_dim, optimize=optimize, - parallel=parallel, + seed=seed, + dtype=dtype, **kwargs, ) @@ -479,7 +618,7 @@ def run(self, func, theta, parameters, model, X, Y, pdis, **kwargs): n_each_dim = self.get_n_each_dim(len(theta)) # Make grid either with the same or different numbers in each dimension lines = self.make_lines(parameters, ngrid=n_each_dim) - thetas = np.append( + thetas = append( [theta], self.make_grid(lines, maxiter=int(self.maxiter - 1)), axis=0, @@ -516,14 +655,21 @@ def run(self, func, theta, parameters, model, X, Y, pdis, **kwargs): ) return sol + def set_parallel(self, parallel=False, **kwargs): + self.parallel = parallel + return self + def update_arguments( self, local_optimizer=None, bounds=None, maxiter=None, + jac=None, + parallel=None, n_each_dim=None, optimize=None, - parallel=None, + seed=None, + dtype=None, **kwargs, ): """ @@ -531,43 +677,56 @@ def update_arguments( The existing arguments are used if they are not given. Parameters: - local_optimizer : Local optimizer class + local_optimizer: Local optimizer class A local optimization method. - bounds : HPBoundaries class + bounds: HPBoundaries class A class of the boundary conditions of the hyperparameters. - maxiter : int + maxiter: int The maximum number of evaluations or iterations the global optimizer can use. - n_each_dim : int or (H) list + jac: bool + Whether to use the gradient of the objective function + wrt. the hyperparameters. + This is not implemented for this method. + parallel: bool + Whether to calculate the grid points in parallel + over multiple CPUs. + n_each_dim: int or (H) list An integer or a list with number of grid points in each dimension of the hyperparameters. - optimize : bool + optimize: bool Whether to perform a local optimization on the best found solution. - parallel : bool - Whether to calculate the grid points in parallel - over multiple CPUs. + seed: int (optional) + The random seed. + The seed can be an integer, RandomState, or Generator instance. + If not given, the default random number generator is used. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. Returns: self: The updated object itself. """ - if local_optimizer is not None: - self.local_optimizer = local_optimizer.copy() - if bounds is not None: - self.bounds = bounds.copy() - # Use the same boundary conditions in the local optimizer - self.local_optimizer.update_arguments(bounds=self.bounds) - if maxiter is not None: - self.maxiter = int(maxiter) - if parallel is not None: - self.parallel = parallel if n_each_dim is not None: - if isinstance(n_each_dim, (list, np.ndarray)): + if isinstance(n_each_dim, (list, ndarray)): self.n_each_dim = n_each_dim.copy() else: self.n_each_dim = n_each_dim + elif not hasattr(self, "n_each_dim"): + self.n_each_dim = None if optimize is not None: self.optimize = optimize + # Set the arguments for the parent class + super().update_arguments( + local_optimizer=local_optimizer, + bounds=bounds, + maxiter=maxiter, + jac=jac, + parallel=parallel, + seed=seed, + dtype=dtype, + ) return self def make_grid(self, lines, maxiter=5000): @@ -575,7 +734,7 @@ def make_grid(self, lines, maxiter=5000): Make a grid in multi-dimensions from a list of 1D grids in each dimension. """ - lines = np.array(lines) + lines = array(lines, dtype=self.dtype) if len(lines.shape) < 2: lines = lines.reshape(1, -1) # Number of combinations @@ -591,26 +750,30 @@ def make_grid(self, lines, maxiter=5000): lines = lines[1:] for line in lines: dim_X = len(X) - X = np.concatenate([X] * len(line), axis=0) - X = np.concatenate( + X = tile(X, (len(line), 1)) + X = concatenate( [ X, - np.sort( - np.concatenate([line.reshape(-1)] * dim_X, axis=0) + sort( + concatenate([line.reshape(-1)] * dim_X, axis=0) ).reshape(-1, 1), ], axis=1, ) - return np.random.permutation(X)[:maxiter] + return self.rng.permutation(X)[:maxiter] # Randomly sample the grid points - X = np.array( - [np.random.choice(line, size=maxiter) for line in lines] + X = asarray( + [self.rng.choice(line, size=maxiter) for line in lines], + dtype=self.dtype, ).T - X = np.unique(X, axis=0) + X = unique(X, axis=0) while len(X) < maxiter: - x = np.array([np.random.choice(line, size=1) for line in lines]).T - X = np.append(X, x, axis=0) - X = np.unique(X, axis=0) + x = asarray( + [self.rng.choice(line, size=1) for line in lines], + dtype=self.dtype, + ).T + X = append(X, x, axis=0) + X = unique(X, axis=0) return X[:maxiter] def optimize_minimum( @@ -668,8 +831,6 @@ def get_n_each_dim(self, dim, **kwargs): def check_npoints(self, thetas, **kwargs): "Check if the number of points is well parallized if it is used." if self.parallel: - from ase.parallel import world - npoints = self.get_optimal_npoints(len(thetas), world.size) return thetas[:npoints] return thetas @@ -677,7 +838,7 @@ def check_npoints(self, thetas, **kwargs): def get_minimum(self, sol, thetas, f_list, **kwargs): "Find the minimum function value and update the solution." # Find the minimum function value - i_min = np.nanargmin(f_list) + i_min = nanargmin(f_list) # Get the number of used iterations thetas_len = len(thetas) # Update the number of used iterations @@ -699,9 +860,12 @@ def get_arguments(self): local_optimizer=self.local_optimizer, bounds=self.bounds, maxiter=self.maxiter, + jac=self.jac, + parallel=self.parallel, n_each_dim=self.n_each_dim, optimize=self.optimize, - parallel=self.parallel, + seed=self.seed, + dtype=self.dtype, ) # Get the constants made within the class constant_kwargs = dict() @@ -714,13 +878,16 @@ class IterativeLineOptimizer(GridOptimizer): def __init__( self, local_optimizer=None, - bounds=None, + bounds=VariableTransformation(), maxiter=5000, + jac=False, + parallel=False, n_each_dim=None, loops=3, calculate_init=False, optimize=True, - parallel=False, + seed=None, + dtype=float, **kwargs, ): """ @@ -735,38 +902,52 @@ def __init__( with the local optimizer. Parameters: - local_optimizer : Local optimizer class + local_optimizer: Local optimizer class A local optimization method. - bounds : HPBoundaries class + bounds: HPBoundaries class A class of the boundary conditions of the hyperparameters. - maxiter : int + maxiter: int The maximum number of evaluations or iterations the global optimizer can use. - n_each_dim : int or (H) list + jac: bool + Whether to use the gradient of the objective function + wrt. the hyperparameters. + This is not implemented for this method. + parallel: bool + Whether to calculate the grid points in parallel + over multiple CPUs. + n_each_dim: int or (H) list An integer or a list with number of grid points in each dimension of the hyperparameters. - loops : int + loops: int The number of times all the hyperparameter dimensions have been searched. - calculate_init : bool + calculate_init: bool Whether to calculate the initial given hyperparameters. If it is parallelized, all CPUs will calculate this point. - optimize : bool + optimize: bool Whether to perform a local optimization on the best found solution. - parallel : bool - Whether to calculate the grid points in parallel - over multiple CPUs. + seed: int (optional) + The random seed. + The seed can be an integer, RandomState, or Generator instance. + If not given, the default random number generator is used. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. """ super().__init__( local_optimizer=local_optimizer, bounds=bounds, maxiter=maxiter, + jac=jac, + parallel=parallel, n_each_dim=n_each_dim, loops=loops, calculate_init=calculate_init, optimize=optimize, - parallel=parallel, + seed=seed, + dtype=dtype, **kwargs, ) @@ -806,11 +987,14 @@ def update_arguments( local_optimizer=None, bounds=None, maxiter=None, + jac=None, + parallel=None, n_each_dim=None, loops=None, calculate_init=None, optimize=None, - parallel=None, + seed=None, + dtype=None, **kwargs, ): """ @@ -818,56 +1002,67 @@ def update_arguments( The existing arguments are used if they are not given. Parameters: - local_optimizer : Local optimizer class + local_optimizer: Local optimizer class A local optimization method. - bounds : HPBoundaries class + bounds: HPBoundaries class A class of the boundary conditions of the hyperparameters. - maxiter : int + maxiter: int The maximum number of evaluations or iterations the global optimizer can use. - n_each_dim : int or (H) list + jac: bool + Whether to use the gradient of the objective function + wrt. the hyperparameters. + This is not implemented for this method. + parallel: bool + Whether to calculate the grid points in parallel + over multiple CPUs. + n_each_dim: int or (H) list An integer or a list with number of grid points in each dimension of the hyperparameters. - loops : int + loops: int The number of times all the hyperparameter dimensions have been searched. - calculate_init : bool + calculate_init: bool Whether to calculate the initial given hyperparameters. If it is parallelized, all CPUs will calculate this point. - optimize : bool + optimize: bool Whether to perform a local optimization on the best found solution. - parallel : bool - Whether to calculate the grid points in parallel - over multiple CPUs. + seed: int (optional) + The random seed. + The seed can be an integer, RandomState, or Generator instance. + If not given, the default random number generator is used. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. Returns: self: The updated object itself. """ - if local_optimizer is not None: - self.local_optimizer = local_optimizer.copy() - if bounds is not None: - self.bounds = bounds.copy() - # Use the same boundary conditions in the local optimizer - self.local_optimizer.update_arguments(bounds=self.bounds) - if maxiter is not None: - self.maxiter = int(maxiter) - if parallel is not None: - self.parallel = parallel + # Set the arguments for the parent class + super().update_arguments( + local_optimizer=local_optimizer, + bounds=bounds, + maxiter=maxiter, + jac=jac, + parallel=parallel, + n_each_dim=None, + optimize=optimize, + seed=seed, + dtype=dtype, + ) if loops is not None: self.loops = int(loops) if calculate_init is not None: self.calculate_init = calculate_init if n_each_dim is not None: - if isinstance(n_each_dim, (list, np.ndarray)): - if np.sum(n_each_dim) * self.loops > self.maxiter: + if isinstance(n_each_dim, (list, ndarray)): + if sum_(n_each_dim) * self.loops > self.maxiter: self.n_each_dim = self.get_n_each_dim(len(n_each_dim)) else: self.n_each_dim = n_each_dim.copy() else: self.n_each_dim = n_each_dim - if optimize is not None: - self.optimize = optimize return self def iterative_line(self, theta, lines, func, func_args=(), **kwargs): @@ -884,13 +1079,13 @@ def iterative_line(self, theta, lines, func, func_args=(), **kwargs): # Perform loops for i in range(self.loops): # Permute the dimensions - dim_perm = np.random.permutation(dims) + dim_perm = self.rng.permutation(dims) # Make sure the same dimension is not used after each other if dim_perm[0] == d: dim_perm = dim_perm[1:] for d in dim_perm: # Make the hyperparameter changes to the specific dimension - thetas = np.tile(theta, (len(lines[d]), 1)) + thetas = tile(theta, (len(lines[d]), 1)) thetas[:, d] = lines[d].copy() f_list = self.calculate_values( thetas, @@ -914,9 +1109,7 @@ def get_n_each_dim(self, dim): def get_n_each_dim_parallel(self, n_each_dim): "Number of points per dimension if it is parallelized." - from ase.parallel import world - - if isinstance(n_each_dim, (list, np.ndarray)): + if isinstance(n_each_dim, (list, ndarray)): for d, n_dim in enumerate(n_each_dim): n_each_dim[d] = self.get_optimal_npoints(n_dim, world.size) else: @@ -930,11 +1123,14 @@ def get_arguments(self): local_optimizer=self.local_optimizer, bounds=self.bounds, maxiter=self.maxiter, + jac=self.jac, + parallel=self.parallel, n_each_dim=self.n_each_dim, loops=self.loops, calculate_init=self.calculate_init, optimize=self.optimize, - parallel=self.parallel, + seed=self.seed, + dtype=self.dtype, ) # Get the constants made within the class constant_kwargs = dict() @@ -943,89 +1139,116 @@ def get_arguments(self): return arg_kwargs, constant_kwargs, object_kwargs -class BasinOptimizer(GlobalOptimizer): +class FactorizedOptimizer(GlobalOptimizer): def __init__( self, + line_optimizer=None, + bounds=VariableTransformation(), maxiter=5000, - jac=True, - opt_kwargs={}, - local_kwargs={}, + jac=False, + parallel=False, + ngrid=80, + calculate_init=False, + seed=None, + dtype=float, **kwargs, ): """ - The basin-hopping optimizer used for optimzing the objective function - wrt. the hyperparameters. - The basin-hopping optimizer is a wrapper to SciPy's basinhopping. - (https://docs.scipy.org/doc/scipy/reference/generated/scipy.optimize.basinhopping.html) - No local optimizer and boundary conditions are given to this optimizer. - The local optimizer is set by keywords in the local_kwargs and - it uses SciPy's minimizer. + The factorized optimizer used for optimzing + the objective function wrt. the hyperparameters. + The factorized optimizer makes a 1D grid for each + hyperparameter from the boundary conditions. + The hyperparameters are then optimized with a line search optimizer. + The line search optimizer optimizes only one of the hyperparameters and + it therefore relies on a factorization method as + the objective function. Parameters: - maxiter : int + line_optimizer: Line search optimizer class + A line search optimization method. + bounds: HPBoundaries class + A class of the boundary conditions of the hyperparameters. + maxiter: int The maximum number of evaluations or iterations the global optimizer can use. - jac : bool + jac: bool Whether to use the gradient of the objective function wrt. the hyperparameters. - opt_kwargs : dict - A dictionary with the arguments and keywords given - to SciPy's basinhopping. - local_kwargs : dict - A dictionary with the arguments and keywords given - to SciPy's local minimizer. + This is not implemented for this method. + parallel: bool + Whether to calculate the grid points in parallel + over multiple CPUs. + ngrid: int + The number of grid points of the hyperparameter + that is optimized. + calculate_init: bool + Whether to calculate the initial given hyperparameters. + If it is parallelized, all CPUs will calculate this point. + seed: int (optional) + The random seed. + The seed can be an integer, RandomState, or Generator instance. + If not given, the default random number generator is used. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. """ - # This global optimizer can not be parallelized - self.parallel = False - # Set default arguments for SciPy's basinhopping - self.opt_kwargs = dict( - niter=5, - interval=10, - T=1.0, - stepsize=0.1, - niter_success=None, - ) - # Set default arguments for SciPy's local minimizer - self.local_kwargs = dict( - options={"maxiter": int(maxiter / self.opt_kwargs["niter"])} - ) + # The gradients of the function are unused by the global optimizer + self.jac = False + # Set default line optimizer + if line_optimizer is None: + line_optimizer = GoldenSearch( + maxiter=int(maxiter), + parallel=parallel, + ) # Set all the arguments self.update_arguments( + line_optimizer=line_optimizer, + bounds=bounds, maxiter=maxiter, jac=jac, - opt_kwargs=opt_kwargs, - local_kwargs=local_kwargs, + parallel=parallel, + ngrid=ngrid, + calculate_init=calculate_init, + seed=seed, + dtype=dtype, **kwargs, ) def run(self, func, theta, parameters, model, X, Y, pdis, **kwargs): - from scipy.optimize import basinhopping - - # Get the function arguments - func_args = self.get_func_arguments( + # Make an initial solution or use an empty solution + if self.calculate_init: + func_args = self.get_func_arguments( + parameters, + model, + X, + Y, + pdis, + jac=False, + **kwargs, + ) + sol = self.get_initial_solution(theta, func, func_args=func_args) + else: + sol = self.get_empty_solution() + # Make the lines of the hyperparameters + lines = asarray(self.make_lines(parameters, ngrid=self.ngrid)).T + # Optimize the hyperparameters with the line search + sol_s = self.run_line_opt( + func, + lines, parameters, model, X, Y, pdis, - self.jac, **kwargs, ) - # Get the function that evaluate the objective function - fun = self.get_fun(func) - # Set the minimizer kwargs - minimizer_kwargs = dict( - args=func_args, - jac=self.jac, - **self.local_kwargs, - ) - # Do the basin-hopping - sol = basinhopping( - fun, - x0=theta, - minimizer_kwargs=minimizer_kwargs, - **self.opt_kwargs, - ) + # Update the solution if it is better + sol = self.compare_solutions(sol, sol_s) + # Change the solution message + if sol["success"]: + sol["message"] = "Local optimization is converged." + else: + sol["message"] = "Local optimization is not converged." return self.get_final_solution( sol, func, @@ -1036,12 +1259,209 @@ def run(self, func, theta, parameters, model, X, Y, pdis, **kwargs): pdis, ) + def set_parallel(self, parallel=False, **kwargs): + self.parallel = parallel + return self + + def update_arguments( + self, + line_optimizer=None, + bounds=None, + maxiter=None, + jac=None, + parallel=None, + ngrid=None, + calculate_init=None, + seed=None, + dtype=None, + **kwargs, + ): + """ + Update the optimizer with its arguments. + The existing arguments are used if they are not given. + + Parameters: + line_optimizer: Line search optimizer class + A line search optimization method. + bounds: HPBoundaries class + A class of the boundary conditions of the hyperparameters. + maxiter: int + The maximum number of evaluations or iterations + the global optimizer can use. + jac: bool + Whether to use the gradient of the objective function + wrt. the hyperparameters. + This is not implemented for this method. + parallel: bool + Whether to calculate the grid points in parallel + over multiple CPUs. + ngrid: int + The number of grid points of the hyperparameter + that is optimized. + calculate_init: bool + Whether to calculate the initial given hyperparameters. + If it is parallelized, all CPUs will calculate this point. + seed: int (optional) + The random seed. + The seed can be an integer, RandomState, or Generator instance. + If not given, the default random number generator is used. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. + + Returns: + self: The updated object itself. + """ + # Set the arguments for the parent class + super().update_arguments( + local_optimizer=line_optimizer, + bounds=bounds, + maxiter=maxiter, + jac=jac, + parallel=parallel, + seed=seed, + dtype=dtype, + ) + # Set the arguments + if ngrid is not None: + if self.parallel: + self.ngrid = self.get_optimal_npoints(ngrid, world.size) + else: + self.ngrid = int(ngrid) + if calculate_init is not None: + self.calculate_init = calculate_init + return self + + def run_line_opt( + self, + func, + lines, + parameters, + model, + X, + Y, + pdis, + **kwargs, + ): + "Run the line search optimization." + return self.local_optimizer.run( + func, + lines, + parameters, + model, + X, + Y, + pdis, + **kwargs, + ) + + def get_arguments(self): + "Get the arguments of the class itself." + # Get the arguments given to the class in the initialization + arg_kwargs = dict( + line_optimizer=self.local_optimizer, + bounds=self.bounds, + maxiter=self.maxiter, + jac=self.jac, + parallel=self.parallel, + ngrid=self.ngrid, + calculate_init=self.calculate_init, + seed=self.seed, + dtype=self.dtype, + ) + # Get the constants made within the class + constant_kwargs = dict() + # Get the objects made within the class + object_kwargs = dict() + return arg_kwargs, constant_kwargs, object_kwargs + + +class ScipyGlobalOptimizer(Optimizer): + def __init__( + self, + maxiter=5000, + jac=True, + parallel=False, + opt_kwargs={}, + local_kwargs={}, + seed=None, + dtype=float, + **kwargs, + ): + """ + The global optimizer used for optimzing the objective function + wrt. the hyperparameters. + The global optimizer requires a local optimization method and + boundary conditions of the hyperparameters. + + Parameters: + maxiter: int + The maximum number of evaluations or iterations + the global optimizer can use. + jac: bool + Whether to use the gradient of the objective function + wrt. the hyperparameters. + parallel: bool + Whether to calculate the grid points in parallel + over multiple CPUs. + This is not implemented for this method. + opt_kwargs: dict + A dictionary with the arguments and keywords given + to SciPy's optimizer. + local_kwargs: dict + A dictionary with the arguments and keywords given + to SciPy's local minimizer. + seed: int (optional) + The random seed. + The seed can be an integer, RandomState, or Generator instance. + If not given, the default random number generator is used. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. + """ + # Set default arguments for SciPy's global optimizer + self.opt_kwargs = dict() + # Set default arguments for SciPy's local minimizer + self.local_kwargs = dict(options={}) + # Set all the arguments + self.update_arguments( + maxiter=maxiter, + jac=jac, + parallel=parallel, + opt_kwargs=opt_kwargs, + local_kwargs=local_kwargs, + seed=seed, + dtype=dtype, + **kwargs, + ) + + def set_seed(self, seed=None, **kwargs): + super().set_seed(seed=seed, **kwargs) + # Set the random number generator for the global optimizer + if scipy_version >= "1.15": + self.opt_kwargs["rng"] = self.rng + else: + self.opt_kwargs["seed"] = self.seed + return self + + def set_jac(self, jac=True, **kwargs): + self.jac = jac + return self + + def set_parallel(self, parallel=False, **kwargs): + # This global optimizer can not be parallelized + self.parallel = False + return self + def update_arguments( self, maxiter=None, jac=None, + parallel=None, opt_kwargs=None, local_kwargs=None, + seed=None, + dtype=None, **kwargs, ): """ @@ -1049,26 +1469,41 @@ def update_arguments( The existing arguments are used if they are not given. Parameters: - maxiter : int + maxiter: int The maximum number of evaluations or iterations the global optimizer can use. - jac : bool + jac: bool Whether to use the gradient of the objective function wrt. the hyperparameters. - opt_kwargs : dict + parallel: bool + Whether to calculate the grid points in parallel + over multiple CPUs. + This is not implemented for this method. + opt_kwargs: dict A dictionary with the arguments and keywords given - to SciPy's basinhopping. - local_kwargs : dict + to SciPy's optimizer. + local_kwargs: dict A dictionary with the arguments and keywords given to SciPy's local minimizer. + seed: int (optional) + The random seed. + The seed can be an integer, RandomState, or Generator instance. + If not given, the default random number generator is used. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. Returns: self: The updated object itself. """ - if maxiter is not None: - self.maxiter = int(maxiter) - if jac is not None: - self.jac = jac + # Set the arguments for the parent class + super().update_arguments( + maxiter=maxiter, + jac=jac, + parallel=parallel, + seed=seed, + dtype=dtype, + ) if opt_kwargs is not None: self.opt_kwargs.update(opt_kwargs) if local_kwargs is not None: @@ -1082,6 +1517,186 @@ def update_arguments( self.local_kwargs["options"].update(local_kwargs["options"]) else: self.local_kwargs.update(local_kwargs) + return self + + def get_arguments(self): + "Get the arguments of the class itself." + # Get the arguments given to the class in the initialization + arg_kwargs = dict( + maxiter=self.maxiter, + jac=self.jac, + parallel=self.parallel, + opt_kwargs=self.opt_kwargs, + local_kwargs=self.local_kwargs, + seed=self.seed, + dtype=self.dtype, + ) + # Get the constants made within the class + constant_kwargs = dict() + # Get the objects made within the class + object_kwargs = dict() + return arg_kwargs, constant_kwargs, object_kwargs + + +class BasinOptimizer(ScipyGlobalOptimizer): + def __init__( + self, + maxiter=5000, + jac=True, + parallel=False, + opt_kwargs={}, + local_kwargs={}, + seed=None, + dtype=float, + **kwargs, + ): + """ + The basin-hopping optimizer used for optimzing the objective function + wrt. the hyperparameters. + The basin-hopping optimizer is a wrapper to SciPy's basinhopping. + (https://docs.scipy.org/doc/scipy/reference/generated/scipy.optimize.basinhopping.html) + No local optimizer and boundary conditions are given to this optimizer. + The local optimizer is set by keywords in the local_kwargs and + it uses SciPy's minimizer. + + Parameters: + maxiter: int + The maximum number of evaluations or iterations + the global optimizer can use. + jac: bool + Whether to use the gradient of the objective function + wrt. the hyperparameters. + parallel: bool + Whether to calculate the grid points in parallel + over multiple CPUs. + This is not implemented for this method. + opt_kwargs: dict + A dictionary with the arguments and keywords given + to SciPy's basinhopping. + local_kwargs: dict + A dictionary with the arguments and keywords given + to SciPy's local minimizer. + seed: int (optional) + The random seed. + The seed can be an integer, RandomState, or Generator instance. + If not given, the default random number generator is used. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. + """ + # Set default arguments for SciPy's basinhopping + self.opt_kwargs = dict( + niter=5, + interval=10, + T=1.0, + stepsize=0.1, + niter_success=None, + ) + # Set default arguments for SciPy's local minimizer + self.local_kwargs = dict( + options={"maxiter": int(maxiter / self.opt_kwargs["niter"])} + ) + # Set all the arguments + self.update_arguments( + maxiter=maxiter, + jac=jac, + parallel=parallel, + opt_kwargs=opt_kwargs, + local_kwargs=local_kwargs, + seed=seed, + dtype=dtype, + **kwargs, + ) + + def run(self, func, theta, parameters, model, X, Y, pdis, **kwargs): + # Get the function arguments + func_args = self.get_func_arguments( + parameters, + model, + X, + Y, + pdis, + self.jac, + **kwargs, + ) + # Get the function that evaluate the objective function + fun = self.get_fun(func) + # Set the minimizer kwargs + minimizer_kwargs = dict( + args=func_args, + jac=self.jac, + **self.local_kwargs, + ) + # Do the basin-hopping + sol = basinhopping( + fun, + x0=theta, + minimizer_kwargs=minimizer_kwargs, + **self.opt_kwargs, + ) + return self.get_final_solution( + sol, + func, + parameters, + model, + X, + Y, + pdis, + ) + + def update_arguments( + self, + maxiter=None, + jac=None, + parallel=None, + opt_kwargs=None, + local_kwargs=None, + seed=None, + dtype=None, + **kwargs, + ): + """ + Update the optimizer with its arguments. + The existing arguments are used if they are not given. + + Parameters: + maxiter: int + The maximum number of evaluations or iterations + the global optimizer can use. + jac: bool + Whether to use the gradient of the objective function + wrt. the hyperparameters. + parallel: bool + Whether to calculate the grid points in parallel + over multiple CPUs. + This is not implemented for this method. + opt_kwargs: dict + A dictionary with the arguments and keywords given + to SciPy's basinhopping. + local_kwargs: dict + A dictionary with the arguments and keywords given + to SciPy's local minimizer. + seed: int (optional) + The random seed. + The seed can be an integer, RandomState, or Generator instance. + If not given, the default random number generator is used. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. + + Returns: + self: The updated object itself. + """ + # Set the arguments for the parent class + super().update_arguments( + maxiter=maxiter, + jac=jac, + parallel=parallel, + opt_kwargs=opt_kwargs, + local_kwargs=local_kwargs, + seed=seed, + dtype=dtype, + ) # Make sure not to many iterations are used in average maxiter_niter = int(self.maxiter / self.opt_kwargs["niter"]) if maxiter_niter < self.local_kwargs["options"]["maxiter"]: @@ -1094,8 +1709,11 @@ def get_arguments(self): arg_kwargs = dict( maxiter=self.maxiter, jac=self.jac, + parallel=self.parallel, opt_kwargs=self.opt_kwargs, local_kwargs=self.local_kwargs, + seed=self.seed, + dtype=self.dtype, ) # Get the constants made within the class constant_kwargs = dict() @@ -1104,14 +1722,17 @@ def get_arguments(self): return arg_kwargs, constant_kwargs, object_kwargs -class AnneallingOptimizer(GlobalOptimizer): +class AnneallingOptimizer(ScipyGlobalOptimizer): def __init__( self, - bounds=None, + bounds=EducatedBoundaries(use_log=True), maxiter=5000, jac=True, + parallel=False, opt_kwargs={}, local_kwargs={}, + seed=None, + dtype=float, **kwargs, ): """ @@ -1124,29 +1745,34 @@ def __init__( The local optimizer is set by keywords in the local_kwargs and it uses SciPy's minimizer. + Parameters: - bounds : HPBoundaries class + bounds: HPBoundaries class A class of the boundary conditions of the hyperparameters. - maxiter : int + maxiter: int The maximum number of evaluations or iterations the global optimizer can use. - jac : bool + jac: bool Whether to use the gradient of the objective function wrt. the hyperparameters. - opt_kwargs : dict + parallel: bool + Whether to calculate the grid points in parallel + over multiple CPUs. + This is not implemented for this method. + opt_kwargs: dict A dictionary with the arguments and keywords given to SciPy's dual_annealing. - local_kwargs : dict + local_kwargs: dict A dictionary with the arguments and keywords given to SciPy's local minimizer. + seed: int (optional) + The random seed. + The seed can be an integer, RandomState, or Generator instance. + If not given, the default random number generator is used. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. """ - # Set default bounds - if bounds is None: - from ..hpboundary.educated import EducatedBoundaries - - bounds = EducatedBoundaries(log=True) - # This global optimizer can not be parallelized - self.parallel = False # Set default arguments for SciPy's dual_annealing self.opt_kwargs = dict( initial_temp=5230.0, @@ -1163,14 +1789,15 @@ def __init__( bounds=bounds, maxiter=maxiter, jac=jac, + parallel=parallel, opt_kwargs=opt_kwargs, local_kwargs=local_kwargs, + seed=seed, + dtype=dtype, **kwargs, ) def run(self, func, theta, parameters, model, X, Y, pdis, **kwargs): - from scipy.optimize import dual_annealing - # Get the function arguments func_args = self.get_func_arguments( parameters, @@ -1186,7 +1813,7 @@ def run(self, func, theta, parameters, model, X, Y, pdis, **kwargs): # Set the minimizer kwargs minimizer_kwargs = dict(jac=False, **self.local_kwargs) # Make boundary conditions - bounds = self.make_bounds(parameters, array=True) + bounds = self.make_bounds(parameters, use_array=True) # Do the dual simulated annealing sol = dual_annealing( fun, @@ -1208,13 +1835,32 @@ def run(self, func, theta, parameters, model, X, Y, pdis, **kwargs): pdis, ) + def set_seed(self, seed=None, **kwargs): + # Set the seed for the global optimizer + super().set_seed(seed=seed, **kwargs) + # Set the random seed of the bounds + if self.bounds is not None and hasattr(self.bounds, "set_seed"): + self.bounds.set_seed(seed=seed, **kwargs) + return self + + def set_dtype(self, dtype, **kwargs): + # Set the dtype for the global optimizer + super().set_dtype(dtype=dtype, **kwargs) + # Set the data type of the bounds + if self.bounds is not None and hasattr(self.bounds, "set_dtype"): + self.bounds.set_dtype(dtype=dtype, **kwargs) + return self + def update_arguments( self, bounds=None, maxiter=None, + parallel=None, jac=None, opt_kwargs=None, local_kwargs=None, + seed=None, + dtype=None, **kwargs, ): """ @@ -1222,45 +1868,57 @@ def update_arguments( The existing arguments are used if they are not given. Parameters: - bounds : HPBoundaries class + bounds: HPBoundaries class A class of the boundary conditions of the hyperparameters. - maxiter : int + maxiter: int The maximum number of evaluations or iterations the global optimizer can use. - jac : bool + jac: bool Whether to use the gradient of the objective function wrt. the hyperparameters. - opt_kwargs : dict + parallel: bool + Whether to calculate the grid points in parallel + over multiple CPUs. + This is not implemented for this method. + opt_kwargs: dict A dictionary with the arguments and keywords given to SciPy's dual_annealing. - local_kwargs : dict + local_kwargs: dict A dictionary with the arguments and keywords given to SciPy's local minimizer. + seed: int (optional) + The random seed. + The seed can be an integer, RandomState, or Generator instance. + If not given, the default random number generator is used. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. Returns: self: The updated object itself. """ if bounds is not None: self.bounds = bounds.copy() - if maxiter is not None: - self.maxiter = int(maxiter) - if jac is not None: - self.jac = jac - if opt_kwargs is not None: - self.opt_kwargs.update(opt_kwargs) - if local_kwargs is not None: - if "options" in local_kwargs: - local_no_options = { - key: value - for key, value in local_kwargs.items() - if key != "options" - } - self.local_kwargs.update(local_no_options) - self.local_kwargs["options"].update(local_kwargs["options"]) - else: - self.local_kwargs.update(local_kwargs) + # Set the arguments for the parent class + super().update_arguments( + maxiter=maxiter, + jac=jac, + parallel=parallel, + opt_kwargs=opt_kwargs, + local_kwargs=local_kwargs, + seed=seed, + dtype=dtype, + ) return self + def make_bounds(self, parameters, use_array=True, **kwargs): + "Make the boundary conditions of the hyperparameters." + return self.bounds.get_bounds( + parameters=parameters, + use_array=use_array, + **kwargs, + ) + def get_arguments(self): "Get the arguments of the class itself." # Get the arguments given to the class in the initialization @@ -1268,8 +1926,11 @@ def get_arguments(self): bounds=self.bounds, maxiter=self.maxiter, jac=self.jac, + parallel=self.parallel, opt_kwargs=self.opt_kwargs, local_kwargs=self.local_kwargs, + seed=self.seed, + dtype=self.dtype, ) # Get the constants made within the class constant_kwargs = dict() @@ -1281,11 +1942,14 @@ def get_arguments(self): class AnneallingTransOptimizer(AnneallingOptimizer): def __init__( self, - bounds=None, + bounds=VariableTransformation(), maxiter=5000, jac=True, + parallel=False, opt_kwargs={}, local_kwargs={}, + seed=None, + dtype=float, **kwargs, ): """ @@ -1301,28 +1965,32 @@ def __init__( the hyperparameters to search the space. Parameters: - bounds : VariableTransformation class + bounds: VariableTransformation class A class of the variable transformation of the hyperparameters. - maxiter : int + maxiter: int The maximum number of evaluations or iterations the global optimizer can use. - jac : bool + jac: bool Whether to use the gradient of the objective function wrt. the hyperparameters. - opt_kwargs : dict + parallel: bool + Whether to calculate the grid points in parallel + over multiple CPUs. + This is not implemented for this method. + opt_kwargs: dict A dictionary with the arguments and keywords given to SciPy's dual_annealing. - local_kwargs : dict + local_kwargs: dict A dictionary with the arguments and keywords given to SciPy's local minimizer. + seed: int (optional) + The random seed. + The seed can be an integer, RandomState, or Generator instance. + If not given, the default random number generator is used. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. """ - # Set default bounds - if bounds is None: - from ..hpboundary.hptrans import VariableTransformation - - bounds = VariableTransformation(bounds=None) - # This global optimizer can not be parallelized - self.parallel = False # Set default arguments for SciPy's dual_annealing self.opt_kwargs = dict( initial_temp=5230.0, @@ -1339,14 +2007,15 @@ def __init__( bounds=bounds, maxiter=maxiter, jac=jac, + parallel=parallel, opt_kwargs=opt_kwargs, local_kwargs=local_kwargs, + seed=seed, + dtype=dtype, **kwargs, ) def run(self, func, theta, parameters, model, X, Y, pdis, **kwargs): - from scipy.optimize import dual_annealing - # Get the function arguments for the wrappers func_args_w = self.get_wrapper_arguments( func, @@ -1361,7 +2030,7 @@ def run(self, func, theta, parameters, model, X, Y, pdis, **kwargs): # Set the minimizer kwargs minimizer_kwargs = dict(jac=False, **self.local_kwargs) # Make boundary conditions - bounds = self.make_bounds(parameters, array=True, transformed=True) + bounds = self.make_bounds(parameters, use_array=True, transformed=True) # Do the dual simulated annealing sol = dual_annealing( self.func_vartrans, @@ -1383,8 +2052,11 @@ def update_arguments( bounds=None, maxiter=None, jac=None, + parallel=None, opt_kwargs=None, local_kwargs=None, + seed=None, + dtype=None, **kwargs, ): """ @@ -1392,49 +2064,50 @@ def update_arguments( The existing arguments are used if they are not given. Parameters: - bounds : VariableTransformation class + bounds: VariableTransformation class A class of the variable transformation of the hyperparameters. - maxiter : int + maxiter: int The maximum number of evaluations or iterations the global optimizer can use. - jac : bool + jac: bool Whether to use the gradient of the objective function wrt. the hyperparameters. - opt_kwargs : dict + parallel: bool + Whether to calculate the grid points in parallel + over multiple CPUs. + This is not implemented for this method. + opt_kwargs: dict A dictionary with the arguments and keywords given to SciPy's dual_annealing. - local_kwargs : dict + local_kwargs: dict A dictionary with the arguments and keywords given to SciPy's local minimizer. + seed: int (optional) + The random seed. + The seed can be an integer, RandomState, or Generator instance. + If not given, the default random number generator is used. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. Returns: self: The updated object itself. """ + super().update_arguments( + bounds=bounds, + maxiter=maxiter, + jac=jac, + parallel=False, + opt_kwargs=opt_kwargs, + local_kwargs=local_kwargs, + seed=seed, + dtype=dtype, + ) if bounds is not None: - from ..hpboundary.hptrans import VariableTransformation - if not isinstance(bounds, VariableTransformation): - raise Exception( + raise ValueError( "A variable transformation as bounds has to be used!" ) - self.bounds = bounds.copy() - if maxiter is not None: - self.maxiter = int(maxiter) - if jac is not None: - self.jac = jac - if opt_kwargs is not None: - self.opt_kwargs.update(opt_kwargs) - if local_kwargs is not None: - if "options" in local_kwargs: - local_no_options = { - key: value - for key, value in local_kwargs.items() - if key != "options" - } - self.local_kwargs.update(local_no_options) - self.local_kwargs["options"].update(local_kwargs["options"]) - else: - self.local_kwargs.update(local_kwargs) return self def func_vartrans(self, ti, fun, parameters, func_args=(), **kwargs): @@ -1450,13 +2123,13 @@ def reverse_trasformation(self, ti, parameters, **kwargs): Transform the variable transformed hyperparameters back to hyperparameter log-space. """ - ti = np.where( + ti = where( ti < 1.0, - np.where(ti > 0.0, ti, self.bounds.eps), + where(ti > 0.0, ti, self.bounds.eps), 1.00 - self.bounds.eps, ) t = self.make_hp(ti, parameters) - theta = self.bounds.reverse_trasformation(t, array=True) + theta = self.bounds.reverse_trasformation(t, use_array=True) return theta def transform_solution(self, sol, **kwargs): @@ -1464,8 +2137,11 @@ def transform_solution(self, sol, **kwargs): Retransform the variable transformed hyperparameters in the solution back to hyperparameter log-space. """ - sol["x"] = self.bounds.reverse_trasformation(sol["hp"], array=True) - sol["hp"] = self.bounds.reverse_trasformation(sol["hp"], array=False) + sol["x"] = self.bounds.reverse_trasformation(sol["hp"], use_array=True) + sol["hp"] = self.bounds.reverse_trasformation( + sol["hp"], + use_array=False, + ) return sol def get_wrapper_arguments( @@ -1495,208 +2171,3 @@ def get_wrapper_arguments( # Get the function arguments for the wrappers func_args_w = (fun, parameters, func_args) return func_args_w - - -class FactorizedOptimizer(GlobalOptimizer): - def __init__( - self, - line_optimizer=None, - bounds=None, - maxiter=5000, - ngrid=80, - calculate_init=False, - parallel=False, - **kwargs, - ): - """ - The factorized optimizer used for optimzing - the objective function wrt. the hyperparameters. - The factorized optimizer makes a 1D grid for each - hyperparameter from the boundary conditions. - The hyperparameters are then optimized with a line search optimizer. - The line search optimizer optimizes only one of the hyperparameters and - it therefore relies on a factorization method as - the objective function. - - Parameters: - line_optimizer : Line search optimizer class - A line search optimization method. - bounds : HPBoundaries class - A class of the boundary conditions of the hyperparameters. - maxiter : int - The maximum number of evaluations or iterations - the global optimizer can use. - ngrid : int - The number of grid points of the hyperparameter - that is optimized. - calculate_init : bool - Whether to calculate the initial given hyperparameters. - If it is parallelized, all CPUs will calculate this point. - parallel : bool - Whether to calculate the grid points in parallel - over multiple CPUs. - """ - # The gradients of the function are unused by the global optimizer - self.jac = False - # Set default bounds - if bounds is None: - from ..hpboundary.hptrans import VariableTransformation - - bounds = VariableTransformation(bounds=None) - # Set default line optimizer - if line_optimizer is None: - from .linesearcher import GoldenSearch - - line_optimizer = GoldenSearch( - maxiter=int(maxiter), - parallel=parallel, - ) - # Set all the arguments - self.update_arguments( - line_optimizer=line_optimizer, - bounds=bounds, - maxiter=maxiter, - ngrid=ngrid, - calculate_init=calculate_init, - parallel=parallel, - **kwargs, - ) - - def run(self, func, theta, parameters, model, X, Y, pdis, **kwargs): - # Make an initial solution or use an empty solution - if self.calculate_init: - func_args = self.get_func_arguments( - parameters, - model, - X, - Y, - pdis, - jac=False, - **kwargs, - ) - sol = self.get_initial_solution(theta, func, func_args=func_args) - else: - sol = self.get_empty_solution() - # Make the lines of the hyperparameters - lines = np.array(self.make_lines(parameters, ngrid=self.ngrid)).T - # Optimize the hyperparameters with the line search - sol_s = self.run_line_opt( - func, - lines, - parameters, - model, - X, - Y, - pdis, - **kwargs, - ) - # Update the solution if it is better - sol = self.compare_solutions(sol, sol_s) - # Change the solution message - if sol["success"]: - sol["message"] = "Local optimization is converged." - else: - sol["message"] = "Local optimization is not converged." - return self.get_final_solution( - sol, - func, - parameters, - model, - X, - Y, - pdis, - ) - - def update_arguments( - self, - line_optimizer=None, - bounds=None, - maxiter=None, - ngrid=None, - calculate_init=None, - parallel=None, - **kwargs, - ): - """ - Update the optimizer with its arguments. - The existing arguments are used if they are not given. - - Parameters: - line_optimizer : Line search optimizer class - A line search optimization method. - bounds : HPBoundaries class - A class of the boundary conditions of the hyperparameters. - maxiter : int - The maximum number of evaluations or iterations - the global optimizer can use. - ngrid : int - The number of grid points of the hyperparameter - that is optimized. - calculate_init : bool - Whether to calculate the initial given hyperparameters. - If it is parallelized, all CPUs will calculate this point. - parallel : bool - Whether to calculate the grid points in parallel - over multiple CPUs. - - Returns: - self: The updated object itself. - """ - if line_optimizer is not None: - self.line_optimizer = line_optimizer.copy() - if bounds is not None: - self.bounds = bounds.copy() - if maxiter is not None: - self.maxiter = int(maxiter) - if parallel is not None: - self.parallel = parallel - if ngrid is not None: - if self.parallel: - from ase.parallel import world - - self.ngrid = self.get_optimal_npoints(ngrid, world.size) - else: - self.ngrid = int(ngrid) - if calculate_init is not None: - self.calculate_init = calculate_init - return self - - def run_line_opt( - self, - func, - lines, - parameters, - model, - X, - Y, - pdis, - **kwargs, - ): - "Run the line search optimization." - return self.line_optimizer.run( - func, - lines, - parameters, - model, - X, - Y, - pdis, - **kwargs, - ) - - def get_arguments(self): - "Get the arguments of the class itself." - # Get the arguments given to the class in the initialization - arg_kwargs = dict( - line_optimizer=self.line_optimizer, - bounds=self.bounds, - maxiter=self.maxiter, - ngrid=self.ngrid, - calculate_init=self.calculate_init, - parallel=self.parallel, - ) - # Get the constants made within the class - constant_kwargs = dict() - # Get the objects made within the class - object_kwargs = dict() - return arg_kwargs, constant_kwargs, object_kwargs diff --git a/catlearn/regression/gp/optimizers/linesearcher.py b/catlearn/regression/gp/optimizers/linesearcher.py index c9362400..47138e75 100644 --- a/catlearn/regression/gp/optimizers/linesearcher.py +++ b/catlearn/regression/gp/optimizers/linesearcher.py @@ -1,16 +1,37 @@ from .localoptimizer import LocalOptimizer -import numpy as np +from numpy import ( + append, + argsort, + asarray, + concatenate, + empty, + exp, + floor, + full, + linspace, + nanargmin, + nanmax, + nanmin, + sqrt, + where, +) +from numpy.linalg import norm +from scipy.integrate import cumulative_trapezoid +from ase.parallel import world class LineSearchOptimizer(LocalOptimizer): def __init__( self, maxiter=5000, + jac=False, + parallel=False, + seed=None, + dtype=float, tol=1e-5, optimize=True, multiple_min=True, theta_index=None, - parallel=False, xtol=None, ftol=None, **kwargs, @@ -23,34 +44,44 @@ def __init__( A line of the hyperparameter is required to run the line search. Parameters: - maxiter : int + maxiter: int The maximum number of evaluations or iterations the optimizer can use. - tol : float + jac: bool + Whether to use the gradient of the objective function + wrt. the hyperparameters. + The line search optimizers cannot use gradients + of the objective function. + parallel: bool + Whether to calculate the grid points in parallel + over multiple CPUs. + seed: int (optional) + The random seed. + The seed can be an integer, RandomState, or Generator instance. + If not given, the default random number generator is used. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. + tol: float A tolerance criterion for convergence. - optimize : bool + optimize: bool Whether to optimize the line given by split it into smaller intervals. - multiple_min : bool + multiple_min: bool Whether to optimize multiple minimums or just optimize the lowest minimum. - theta_index : int or None + theta_index: int or None The index of the hyperparameter that is optimized with the line search. If theta_index=None, then it will use the index of the length-scale. If theta_index=None and no length-scale, then theta_index=0. - parallel : bool - Whether to calculate the grid points in parallel - over multiple CPUs. - xtol : float + xtol: float A tolerance criterion of the hyperparameter for convergence. - ftol : float + ftol: float A tolerance criterion of the objective function for convergence. """ - # Line search optimizers cannot use gradients of the objective function - self.jac = False # Set the default theta_index self.theta_index = None # Set xtol and ftol to the tolerance if they are not given. @@ -58,11 +89,14 @@ def __init__( # Set all the arguments self.update_arguments( maxiter=maxiter, + jac=jac, + parallel=parallel, + seed=seed, + dtype=dtype, tol=tol, optimize=optimize, multiple_min=multiple_min, theta_index=theta_index, - parallel=parallel, xtol=xtol, ftol=ftol, **kwargs, @@ -75,41 +109,53 @@ def run(self, func, line, parameters, model, X, Y, pdis, **kwargs): The grid/line of the hyperparameter has to be given. Parameters: - func : ObjectiveFunction class object + func: ObjectiveFunction class object The objective function class that is used to calculate the value. - line : (ngrid,H) array + line: (ngrid,H) array An array with the grid points of the hyperparameters. Only one of the hyperparameters is used, which is given by theta_index. - parameters : (H) list of strings + parameters: (H) list of strings A list of names of the hyperparameters. - model : Model class object + model: Model class object The Machine Learning Model with kernel and prior that are optimized. - X : (N,D) array + X: (N,D) array Training features with N data points and D dimensions. - Y : (N,1) array or (N,D+1) array + Y: (N,1) array or (N,D+1) array Training targets with or without derivatives with N data points. - pdis : dict + pdis: dict A dict of prior distributions for each hyperparameter type. Returns: - dict : A solution dictionary with objective function value, + dict: A solution dictionary with objective function value, optimized hyperparameters, success statement, and number of used evaluations. """ raise NotImplementedError() + def set_jac(self, jac=False, **kwargs): + # Line search optimizers cannot use gradients of the objective function + self.jac = False + return self + + def set_parallel(self, parallel=False, **kwargs): + self.parallel = parallel + return self + def update_arguments( self, maxiter=None, + jac=None, + parallel=None, + seed=None, + dtype=None, tol=None, optimize=None, multiple_min=None, theta_index=None, - parallel=None, xtol=None, ftol=None, **kwargs, @@ -119,47 +165,61 @@ def update_arguments( The existing arguments are used if they are not given. Parameters: - maxiter : int + maxiter: int The maximum number of evaluations or iterations the optimizer can use. - tol : float + jac: bool + Whether to use the gradient of the objective function + wrt. the hyperparameters. + The line search optimizers cannot use gradients + of the objective function. + parallel: bool + Whether to calculate the grid points in parallel + over multiple CPUs. + seed: int (optional) + The random seed. + The seed can be an integer, RandomState, or Generator instance. + If not given, the default random number generator is used. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. + tol: float A tolerance criterion for convergence. - optimize : bool + optimize: bool Whether to optimize the line given by split it into smaller intervals. - multiple_min : bool + multiple_min: bool Whether to optimize multiple minimums or just optimize the lowest minimum. - theta_index : int or None + theta_index: int or None The index of the hyperparameter that is optimized with the line search. If theta_index=None, then it will use the index of the length-scale. If theta_index=None and no length-scale, then theta_index=0. - parallel : bool - Whether to calculate the grid points in parallel - over multiple CPUs. - xtol : float + xtol: float A tolerance criterion of the hyperparameter for convergence. - ftol : float + ftol: float A tolerance criterion of the objective function for convergence. Returns: self: The updated object itself. """ - if maxiter is not None: - self.maxiter = int(maxiter) - if tol is not None: - self.tol = tol + super().update_arguments( + maxiter=maxiter, + jac=jac, + parallel=parallel, + seed=seed, + dtype=dtype, + tol=tol, + ) if optimize is not None: self.optimize = optimize if multiple_min is not None: self.multiple_min = multiple_min if theta_index is not None: self.theta_index = int(theta_index) - if parallel is not None: - self.parallel = parallel if xtol is not None: self.xtol = xtol if ftol is not None: @@ -205,7 +265,7 @@ def find_multiple_min( """ # Find local minimas for middel part of line i_minimas = ( - np.where( + where( (fvalues[1:-1] < fvalues[:-2]) & (fvalues[2:] > fvalues[1:-1]) )[0] + 1 @@ -220,9 +280,9 @@ def find_multiple_min( i_minimas = i_minimas[i_keep] # Find local minimas for end parts of line if fvalues[0] - fvalues[1] < -self.ftol: - i_minimas = np.append([1], i_minimas) + i_minimas = append([1], i_minimas) if fvalues[-1] - fvalues[-2] < -self.ftol: - i_minimas = np.append(i_minimas, [len_l - 2]) + i_minimas = append(i_minimas, [len_l - 2]) # Check the distances in the local minimas are within the tolerance if len(i_minimas): i_keep = abs( @@ -232,7 +292,7 @@ def find_multiple_min( i_minimas = i_minimas[i_keep] # Sort the indicies after function value sizes if len(i_minimas) > 1: - i_sort = np.argsort(fvalues[i_minimas]) + i_sort = argsort(fvalues[i_minimas]) i_minimas = i_minimas[i_sort] return i_minimas @@ -272,7 +332,7 @@ def find_single_min( - xvalues[i_minima - 1, theta_index] ) >= self.xtol * (1.0 + abs(xvalues[i_minima, theta_index])): i_minimas = [] - return np.array(i_minimas) + return asarray(i_minimas) def get_theta_index(self, parameters=[], **kwargs): "Get the theta_index." @@ -295,11 +355,14 @@ def get_arguments(self): # Get the arguments given to the class in the initialization arg_kwargs = dict( maxiter=self.maxiter, + jac=self.jac, + parallel=self.parallel, + seed=self.seed, + dtype=self.dtype, tol=self.tol, optimize=self.optimize, multiple_min=self.multiple_min, theta_index=self.theta_index, - parallel=self.parallel, xtol=self.xtol, ftol=self.ftol, ) @@ -314,11 +377,14 @@ class GoldenSearch(LineSearchOptimizer): def __init__( self, maxiter=5000, + jac=False, + parallel=False, + seed=None, + dtype=float, tol=1e-5, optimize=True, multiple_min=True, theta_index=None, - parallel=False, xtol=None, ftol=None, **kwargs, @@ -332,39 +398,54 @@ def __init__( A line of the hyperparameter is required to run the line search. Parameters: - maxiter : int + maxiter: int The maximum number of evaluations or iterations the optimizer can use. - tol : float + jac: bool + Whether to use the gradient of the objective function + wrt. the hyperparameters. + The line search optimizers cannot use gradients + of the objective function. + parallel: bool + Whether to calculate the grid points in parallel + over multiple CPUs. + seed: int (optional) + The random seed. + The seed can be an integer, RandomState, or Generator instance. + If not given, the default random number generator is used. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. + tol: float A tolerance criterion for convergence. - optimize : bool + optimize: bool Whether to optimize the line given by split it into smaller intervals. - multiple_min : bool + multiple_min: bool Whether to optimize multiple minimums or just optimize the lowest minimum. - theta_index : int or None + theta_index: int or None The index of the hyperparameter that is optimized with the line search. If theta_index=None, then it will use the index of the length-scale. If theta_index=None and no length-scale, then theta_index=0. - parallel : bool - Whether to calculate the grid points in parallel - over multiple CPUs. - xtol : float + xtol: float A tolerance criterion of the hyperparameter for convergence. - ftol : float + ftol: float A tolerance criterion of the objective function for convergence. """ super().__init__( maxiter=maxiter, + jac=jac, + parallel=parallel, + seed=seed, + dtype=dtype, tol=tol, optimize=optimize, multiple_min=multiple_min, theta_index=theta_index, - parallel=parallel, xtol=xtol, ftol=ftol, **kwargs, @@ -388,7 +469,7 @@ def run(self, func, line, parameters, model, X, Y, pdis, **kwargs): line = line.reshape(len_l, -1) f_list = self.calculate_values(line, func, func_args=func_args) # Find the optimal value - i_min = np.nanargmin(f_list) + i_min = nanargmin(f_list) sol = { "fun": f_list[i_min], "x": line[i_min], @@ -397,8 +478,8 @@ def run(self, func, line, parameters, model, X, Y, pdis, **kwargs): "nit": len_l, } # Check whether the object function is flat - if (np.nanmax(f_list) - f_list[i_min]) < self.ftol: - i = int(np.floor(0.3 * (len(line) - 1))) + if (nanmax(f_list) - f_list[i_min]) < self.ftol: + i = int(floor(0.3 * (len(line) - 1))) return { "fun": f_list[i], "x": line[i], @@ -480,14 +561,17 @@ def golden_search( maxiter=200, func_args=(), fbracket=None, - vec0=np.array([0.0]), - direc=np.array([1.0]), + vec0=[0.0], + direc=[1.0], direc_norm=None, **kwargs, ): "Perform a golden section search." + # Make arrays + vec0 = asarray(vec0, dtype=self.dtype) + direc = asarray(direc, dtype=self.dtype) # Golden ratio - r = (np.sqrt(5) - 1) / 2 + r = (sqrt(5) - 1) / 2 c = 1 - r # Number of function evaluations nfev = 0 @@ -502,10 +586,10 @@ def golden_search( f1, f4 = fbracket # Direction vector norm if direc_norm is None: - direc_norm = np.linalg.norm(direc) + direc_norm = norm(direc) # Check if the maximum number of iterations have been used if maxiter < 3: - i_min = np.nanargmin([f1, f4]) + i_min = nanargmin([f1, f4]) sol = { "fun": [f1, f4][i_min], "x": [vec1, vec4][i_min], @@ -516,7 +600,7 @@ def golden_search( return sol # Check if the coordinate convergence criteria is already met if abs(x4 - x1) * direc_norm <= self.xtol: - i_min = np.nanargmin([f1, f4]) + i_min = nanargmin([f1, f4]) sol = { "fun": [f1, f4][i_min], "x": [vec1, vec4][i_min], @@ -537,9 +621,9 @@ def golden_search( # Perform the line search success = False while nfev < maxiter: - i_min = np.nanargmin(f_list) + i_min = nanargmin(f_list) # Check for convergence - if np.nanmax(f_list) - f_list[i_min] <= self.ftol * ( + if nanmax(f_list) - f_list[i_min] <= self.ftol * ( 1.0 + abs(f_list[i_min]) ) or abs(x_list[3] - x_list[0]) * direc_norm <= self.xtol * ( 1.0 + direc_norm * abs(x_list[1]) @@ -563,7 +647,7 @@ def golden_search( f_list[2] = fun(vec0 + direc * x_list[2], *func_args) nfev += 1 # Get the solution - i_min = np.nanargmin(f_list) + i_min = nanargmin(f_list) sol = { "fun": f_list[i_min], "x": vec0 + direc * (x_list[i_min]), @@ -578,13 +662,16 @@ class FineGridSearch(LineSearchOptimizer): def __init__( self, maxiter=5000, + jac=False, + parallel=False, + seed=None, + dtype=float, tol=1e-5, optimize=True, multiple_min=True, ngrid=80, loops=3, theta_index=None, - parallel=False, xtol=None, ftol=None, **kwargs, @@ -599,39 +686,49 @@ def __init__( A line of the hyperparameter is required to run the line search. Parameters: - maxiter : int + maxiter: int The maximum number of evaluations or iterations the optimizer can use. - tol : float + jac: bool + Whether to use the gradient of the objective function + wrt. the hyperparameters. + The line search optimizers cannot use gradients + of the objective function. + parallel: bool + Whether to calculate the grid points in parallel + over multiple CPUs. + seed: int (optional) + The random seed. + The seed can be an integer, RandomState, or Generator instance. + If not given, the default random number generator is used. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. + tol: float A tolerance criterion for convergence. - optimize : bool + optimize: bool Whether to optimize the line given by split it into smaller intervals. - multiple_min : bool + multiple_min: bool Whether to optimize multiple minimums or just optimize the lowest minimum. - ngrid : int + ngrid: int The number of grid points of the hyperparameter that is optimized. - loops : int + loops: int The number of loops where the grid points are made. - theta_index : int or None + theta_index: int or None The index of the hyperparameter that is optimized with the line search. If theta_index=None, then it will use the index of the length-scale. If theta_index=None and no length-scale, then theta_index=0. - parallel : bool - Whether to calculate the grid points in parallel - over multiple CPUs. - xtol : float + xtol: float A tolerance criterion of the hyperparameter for convergence. - ftol : float + ftol: float A tolerance criterion of the objective function for convergence. """ - # Line search optimizers cannot use gradients of the objective function - self.jac = False # Set the default theta_index self.theta_index = None # Set xtol and ftol to the tolerance if they are not given. @@ -639,13 +736,16 @@ def __init__( # Set all the arguments self.update_arguments( maxiter=maxiter, + jac=jac, + parallel=parallel, + seed=seed, + dtype=dtype, tol=tol, optimize=optimize, multiple_min=multiple_min, ngrid=ngrid, loops=loops, theta_index=theta_index, - parallel=parallel, xtol=xtol, ftol=ftol, **kwargs, @@ -666,8 +766,8 @@ def run(self, func, line, parameters, model, X, Y, pdis, **kwargs): theta_index = self.get_theta_index(parameters) # Make empty solution and lists sol = self.get_empty_solution() - lines = np.empty((0, len(line[0]))) - f_lists = np.empty((0)) + lines = empty((0, len(line[0])), dtype=self.dtype) + f_lists = empty((0), dtype=self.dtype) # Get the solution from loops of the fine grid method sol = self.run_grid_loops( func, @@ -682,16 +782,40 @@ def run(self, func, line, parameters, model, X, Y, pdis, **kwargs): ) return sol + def set_ngrid(self, ngrid=None, **kwargs): + """ + Set the number of grid points of the hyperparameter + that is optimized. + + Parameters: + ngrid: int + The number of grid points of the hyperparameter + that is optimized. + + Returns: + self: The updated object itself. + """ + if self.parallel: + self.ngrid = int(int(ngrid / world.size) * world.size) + if self.ngrid == 0: + self.ngrid = world.size + else: + self.ngrid = int(ngrid) + return self + def update_arguments( self, maxiter=None, + jac=None, + parallel=None, + seed=None, + dtype=None, tol=None, optimize=None, multiple_min=None, ngrid=None, loops=None, theta_index=None, - parallel=None, xtol=None, ftol=None, **kwargs, @@ -701,67 +825,69 @@ def update_arguments( The existing arguments are used if they are not given. Parameters: - maxiter : int + maxiter: int The maximum number of evaluations or iterations the optimizer can use. - tol : float + jac: bool + Whether to use the gradient of the objective function + wrt. the hyperparameters. + The line search optimizers cannot use gradients + of the objective function. + parallel: bool + Whether to calculate the grid points in parallel + over multiple CPUs. + seed: int (optional) + The random seed. + The seed can be an integer, RandomState, or Generator instance. + If not given, the default random number generator is used. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. + tol: float A tolerance criterion for convergence. - optimize : bool + optimize: bool Whether to optimize the line given by split it into smaller intervals. - multiple_min : bool + multiple_min: bool Whether to optimize multiple minimums or just optimize the lowest minimum. - ngrid : int + ngrid: int The number of grid points of the hyperparameter that is optimized. - loops : int + loops: int The number of loops where the grid points are made. - theta_index : int or None + theta_index: int or None The index of the hyperparameter that is optimized with the line search. If theta_index=None, then it will use the index of the length-scale. If theta_index=None and no length-scale, then theta_index=0. - parallel : bool - Whether to calculate the grid points in parallel - over multiple CPUs. - xtol : float + xtol: float A tolerance criterion of the hyperparameter for convergence. - ftol : float + ftol: float A tolerance criterion of the objective function for convergence. Returns: self: The updated object itself. """ - if maxiter is not None: - self.maxiter = int(maxiter) - if tol is not None: - self.tol = tol - if optimize is not None: - self.optimize = optimize - if multiple_min is not None: - self.multiple_min = multiple_min - if theta_index is not None: - self.theta_index = int(theta_index) - if parallel is not None: - self.parallel = parallel + super().update_arguments( + maxiter=maxiter, + jac=jac, + parallel=parallel, + seed=seed, + dtype=dtype, + tol=tol, + optimize=optimize, + multiple_min=multiple_min, + theta_index=theta_index, + xtol=xtol, + ftol=ftol, + ) if ngrid is not None: - if self.parallel: - from ase.parallel import world - - self.ngrid = int(int(ngrid / world.size) * world.size) - if self.ngrid == 0: - self.ngrid = world.size - else: - self.ngrid = int(ngrid) + self.set_ngrid(ngrid=ngrid) if loops is not None: self.loops = int(loops) - if xtol is not None: - self.xtol = xtol - if ftol is not None: - self.ftol = ftol return self def run_grid_loops( @@ -786,12 +912,12 @@ def run_grid_loops( line = line.reshape(len_l, -1) f_list = self.calculate_values(line, func, func_args=func_args) # Use previously calculated grid points - lines = np.append(lines, line, axis=0) - i_sort = np.argsort(lines[:, theta_index]) + lines = append(lines, line, axis=0) + i_sort = argsort(lines[:, theta_index]) lines = lines[i_sort] - f_lists = np.append(f_lists, f_list)[i_sort] + f_lists = append(f_lists, f_list)[i_sort] # Find the minimum value - i_min = np.nanargmin(f_lists) + i_min = nanargmin(f_lists) # Update the solution dictionary sol["nfev"] += len_l sol["nit"] += len_l @@ -845,7 +971,7 @@ def make_new_line( ): "Make a new line/grid for the minimums to optimize the hyperparameter." # Find the grid points that must be saved for later - i_d = np.array([[-1], [0], [1]], dtype=int) + i_d = asarray([[-1], [0], [1]], dtype=int) i_all = (i_minimas + i_d).T.reshape(-1) saved_lines = lines[i_all] saved_f_lists = f_lists[i_all] @@ -857,7 +983,7 @@ def make_new_line( i_minimas = i_minimas[: self.ngrid // 3] len_i = len(i_minimas) # Get the number of grid points for each minimum - di = np.full( + di = full( shape=len_i, fill_value=self.ngrid // len_i, dtype=int, @@ -866,16 +992,16 @@ def make_new_line( # if there are grid points to spare di[: int(self.ngrid % len_i)] += 1 # Make new line - newline = np.concatenate( + newline = concatenate( [ - np.linspace(lines[i - 1], lines[i + 1], di[j] + 2)[1:-1] + linspace(lines[i - 1], lines[i + 1], di[j] + 2)[1:-1] for j, i in enumerate(i_minimas) ] ) else: i_min = i_minimas[0] # Make new line - newline = np.linspace( + newline = linspace( lines[i_min - 1], lines[i_min + 1], self.ngrid + 2, @@ -887,13 +1013,16 @@ def get_arguments(self): # Get the arguments given to the class in the initialization arg_kwargs = dict( maxiter=self.maxiter, + jac=self.jac, + parallel=self.parallel, + seed=self.seed, + dtype=self.dtype, tol=self.tol, optimize=self.optimize, multiple_min=self.multiple_min, ngrid=self.ngrid, loops=self.loops, theta_index=self.theta_index, - parallel=self.parallel, xtol=self.xtol, ftol=self.ftol, ) @@ -908,6 +1037,10 @@ class TransGridSearch(FineGridSearch): def __init__( self, maxiter=5000, + jac=False, + parallel=False, + seed=None, + dtype=float, tol=1e-5, optimize=True, multiple_min=True, @@ -915,7 +1048,6 @@ def __init__( loops=3, use_likelihood=True, theta_index=None, - parallel=False, xtol=None, ftol=None, **kwargs, @@ -932,44 +1064,54 @@ def __init__( A line of the hyperparameter is required to run the line search. Parameters: - maxiter : int + maxiter: int The maximum number of evaluations or iterations the optimizer can use. - tol : float + jac: bool + Whether to use the gradient of the objective function + wrt. the hyperparameters. + The line search optimizers cannot use gradients + of the objective function. + parallel : bool + Whether to calculate the grid points in parallel + over multiple CPUs. + seed: int (optional) + The random seed. + The seed can be an integer, RandomState, or Generator instance. + If not given, the default random number generator is used. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. + tol: float A tolerance criterion for convergence. - optimize : bool + optimize: bool Whether to optimize the line given by split it into smaller intervals. - multiple_min : bool + multiple_min: bool Whether to optimize multiple minimums or just optimize the lowest minimum. - ngrid : int + ngrid: int The number of grid points of the hyperparameter that is optimized. - loops : int + loops: int The number of loops where the grid points are made. - use_likelihood : bool + use_likelihood: bool Whether to use the objective function as a log-likelihood or not. If the use_likelihood=False, the objective function is scaled and shifted with the maximum value. - theta_index : int or None + theta_index: int or None The index of the hyperparameter that is optimized with the line search. If theta_index=None, then it will use the index of the length-scale. If theta_index=None and no length-scale, then theta_index=0. - parallel : bool - Whether to calculate the grid points in parallel - over multiple CPUs. - xtol : float + xtol: float A tolerance criterion of the hyperparameter for convergence. - ftol : float + ftol: float A tolerance criterion of the objective function for convergence. """ - # Line search optimizers cannot use gradients of the objective function - self.jac = False # Set the default theta_index self.theta_index = None # Set xtol and ftol to the tolerance if they are not given. @@ -977,6 +1119,10 @@ def __init__( # Set all the arguments self.update_arguments( maxiter=maxiter, + jac=jac, + parallel=parallel, + seed=seed, + dtype=dtype, tol=tol, optimize=optimize, multiple_min=multiple_min, @@ -984,7 +1130,6 @@ def __init__( loops=loops, use_likelihood=use_likelihood, theta_index=theta_index, - parallel=parallel, xtol=xtol, ftol=ftol, **kwargs, @@ -993,6 +1138,10 @@ def __init__( def update_arguments( self, maxiter=None, + jac=None, + parallel=None, + seed=None, + dtype=None, tol=None, optimize=None, multiple_min=None, @@ -1000,7 +1149,6 @@ def update_arguments( loops=None, use_likelihood=None, theta_index=None, - parallel=None, xtol=None, ftol=None, **kwargs, @@ -1010,74 +1158,74 @@ def update_arguments( The existing arguments are used if they are not given. Parameters: - maxiter : int + maxiter: int The maximum number of evaluations or iterations the optimizer can use. - tol : float + jac: bool + Whether to use the gradient of the objective function + wrt. the hyperparameters. + The line search optimizers cannot use gradients + of the objective function. + parallel : bool + Whether to calculate the grid points in parallel + over multiple CPUs. + seed: int (optional) + The random seed. + The seed can be an integer, RandomState, or Generator instance. + If not given, the default random number generator is used. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. + tol: float A tolerance criterion for convergence. - optimize : bool + optimize: bool Whether to optimize the line given by split it into smaller intervals. - multiple_min : bool + multiple_min: bool Whether to optimize multiple minimums or just optimize the lowest minimum. - ngrid : int + ngrid: int The number of grid points of the hyperparameter that is optimized. - loops : int + loops: int The number of loops where the grid points are made. - use_likelihood : bool + use_likelihood: bool Whether to use the objective function as a log-likelihood or not. If the use_likelihood=False, the objective function is scaled and shifted with the maximum value. - theta_index : int or None + theta_index: int or None The index of the hyperparameter that is optimized with the line search. If theta_index=None, then it will use the index of the length-scale. If theta_index=None and no length-scale, then theta_index=0. - parallel : bool - Whether to calculate the grid points in parallel - over multiple CPUs. - xtol : float + xtol: float A tolerance criterion of the hyperparameter for convergence. - ftol : float + ftol: float A tolerance criterion of the objective function for convergence. Returns: self: The updated object itself. """ - if maxiter is not None: - self.maxiter = int(maxiter) - if tol is not None: - self.tol = tol - if optimize is not None: - self.optimize = optimize - if multiple_min is not None: - self.multiple_min = multiple_min - if theta_index is not None: - self.theta_index = int(theta_index) - if parallel is not None: - self.parallel = parallel - if ngrid is not None: - if self.parallel: - from ase.parallel import world - - self.ngrid = int(int(ngrid / world.size) * world.size) - if self.ngrid == 0: - self.ngrid = world.size - else: - self.ngrid = ngrid - if loops is not None: - self.loops = int(loops) + super().update_arguments( + maxiter=maxiter, + jac=jac, + parallel=parallel, + seed=seed, + dtype=dtype, + tol=tol, + optimize=optimize, + multiple_min=multiple_min, + theta_index=theta_index, + xtol=xtol, + ftol=ftol, + ngrid=ngrid, + loops=loops, + ) if use_likelihood is not None: self.use_likelihood = use_likelihood - if xtol is not None: - self.xtol = xtol - if ftol is not None: - self.ftol = ftol return self def make_new_line( @@ -1093,23 +1241,22 @@ def make_new_line( Make new line/grid points from the variable transformation of the objective function. """ - from scipy.integrate import cumulative_trapezoid # Change the function to likelihood or to a scaled function from 0 to 1 if self.use_likelihood: - fs = np.exp(-(f_lists - np.nanmin(f_lists))) + fs = exp(-(f_lists - nanmin(f_lists))) else: - fs = -(f_lists - np.nanmax(f_lists)) - fs = fs / np.nanmax(fs) + fs = -(f_lists - nanmax(f_lists)) + fs = fs / nanmax(fs) # Calculate the cumulative distribution function values on the grid cdf = cumulative_trapezoid(fs, x=lines[:, theta_index], initial=0.0) cdf = cdf / cdf[-1] cdf_r = cdf.reshape(-1, 1) # Make new grid points on the inverse cumulative distribution function - dl = np.finfo(float).eps - newlines = np.linspace(0.0 + dl, 1.0 - dl, self.ngrid) + dl = self.eps + newlines = linspace(0.0 + dl, 1.0 - dl, self.ngrid) # Find the intervals where the new grid points are located - i_new = np.where((cdf_r[:-1] <= newlines) & (newlines < cdf_r[1:]))[0] + i_new = where((cdf_r[:-1] <= newlines) & (newlines < cdf_r[1:]))[0] i_new_a = i_new + 1 # Calculate the linear interpolation for the intervals of interest slope = (lines[i_new_a] - lines[i_new]) / ( @@ -1127,6 +1274,10 @@ def get_arguments(self): # Get the arguments given to the class in the initialization arg_kwargs = dict( maxiter=self.maxiter, + jac=self.jac, + parallel=self.parallel, + seed=self.seed, + dtype=self.dtype, tol=self.tol, optimize=self.optimize, multiple_min=self.multiple_min, @@ -1134,7 +1285,6 @@ def get_arguments(self): loops=self.loops, use_likelihood=self.use_likelihood, theta_index=self.theta_index, - parallel=self.parallel, xtol=self.xtol, ftol=self.ftol, ) diff --git a/catlearn/regression/gp/optimizers/localoptimizer.py b/catlearn/regression/gp/optimizers/localoptimizer.py index 87692b23..a52cbcf4 100644 --- a/catlearn/regression/gp/optimizers/localoptimizer.py +++ b/catlearn/regression/gp/optimizers/localoptimizer.py @@ -1,52 +1,100 @@ from .optimizer import Optimizer +from scipy.optimize import minimize class LocalOptimizer(Optimizer): - def __init__(self, maxiter=5000, jac=True, tol=1e-3, **kwargs): + def __init__( + self, + maxiter=5000, + jac=True, + parallel=False, + seed=None, + dtype=float, + tol=1e-3, + **kwargs, + ): """ The local optimizer used for optimzing the objective function wrt. the hyperparameters. Parameters: - maxiter : int + maxiter: int The maximum number of evaluations or iterations the optimizer can use. - jac : bool + jac: bool Whether to use the gradient of the objective function wrt. the hyperparameters. - tol : float + parallel: bool + Whether to use parallelization. + This is not implemented for this method. + seed: int (optional) + The random seed. + The seed can be an integer, RandomState, or Generator instance. + If not given, the default random number generator is used. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. + tol: float A tolerance criterion for convergence. """ - # This optimizer can not be parallelized - self.parallel = False # Set all the arguments - self.update_arguments(maxiter=maxiter, jac=jac, tol=tol, **kwargs) + self.update_arguments( + maxiter=maxiter, + jac=jac, + parallel=parallel, + seed=seed, + dtype=dtype, + tol=tol, + **kwargs, + ) def run(self, func, theta, parameters, model, X, Y, pdis, **kwargs): raise NotImplementedError() - def update_arguments(self, maxiter=None, jac=None, tol=None, **kwargs): + def update_arguments( + self, + maxiter=None, + jac=None, + parallel=None, + seed=None, + dtype=None, + tol=None, + **kwargs, + ): """ Update the optimizer with its arguments. The existing arguments are used if they are not given. Parameters: - maxiter : int + maxiter: int The maximum number of evaluations or iterations the optimizer can use. - jac : bool + jac: bool Whether to use the gradient of the objective function wrt. the hyperparameters. - tol : float + parallel: bool + Whether to use parallelization. + This is not implemented for this method. + seed: int (optional) + The random seed. + The seed can be an integer, RandomState, or Generator instance. + If not given, the default random number generator is used. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. + tol: float A tolerance criterion for convergence. Returns: self: The updated object itself. """ - if maxiter is not None: - self.maxiter = int(maxiter) - if jac is not None: - self.jac = jac + super().update_arguments( + maxiter=maxiter, + jac=jac, + parallel=parallel, + seed=seed, + dtype=dtype, + ) if tol is not None: self.tol = tol return self @@ -54,7 +102,14 @@ def update_arguments(self, maxiter=None, jac=None, tol=None, **kwargs): def get_arguments(self): "Get the arguments of the class itself." # Get the arguments given to the class in the initialization - arg_kwargs = dict(maxiter=self.maxiter, jac=self.jac, tol=self.tol) + arg_kwargs = dict( + maxiter=self.maxiter, + jac=self.jac, + parallel=self.parallel, + seed=self.seed, + dtype=self.dtype, + tol=self.tol, + ) # Get the constants made within the class constant_kwargs = dict() # Get the objects made within the class @@ -67,6 +122,9 @@ def __init__( self, maxiter=5000, jac=True, + parallel=False, + seed=None, + dtype=float, tol=1e-8, method="l-bfgs-b", bounds=None, @@ -82,29 +140,37 @@ def __init__( (https://docs.scipy.org/doc/scipy/reference/generated/scipy.optimize.minimize.html) Parameters: - maxiter : int + maxiter: int The maximum number of evaluations or iterations the optimizer can use. - jac : bool + jac: bool Whether to use the gradient of the objective function wrt. the hyperparameters. - tol : float + parallel: bool + Whether to use parallelization. + This is not implemented for this method. + seed: int (optional) + The random seed. + The seed can be an integer, RandomState, or Generator instance. + If not given, the default random number generator is used. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. + tol: float A tolerance criterion for convergence. - method : str + method: str The minimizer method used in SciPy. - bounds : HPBoundaries class + bounds: HPBoundaries class A class of the boundary conditions of the hyperparameters. All global optimization methods are using boundary conditions. - use_bounds : bool + use_bounds: bool Whether to use the boundary conditions or not. Only some methods can use boundary conditions. - options : dict + options: dict Solver options used in the SciPy minimizer. - opt_kwargs : dict + opt_kwargs: dict Extra arguments used in the SciPy minimizer. """ - # This optimizer can not be parallelized - self.parallel = False # Set boundary conditions self.bounds = None # Set options @@ -115,6 +181,9 @@ def __init__( self.update_arguments( maxiter=maxiter, jac=jac, + parallel=parallel, + seed=seed, + dtype=dtype, tol=tol, method=method, bounds=bounds, @@ -125,8 +194,6 @@ def __init__( ) def run(self, func, theta, parameters, model, X, Y, pdis, **kwargs): - from scipy.optimize import minimize - # Get the objective function arguments func_args = self.get_func_arguments( parameters, @@ -138,7 +205,7 @@ def run(self, func, theta, parameters, model, X, Y, pdis, **kwargs): ) # Get bounds or set it to default argument if self.use_bounds: - bounds = self.make_bounds(parameters, array=True) + bounds = self.make_bounds(parameters, use_array=True) else: bounds = None # Minimize objective function with SciPy @@ -163,10 +230,38 @@ def run(self, func, theta, parameters, model, X, Y, pdis, **kwargs): pdis, ) + def set_dtype(self, dtype, **kwargs): + super().set_dtype(dtype=dtype, **kwargs) + # Set the data type of the bounds + if self.bounds is not None and hasattr(self.bounds, "set_dtype"): + self.bounds.set_dtype(dtype=dtype, **kwargs) + return self + + def set_seed(self, seed=None, **kwargs): + super().set_seed(seed=seed, **kwargs) + # Set the random seed of the bounds + if self.bounds is not None and hasattr(self.bounds, "set_seed"): + self.bounds.set_seed(seed=seed, **kwargs) + return self + + def set_maxiter(self, maxiter, **kwargs): + super().set_maxiter(maxiter, **kwargs) + # Set the maximum number of iterations in the options + if self.method in ["nelder-mead"]: + self.options["maxfev"] = self.maxiter + elif self.method in ["l-bfgs-b", "tnc"]: + self.options["maxfun"] = self.maxiter + else: + self.options["maxiter"] = self.maxiter + return self + def update_arguments( self, maxiter=None, jac=None, + parallel=None, + seed=None, + dtype=None, tol=None, method=None, bounds=None, @@ -180,49 +275,47 @@ def update_arguments( The existing arguments are used if they are not given. Parameters: - maxiter : int + maxiter: int The maximum number of evaluations or iterations the optimizer can use. - jac : bool + jac: bool Whether to use the gradient of the objective function wrt. the hyperparameters. - tol : float + parallel: bool + Whether to use parallelization. + This is not implemented for this method. + seed: int (optional) + The random seed. + The seed can be an integer, RandomState, or Generator instance. + If not given, the default random number generator is used. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. + tol: float A tolerance criterion for convergence. - method : str + method: str The minimizer method used in SciPy. - bounds : HPBoundaries class + bounds: HPBoundaries class A class of the boundary conditions of the hyperparameters. All global optimization methods are using boundary conditions. - use_bounds : bool + use_bounds: bool Whether to use the boundary conditions or not. Only some methods can use boundary conditions. - options : dict + options: dict Solver options used in the SciPy minimizer. - opt_kwargs : dict + opt_kwargs: dict Extra arguments used in the SciPy minimizer. Returns: self: The updated object itself. """ - if jac is not None: - self.jac = jac - if tol is not None: - self.tol = tol if method is not None: self.method = method.lower() # If method is updated then maxiter must be updated - if maxiter is None: + if maxiter is None and hasattr(self, "maxiter"): maxiter = self.maxiter if options is not None: self.options.update(options) - if maxiter is not None: - self.maxiter = int(maxiter) - if self.method in ["nelder-mead"]: - self.options["maxfev"] = self.maxiter - elif self.method in ["l-bfgs-b", "tnc"]: - self.options["maxfun"] = self.maxiter - else: - self.options["maxiter"] = self.maxiter if bounds is not None: self.bounds = bounds.copy() if use_bounds is not None: @@ -240,13 +333,22 @@ def update_arguments( self.use_bounds = False if opt_kwargs is not None: self.opt_kwargs.update(opt_kwargs) + # Set the arguments for the parent class + super().update_arguments( + maxiter=maxiter, + jac=jac, + parallel=parallel, + seed=seed, + dtype=dtype, + tol=tol, + ) return self - def make_bounds(self, parameters, array=True, **kwargs): + def make_bounds(self, parameters, use_array=True, **kwargs): "Make the boundary conditions of the hyperparameters." return self.bounds.get_bounds( parameters=parameters, - array=array, + use_array=use_array, **kwargs, ) @@ -256,6 +358,9 @@ def get_arguments(self): arg_kwargs = dict( maxiter=self.maxiter, jac=self.jac, + parallel=self.parallel, + seed=self.seed, + dtype=self.dtype, tol=self.tol, method=self.method, bounds=self.bounds, @@ -275,6 +380,9 @@ def __init__( self, maxiter=5000, jac=True, + parallel=False, + seed=None, + dtype=float, tol=1e-8, method="l-bfgs-b", bounds=None, @@ -294,30 +402,43 @@ def __init__( excluded prior distributions. Parameters: - maxiter : int + maxiter: int The maximum number of evaluations or iterations the optimizer can use. - jac : bool + jac: bool Whether to use the gradient of the objective function wrt. the hyperparameters. - tol : float + parallel: bool + Whether to use parallelization. + This is not implemented for this method. + seed: int (optional) + The random seed. + The seed can be an integer, RandomState, or Generator instance. + If not given, the default random number generator is used. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. + tol: float A tolerance criterion for convergence. - method : str + method: str The minimizer method used in SciPy. - bounds : HPBoundaries class + bounds: HPBoundaries class A class of the boundary conditions of the hyperparameters. All global optimization methods are using boundary conditions. - use_bounds : bool + use_bounds: bool Whether to use the boundary conditions or not. Only some methods can use boundary conditions. - options : dict + options: dict Solver options used in the SciPy minimizer. - opt_kwargs : dict + opt_kwargs: dict Extra arguments used in the SciPy minimizer. """ super().__init__( maxiter=maxiter, jac=jac, + parallel=parallel, + seed=seed, + dtype=dtype, tol=tol, method=method, bounds=bounds, @@ -358,6 +479,9 @@ def __init__( self, maxiter=5000, jac=True, + parallel=False, + seed=None, + dtype=float, tol=1e-8, method="l-bfgs-b", bounds=None, @@ -375,30 +499,43 @@ def __init__( that also are optimized. Parameters: - maxiter : int + maxiter: int The maximum number of evaluations or iterations the optimizer can use. - jac : bool + jac: bool Whether to use the gradient of the objective function wrt. the hyperparameters. - tol : float + parallel: bool + Whether to use parallelization. + This is not implemented for this method. + seed: int (optional) + The random seed. + The seed can be an integer, RandomState, or Generator instance. + If not given, the default random number generator is used. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. + tol: float A tolerance criterion for convergence. - method : str + method: str The minimizer method used in SciPy. - bounds : HPBoundaries class + bounds: HPBoundaries class A class of the boundary conditions of the hyperparameters. All global optimization methods are using boundary conditions. - use_bounds : bool + use_bounds: bool Whether to use the boundary conditions or not. Only some methods can use boundary conditions. - options : dict + options: dict Solver options used in the SciPy minimizer. - opt_kwargs : dict + opt_kwargs: dict Extra arguments used in the SciPy minimizer. """ super().__init__( maxiter=maxiter, jac=jac, + parallel=parallel, + seed=seed, + dtype=dtype, tol=tol, method=method, bounds=bounds, @@ -415,7 +552,7 @@ def run(self, func, theta, parameters, model, X, Y, pdis, **kwargs): if self.bounds is None: return sol # Use the boundaries to give an educated guess of the hyperparmeters - theta_guess = self.guess_hp(parameters, array=True) + theta_guess = self.guess_hp(parameters, use_array=True) sol_ed = super().run( func, theta_guess, @@ -431,6 +568,10 @@ def run(self, func, theta, parameters, model, X, Y, pdis, **kwargs): sol["nit"] = 2 return sol - def guess_hp(self, parameters, array=True, **kwargs): + def guess_hp(self, parameters, use_array=True, **kwargs): "Make a guess of the hyperparameters from the boundary conditions." - return self.bounds.get_hp(parameters=parameters, array=array, **kwargs) + return self.bounds.get_hp( + parameters=parameters, + use_array=use_array, + **kwargs, + ) diff --git a/catlearn/regression/gp/optimizers/noisesearcher.py b/catlearn/regression/gp/optimizers/noisesearcher.py index 88787d7d..be833a39 100644 --- a/catlearn/regression/gp/optimizers/noisesearcher.py +++ b/catlearn/regression/gp/optimizers/noisesearcher.py @@ -1,14 +1,23 @@ -import numpy as np +from numpy import nanargmin from .linesearcher import ( - LineSearchOptimizer, - GoldenSearch, FineGridSearch, + GoldenSearch, + LineSearchOptimizer, + LocalOptimizer, TransGridSearch, ) class NoiseGrid(LineSearchOptimizer): - def __init__(self, maxiter=5000, **kwargs): + def __init__( + self, + maxiter=5000, + jac=False, + parallel=False, + seed=None, + dtype=float, + **kwargs, + ): """ The grid method is used as the line search optimizer. The grid of relative-noise hyperparameter values is calculated @@ -22,13 +31,32 @@ def __init__(self, maxiter=5000, **kwargs): maxiter : int The maximum number of evaluations or iterations the optimizer can use. + jac: bool + Whether to use the gradient of the objective function + wrt. the hyperparameters. + The line search optimizers cannot use gradients + of the objective function. + parallel: bool + Whether to calculate the grid points in parallel + over multiple CPUs. + This optimizer can not be parallelized. + seed: int (optional) + The random seed. + The seed can be an integer, RandomState, or Generator instance. + If not given, the default random number generator is used. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. """ - # This optimizer can not be parallelized - self.parallel = False - # Line search optimizers cannot use gradients of the objective function - self.jac = False # Set all the arguments - self.update_arguments(maxiter=maxiter, **kwargs) + self.update_arguments( + maxiter=maxiter, + jac=jac, + parallel=parallel, + seed=seed, + dtype=dtype, + **kwargs, + ) def run(self, func, line, parameters, model, X, Y, pdis, **kwargs): # Get the function arguments @@ -46,7 +74,7 @@ def run(self, func, line, parameters, model, X, Y, pdis, **kwargs): line = line.reshape(len_l, -1) f_list = self.calculate_values(line, func, func_args=func_args) # Find the optimal value - i_min = np.nanargmin(f_list) + i_min = nanargmin(f_list) sol = { "fun": f_list[i_min], "x": line[i_min], @@ -56,7 +84,20 @@ def run(self, func, line, parameters, model, X, Y, pdis, **kwargs): } return sol - def update_arguments(self, maxiter=None, **kwargs): + def set_parallel(self, parallel=False, **kwargs): + # This optimizer can not be parallelized + self.parallel = False + return self + + def update_arguments( + self, + maxiter=None, + jac=None, + parallel=None, + seed=None, + dtype=None, + **kwargs, + ): """ Update the optimizer with its arguments. The existing arguments are used if they are not given. @@ -65,12 +106,33 @@ def update_arguments(self, maxiter=None, **kwargs): maxiter : int The maximum number of evaluations or iterations the optimizer can use. + jac: bool + Whether to use the gradient of the objective function + wrt. the hyperparameters. + The line search optimizers cannot use gradients + of the objective function. + parallel: bool + Whether to calculate the grid points in parallel + over multiple CPUs. + This optimizer can not be parallelized. + seed: int (optional) + The random seed. + The seed can be an integer, RandomState, or Generator instance. + If not given, the default random number generator is used. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. Returns: self: The updated object itself. """ - if maxiter is not None: - self.maxiter = int(maxiter) + super(LocalOptimizer, self).update_arguments( + maxiter=maxiter, + jac=jac, + parallel=parallel, + seed=seed, + dtype=dtype, + ) return self def get_func_arguments( @@ -98,7 +160,13 @@ def calculate_values(self, thetas, func, func_args=(), **kwargs): def get_arguments(self): "Get the arguments of the class itself." # Get the arguments given to the class in the initialization - arg_kwargs = dict(maxiter=self.maxiter) + arg_kwargs = dict( + maxiter=self.maxiter, + jac=self.jac, + parallel=self.parallel, + seed=self.seed, + dtype=self.dtype, + ) # Get the constants made within the class constant_kwargs = dict() # Get the objects made within the class @@ -110,6 +178,10 @@ class NoiseGoldenSearch(GoldenSearch): def __init__( self, maxiter=5000, + jac=False, + parallel=False, + seed=None, + dtype=float, tol=1e-5, optimize=True, multiple_min=False, @@ -129,6 +201,22 @@ def __init__( maxiter : int The maximum number of evaluations or iterations the optimizer can use. + jac: bool + Whether to use the gradient of the objective function + wrt. the hyperparameters. + The line search optimizers cannot use gradients + of the objective function. + parallel: bool + Whether to calculate the grid points in parallel + over multiple CPUs. + This optimizer can not be parallelized. + seed: int (optional) + The random seed. + The seed can be an integer, RandomState, or Generator instance. + If not given, the default random number generator is used. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. tol : float A tolerance criterion for convergence. optimize : bool @@ -149,17 +237,12 @@ def __init__( A tolerance criterion of the objective function for convergence. """ - # This optimizer can not be parallelized - self.parallel = False - # Line search optimizers cannot use gradients of the objective function - self.jac = False - # Set the default theta_index - self.theta_index = None - # Set xtol and ftol to the tolerance if they are not given. - xtol, ftol = self.set_tols(tol, xtol=xtol, ftol=ftol) - # Set all the arguments - self.update_arguments( + super().__init__( maxiter=maxiter, + jac=jac, + parallel=parallel, + seed=seed, + dtype=dtype, tol=tol, optimize=optimize, multiple_min=multiple_min, @@ -191,11 +274,25 @@ def calculate_values(self, thetas, func, func_args=(), **kwargs): "Calculate a list of values with a function." return func.get_all_eig_fun(thetas, *func_args) + def set_jac(self, jac=False, **kwargs): + # Line search optimizers cannot use gradients of the objective function + self.jac = False + return self + + def set_parallel(self, parallel=False, **kwargs): + # This optimizer can not be parallelized + self.parallel = False + return self + class NoiseFineGridSearch(FineGridSearch): def __init__( self, maxiter=5000, + jac=False, + parallel=False, + seed=None, + dtype=float, tol=1e-5, optimize=True, multiple_min=False, @@ -218,6 +315,22 @@ def __init__( maxiter : int The maximum number of evaluations or iterations the optimizer can use. + jac: bool + Whether to use the gradient of the objective function + wrt. the hyperparameters. + The line search optimizers cannot use gradients + of the objective function. + parallel: bool + Whether to calculate the grid points in parallel + over multiple CPUs. + This optimizer can not be parallelized. + seed: int (optional) + The random seed. + The seed can be an integer, RandomState, or Generator instance. + If not given, the default random number generator is used. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. tol : float A tolerance criterion for convergence. optimize : bool @@ -238,17 +351,12 @@ def __init__( A tolerance criterion of the objective function for convergence. """ - # This optimizer can not be parallelized - self.parallel = False - # Line search optimizers cannot use gradients of the objective function - self.jac = False - # Set the default theta_index - self.theta_index = None - # Set xtol and ftol to the tolerance if they are not given. - xtol, ftol = self.set_tols(tol, xtol=xtol, ftol=ftol) - # Set all the arguments - self.update_arguments( + super().__init__( maxiter=maxiter, + jac=jac, + parallel=parallel, + seed=seed, + dtype=dtype, tol=tol, optimize=optimize, multiple_min=multiple_min, @@ -282,11 +390,25 @@ def calculate_values(self, thetas, func, func_args=(), **kwargs): "Calculate a list of values with a function." return func.get_all_eig_fun(thetas, *func_args) + def set_jac(self, jac=False, **kwargs): + # Line search optimizers cannot use gradients of the objective function + self.jac = False + return self + + def set_parallel(self, parallel=False, **kwargs): + # This optimizer can not be parallelized + self.parallel = False + return self + class NoiseTransGridSearch(TransGridSearch): def __init__( self, maxiter=5000, + jac=False, + parallel=False, + seed=None, + dtype=float, tol=1e-5, optimize=True, multiple_min=False, @@ -312,6 +434,22 @@ def __init__( maxiter : int The maximum number of evaluations or iterations the optimizer can use. + jac: bool + Whether to use the gradient of the objective function + wrt. the hyperparameters. + The line search optimizers cannot use gradients + of the objective function. + parallel: bool + Whether to calculate the grid points in parallel + over multiple CPUs. + This optimizer can not be parallelized. + seed: int (optional) + The random seed. + The seed can be an integer, RandomState, or Generator instance. + If not given, the default random number generator is used. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. tol : float A tolerance criterion for convergence. optimize : bool @@ -342,17 +480,12 @@ def __init__( A tolerance criterion of the objective function for convergence. """ - # This optimizer can not be parallelized - self.parallel = False - # Line search optimizers cannot use gradients of the objective function - self.jac = False - # Set the default theta_index - self.theta_index = None - # Set xtol and ftol to the tolerance if they are not given. - xtol, ftol = self.set_tols(tol, xtol=xtol, ftol=ftol) - # Set all the arguments - self.update_arguments( + super().__init__( maxiter=maxiter, + jac=jac, + parallel=parallel, + seed=seed, + dtype=dtype, tol=tol, optimize=optimize, multiple_min=multiple_min, @@ -386,3 +519,13 @@ def get_fun(self, func, **kwargs): def calculate_values(self, thetas, func, func_args=(), **kwargs): "Calculate a list of values with a function." return func.get_all_eig_fun(thetas, *func_args) + + def set_jac(self, jac=False, **kwargs): + # Line search optimizers cannot use gradients of the objective function + self.jac = False + return self + + def set_parallel(self, parallel=False, **kwargs): + # This optimizer can not be parallelized + self.parallel = False + return self diff --git a/catlearn/regression/gp/optimizers/optimizer.py b/catlearn/regression/gp/optimizers/optimizer.py index 30c3a60d..a72c06b2 100644 --- a/catlearn/regression/gp/optimizers/optimizer.py +++ b/catlearn/regression/gp/optimizers/optimizer.py @@ -1,25 +1,50 @@ +from numpy import argmin, array, asarray, empty, finfo, inf +from numpy.random import default_rng, Generator, RandomState from scipy.optimize import OptimizeResult -import numpy as np +from ase.parallel import world, broadcast class Optimizer: - def __init__(self, maxiter=5000, jac=True, **kwargs): + def __init__( + self, + maxiter=5000, + jac=True, + parallel=False, + seed=None, + dtype=float, + **kwargs, + ): """ The optimizer used for optimzing the objective function wrt. the hyperparameters. Parameters: - maxiter : int + maxiter: int The maximum number of evaluations or iterations the optimizer can use. - jac : bool + jac: bool Whether to use the gradient of the objective function wrt. the hyperparameters. + parallel: bool + Whether to use parallelization. + This is not implemented for this method. + seed: int (optional) + The random seed. + The seed can be an integer, RandomState, or Generator instance. + If not given, the default random number generator is used. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. """ - # This optimizer can not be parallelized - self.parallel = False # Set all the arguments - self.update_arguments(maxiter=maxiter, jac=jac, **kwargs) + self.update_arguments( + maxiter=maxiter, + jac=jac, + parallel=parallel, + seed=seed, + dtype=dtype, + **kwargs, + ) def run(self, func, theta, parameters, model, X, Y, pdis, **kwargs): """ @@ -27,51 +52,164 @@ def run(self, func, theta, parameters, model, X, Y, pdis, **kwargs): wrt. the hyperparameters. Parameters: - func : ObjectiveFunction class object + func: ObjectiveFunction class object The objective function class that is used to calculate the value. - theta : (H) array + theta: (H) array An array with the hyperparameter values. - parameters : (H) list of strings + parameters: (H) list of strings A list of names of the hyperparameters. - model : Model class object + model: Model class object The Machine Learning Model with kernel and prior that are optimized. - X : (N,D) array + X: (N,D) array Training features with N data points and D dimensions. - Y : (N,1) array or (N,D+1) array + Y: (N,1) array or (N,D+1) array Training targets with or without derivatives with N data points. - pdis : dict + pdis: dict A dict of prior distributions for each hyperparameter type. Returns: - dict : A solution dictionary with objective function value, + dict: A solution dictionary with objective function value, optimized hyperparameters, success statement, and number of used evaluations. """ raise NotImplementedError() - def update_arguments(self, maxiter=None, jac=None, **kwargs): + def set_dtype(self, dtype, **kwargs): + """ + Set the data type of the arrays. + + Parameters: + dtype: type + The data type of the arrays. + + Returns: + self: The updated object itself. + """ + # Set the data type + self.dtype = dtype + # Set a small number to avoid division by zero + self.eps = 1.1 * finfo(self.dtype).eps + return self + + def set_seed(self, seed=None, **kwargs): + """ + Set the random seed. + + Parameters: + seed: int (optional) + The random seed. + The seed can be an integer, RandomState, or Generator instance. + If not given, the default random number generator is used. + + Returns: + self: The instance itself. + """ + if seed is not None: + self.seed = seed + if isinstance(seed, int): + self.rng = default_rng(self.seed) + elif isinstance(seed, Generator) or isinstance(seed, RandomState): + self.rng = seed + else: + self.seed = None + self.rng = default_rng() + return self + + def set_maxiter(self, maxiter, **kwargs): + """ + Set the maximum number of iterations. + + Parameters: + maxiter: int + The maximum number of evaluations or iterations + the optimizer can use. + + Returns: + self: The updated object itself. + """ + self.maxiter = int(maxiter) + return self + + def set_jac(self, jac=True, **kwargs): + """ + Set whether to use the gradient of the objective function + wrt. the hyperparameters. + + Parameters: + jac: bool + Whether to use the gradient of the objective function + wrt. the hyperparameters. + + Returns: + self: The updated object itself. + """ + self.jac = jac + return self + + def set_parallel(self, parallel=False, **kwargs): + """ + Set whether to use parallelization. + + Parameters: + parallel: bool + Whether to use parallelization. + + Returns: + self: The updated object itself. + """ + # This optimizer can not be parallelized + self.parallel = False + return self + + def update_arguments( + self, + maxiter=None, + jac=None, + parallel=None, + seed=None, + dtype=None, + **kwargs, + ): """ Update the optimizer with its arguments. The existing arguments are used if they are not given. Parameters: - maxiter : int + maxiter: int The maximum number of evaluations or iterations the optimizer can use. - jac : bool + jac: bool Whether to use the gradient of the objective function wrt. the hyperparameters. + parallel: bool + Whether to use parallelization. + This is not implemented for this method. + seed: int (optional) + The random seed. + The seed can be an integer, RandomState, or Generator instance. + If not given, the default random number generator is used. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. Returns: self: The updated object itself. """ - if maxiter is not None: - self.maxiter = int(maxiter) if jac is not None: - self.jac = jac + self.set_jac(jac) + if parallel is not None or not hasattr(self, "parallel"): + self.set_parallel(parallel) + if maxiter is not None: + self.set_maxiter(maxiter) + # Set the seed + if seed is not None or not hasattr(self, "seed"): + self.set_seed(seed) + # Set the data type + if dtype is not None or not hasattr(self, "dtype"): + self.set_dtype(dtype) return self def get_final_solution( @@ -113,20 +251,18 @@ def get_final_solution_parallel( **kwargs, ): "Get all final solutions from each function at each rank." - from ase.parallel import world, broadcast - size = world.size fun_sol = func.get_stored_solution() sol = func.get_solution(sol, parameters, model, X, Y, pdis) fun_sols = [broadcast(fun_sol["fun"], root=r) for r in range(size)] - rank_min = np.argmin(fun_sols) + rank_min = argmin(fun_sols) return broadcast(sol, root=rank_min) def get_empty_solution(self, **kwargs): "Get an empty solution without any function evaluations." sol = { - "fun": np.inf, - "x": np.array([]), + "fun": inf, + "x": empty(0, dtype=self.dtype), "success": False, "nfev": 0, "nit": 0, @@ -137,7 +273,7 @@ def get_empty_solution(self, **kwargs): def get_initial_solution(self, theta, func, func_args=(), **kwargs): "Get a solution with the evaluation of the initial hyperparameters." sol = { - "fun": np.inf, + "fun": inf, "x": theta, "success": False, "nfev": 1, @@ -172,22 +308,25 @@ def calculate_values(self, thetas, func, func_args=(), **kwargs): func_args=func_args, **kwargs, ) - return np.array([func.function(theta, *func_args) for theta in thetas]) + return asarray( + [func.function(theta, *func_args) for theta in thetas], + dtype=self.dtype, + ) def calculate_values_parallel(self, thetas, func, func_args=(), **kwargs): "Calculate a list of values with a function in parallel." - from ase.parallel import world, broadcast - rank, size = world.rank, world.size - f_list = np.array( + f_list = asarray( [ func.function(theta, *func_args) for t, theta in enumerate(thetas) if rank == t % size - ] + ], + dtype=self.dtype, ) - return np.array( - [broadcast(f_list, root=r) for r in range(size)] + return asarray( + [broadcast(f_list, root=r) for r in range(size)], + dtype=self.dtype, ).T.reshape(-1) def compare_solutions(self, sol1, sol2, **kwargs): @@ -210,7 +349,8 @@ def compare_solutions(self, sol1, sol2, **kwargs): def make_hp(self, theta, parameters, **kwargs): "Make hyperparameter dictionary from lists." - theta, parameters = np.array(theta), np.array(parameters) + theta = array(theta, dtype=self.dtype) + parameters = asarray(parameters) parameters_set = sorted(set(parameters)) hp = {para_s: theta[parameters == para_s] for para_s in parameters_set} return hp @@ -218,7 +358,13 @@ def make_hp(self, theta, parameters, **kwargs): def get_arguments(self): "Get the arguments of the class itself." # Get the arguments given to the class in the initialization - arg_kwargs = dict(maxiter=self.maxiter, jac=self.jac) + arg_kwargs = dict( + maxiter=self.maxiter, + jac=self.jac, + parallel=self.parallel, + seed=self.seed, + dtype=self.dtype, + ) # Get the constants made within the class constant_kwargs = dict() # Get the objects made within the class @@ -250,46 +396,55 @@ def __repr__(self): class FunctionEvaluation(Optimizer): - def __init__(self, jac=True, **kwargs): + def __init__(self, jac=True, parallel=False, dtype=float, **kwargs): """ A method used for evaluating the objective function for the given hyperparameters. Parameters: - jac : bool + jac: bool Whether to use the gradient of the objective function wrt. the hyperparameters. + parallel: bool + Whether to use parallelization. + This is not implemented for this method. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. """ - # This optimizer can not be parallelized - self.parallel = False # Set all the arguments - self.update_arguments(jac=jac, **kwargs) + self.update_arguments( + jac=jac, + parallel=parallel, + dtype=dtype, + **kwargs, + ) def run(self, func, theta, parameters, model, X, Y, pdis, **kwargs): """ Run the evaluation of the objective function wrt. the hyperparameters. Parameters: - func : ObjectiveFunction class object + func: ObjectiveFunction class object The objective function class that is used to calculate the value. - theta : (H) array + theta: (H) array An array with the hyperparameter values. - parameters : (H) list of strings + parameters: (H) list of strings A list of names of the hyperparameters. - model : Model class object + model: Model class object The Machine Learning Model with kernel and prior that are optimized. - X : (N,D) array + X: (N,D) array Training features with N data points and D dimensions. - Y : (N,1) array or (N,D+1) array + Y: (N,1) array or (N,D+1) array Training targets with or without derivatives with N data points. - pdis : dict + pdis: dict A dict of prior distributions for each hyperparameter type. Returns: - dict : A solution dictionary with objective function value, + dict: A solution dictionary with objective function value, hyperparameters, success statement, and number of used evaluations. """ @@ -313,27 +468,14 @@ def run(self, func, theta, parameters, model, X, Y, pdis, **kwargs): pdis, ) - def update_arguments(self, maxiter=None, jac=None, **kwargs): - """ - Update the class with its arguments. - The existing arguments are used if they are not given. - - Parameters: - jac : bool - Whether to use the gradient of the objective function - wrt. the hyperparameters. - - Returns: - self: The updated object itself. - """ - if jac is not None: - self.jac = jac - return self - def get_arguments(self): "Get the arguments of the class itself." # Get the arguments given to the class in the initialization - arg_kwargs = dict(jac=self.jac) + arg_kwargs = dict( + jac=self.jac, + parallel=self.parallel, + dtype=self.dtype, + ) # Get the constants made within the class constant_kwargs = dict() # Get the objects made within the class diff --git a/catlearn/regression/gp/pdistributions/gamma.py b/catlearn/regression/gp/pdistributions/gamma.py index 71292008..593903a1 100644 --- a/catlearn/regression/gp/pdistributions/gamma.py +++ b/catlearn/regression/gp/pdistributions/gamma.py @@ -1,10 +1,10 @@ -import numpy as np +from numpy import array, asarray, exp, log, ndarray, sum as sum_, sqrt from .pdistributions import Prior_distribution from scipy.special import loggamma class Gamma_prior(Prior_distribution): - def __init__(self, a=1e-20, b=1e-20, **kwargs): + def __init__(self, a=1e-20, b=1e-20, dtype=float, **kwargs): """ Gamma prior distribution used for each type of hyperparameters in log-space. @@ -20,19 +20,39 @@ def __init__(self, a=1e-20, b=1e-20, **kwargs): The shape parameter. b: float or (H) array The scale parameter. + dtype: type + The data type of the arrays. """ - self.update_arguments(a=a, b=b, **kwargs) + self.update_arguments(a=a, b=b, dtype=dtype, **kwargs) def ln_pdf(self, x): - ln_pdf = self.lnpre + 2.0 * self.a * x - self.b * np.exp(2.0 * x) + ln_pdf = self.lnpre + 2.0 * self.a * x - self.b * exp(2.0 * x) if self.nosum: return ln_pdf - return np.sum(ln_pdf, axis=-1) + return sum_(ln_pdf, axis=-1) def ln_deriv(self, x): - return 2.0 * self.a - 2.0 * self.b * np.exp(2.0 * x) + return 2.0 * self.a - 2.0 * self.b * exp(2.0 * x) - def update_arguments(self, a=None, b=None, **kwargs): + def calc_lnpre(self): + """ + Calculate the lnpre value. + This is used to calculate the ln_pdf value. + """ + self.lnpre = log(2.0) + self.a * log(self.b) - loggamma(self.a) + return self.lnpre + + def set_dtype(self, dtype, **kwargs): + super().set_dtype(dtype, **kwargs) + if hasattr(self, "a") and isinstance(self.a, ndarray): + self.a = asarray(self.a, dtype=self.dtype) + if hasattr(self, "b") and isinstance(self.b, ndarray): + self.b = asarray(self.b, dtype=self.dtype) + if hasattr(self, "lnpre"): + self.calc_lnpre() + return self + + def update_arguments(self, a=None, b=None, dtype=None, **kwargs): """ Update the object with its arguments. The existing arguments are used if they are not given. @@ -42,21 +62,27 @@ def update_arguments(self, a=None, b=None, **kwargs): The shape parameter. b: float or (H) array The scale parameter. + dtype: type + The data type of the arrays. Returns: self: The updated object itself. """ + # Set the arguments for the parent class + super().update_arguments( + dtype=dtype, + ) if a is not None: if isinstance(a, (float, int)): self.a = a else: - self.a = np.array(a).reshape(-1) + self.a = array(a, dtype=self.dtype).reshape(-1) if b is not None: if isinstance(b, (float, int)): self.b = b else: - self.b = np.array(b).reshape(-1) - self.lnpre = np.log(2.0) + self.a * np.log(self.b) - loggamma(self.a) + self.b = array(b, dtype=self.dtype).reshape(-1) + self.calc_lnpre() if isinstance(self.a, (float, int)) and isinstance( self.b, (float, int) ): @@ -66,14 +92,16 @@ def update_arguments(self, a=None, b=None, **kwargs): return self def mean_var(self, mean, var): - mean, var = np.exp(mean), np.exp(2.0 * np.sqrt(var)) + mean = (exp(mean),) + var = exp(2.0 * sqrt(var)) a = mean**2.0 / var if a == 0: a = 1 return self.update_arguments(a=a, b=mean / var) def min_max(self, min_v, max_v): - min_v, max_v = np.exp(min_v), np.exp(max_v) + min_v = exp(min_v) + max_v = exp(max_v) mean = 0.5 * (min_v + max_v) var = 0.5 * (max_v - min_v) ** 2 return self.update_arguments(a=mean**2 / var, b=mean / var) @@ -81,7 +109,7 @@ def min_max(self, min_v, max_v): def get_arguments(self): "Get the arguments of the class itself." # Get the arguments given to the class in the initialization - arg_kwargs = dict(a=self.a, b=self.b) + arg_kwargs = dict(a=self.a, b=self.b, dtype=self.dtype) # Get the constants made within the class constant_kwargs = dict() # Get the objects made within the class diff --git a/catlearn/regression/gp/pdistributions/gen_normal.py b/catlearn/regression/gp/pdistributions/gen_normal.py index 0881f8f9..321d589e 100644 --- a/catlearn/regression/gp/pdistributions/gen_normal.py +++ b/catlearn/regression/gp/pdistributions/gen_normal.py @@ -1,9 +1,9 @@ -import numpy as np +from numpy import array, asarray, log, ndarray, sum as sum_, sqrt from .pdistributions import Prior_distribution class Gen_normal_prior(Prior_distribution): - def __init__(self, mu=0.0, s=10.0, v=2, **kwargs): + def __init__(self, mu=0.0, s=10.0, v=2, dtype=float, **kwargs): """ Independent Generalized Normal prior distribution used for each type of hyperparameters in log-space. @@ -19,25 +19,37 @@ def __init__(self, mu=0.0, s=10.0, v=2, **kwargs): The scale of the generalized normal distribution. v: float or (H) array The shape or magnitude of the generalized normal distribution. + dtype: type + The data type of the arrays. """ - self.update_arguments(mu=mu, s=s, v=v, **kwargs) + self.update_arguments(mu=mu, s=s, v=v, dtype=dtype, **kwargs) def ln_pdf(self, x): - lnpdf = ( + ln_pdf = ( -(((x - self.mu) / self.s) ** (2 * self.v)) - - np.log(self.s) - + np.log(0.52) + - log(self.s) + + log(0.52) ) if self.nosum: - return lnpdf - return np.sum(lnpdf, axis=-1) + return ln_pdf + return sum_(ln_pdf, axis=-1) def ln_deriv(self, x): return (-(2.0 * self.v) * ((x - self.mu) ** (2 * self.v - 1))) / ( self.s ** (2 * self.v) ) - def update_arguments(self, mu=None, s=None, v=None, **kwargs): + def set_dtype(self, dtype, **kwargs): + super().set_dtype(dtype, **kwargs) + if hasattr(self, "mu") and isinstance(self.mu, ndarray): + self.mu = asarray(self.mu, dtype=self.dtype) + if hasattr(self, "std") and isinstance(self.std, ndarray): + self.std = asarray(self.std, dtype=self.dtype) + if hasattr(self, "v") and isinstance(self.v, ndarray): + self.v = asarray(self.v, dtype=self.dtype) + return self + + def update_arguments(self, mu=None, s=None, v=None, dtype=None, **kwargs): """ Update the object with its arguments. The existing arguments are used if they are not given. @@ -49,25 +61,31 @@ def update_arguments(self, mu=None, s=None, v=None, **kwargs): The scale of the generalized normal distribution. v: float or (H) array The shape or magnitude of the generalized normal distribution. + dtype: type + The data type of the arrays. Returns: self: The updated object itself. """ + # Set the arguments for the parent class + super().update_arguments( + dtype=dtype, + ) if mu is not None: if isinstance(mu, (float, int)): self.mu = mu else: - self.mu = np.array(mu).reshape(-1) + self.mu = array(mu, dtype=self.dtype).reshape(-1) if s is not None: if isinstance(s, (float, int)): self.s = s else: - self.s = np.array(s).reshape(-1) + self.s = array(s, dtype=self.dtype).reshape(-1) if v is not None: if isinstance(v, (float, int)): self.v = v else: - self.v = np.array(v).reshape(-1) + self.v = array(v, dtype=self.dtype).reshape(-1) if ( isinstance(self.mu, (float, int)) and isinstance(self.s, (float, int)) @@ -79,19 +97,19 @@ def update_arguments(self, mu=None, s=None, v=None, **kwargs): return self def mean_var(self, mean, var): - return self.update_arguments(mu=mean, s=np.sqrt(var / 0.32)) + return self.update_arguments(mu=mean, s=sqrt(var / 0.32)) def min_max(self, min_v, max_v): mu = (max_v + min_v) / 2.0 return self.update_arguments( mu=mu, - s=np.sqrt(2.0 / 0.32) * (max_v - mu), + s=sqrt(2.0 / 0.32) * (max_v - mu), ) def get_arguments(self): "Get the arguments of the class itself." # Get the arguments given to the class in the initialization - arg_kwargs = dict(mu=self.mu, s=self.s, v=self.v) + arg_kwargs = dict(mu=self.mu, s=self.s, v=self.v, dtype=self.dtype) # Get the constants made within the class constant_kwargs = dict() # Get the objects made within the class diff --git a/catlearn/regression/gp/pdistributions/invgamma.py b/catlearn/regression/gp/pdistributions/invgamma.py index 26d5224d..4460defd 100644 --- a/catlearn/regression/gp/pdistributions/invgamma.py +++ b/catlearn/regression/gp/pdistributions/invgamma.py @@ -1,10 +1,10 @@ -import numpy as np +from numpy import array, asarray, exp, log, ndarray, sum as sum_, sqrt from .pdistributions import Prior_distribution from scipy.special import loggamma class Invgamma_prior(Prior_distribution): - def __init__(self, a=1e-20, b=1e-20, **kwargs): + def __init__(self, a=1e-20, b=1e-20, dtype=float, **kwargs): """ Inverse-Gamma prior distribution used for each type of hyperparameters in log-space. @@ -20,19 +20,39 @@ def __init__(self, a=1e-20, b=1e-20, **kwargs): The shape parameter. b: float or (H) array The scale parameter. + dtype: type + The data type of the arrays. """ - self.update_arguments(a=a, b=b, **kwargs) + self.update_arguments(a=a, b=b, dtype=dtype, **kwargs) def ln_pdf(self, x): - ln_pdf = self.lnpre - 2.0 * self.a * x - self.b * np.exp(-2.0 * x) + ln_pdf = self.lnpre - 2.0 * self.a * x - self.b * exp(-2.0 * x) if self.nosum: return ln_pdf - return np.sum(ln_pdf, axis=-1) + return sum_(ln_pdf, axis=-1) def ln_deriv(self, x): - return -2.0 * self.a + 2.0 * self.b * np.exp(-2.0 * x) + return -2.0 * self.a + 2.0 * self.b * exp(-2.0 * x) - def update_arguments(self, a=None, b=None, **kwargs): + def calc_lnpre(self): + """ + Calculate the lnpre value. + This is used to calculate the ln_pdf value. + """ + self.lnpre = log(2.0) + self.a * log(self.b) - loggamma(self.a) + return self.lnpre + + def set_dtype(self, dtype, **kwargs): + super().set_dtype(dtype, **kwargs) + if hasattr(self, "a") and isinstance(self.a, ndarray): + self.a = asarray(self.a, dtype=self.dtype) + if hasattr(self, "b") and isinstance(self.b, ndarray): + self.b = asarray(self.b, dtype=self.dtype) + if hasattr(self, "lnpre"): + self.calc_lnpre() + return self + + def update_arguments(self, a=None, b=None, dtype=None, **kwargs): """ Update the object with its arguments. The existing arguments are used if they are not given. @@ -42,21 +62,27 @@ def update_arguments(self, a=None, b=None, **kwargs): The shape parameter. b: float or (H) array The scale parameter. + dtype: type + The data type of the arrays. Returns: self: The updated object itself. """ + # Set the arguments for the parent class + super().update_arguments( + dtype=dtype, + ) if a is not None: if isinstance(a, (float, int)): self.a = a else: - self.a = np.array(a).reshape(-1) + self.a = array(a, dtype=self.dtype).reshape(-1) if b is not None: if isinstance(b, (float, int)): self.b = b else: - self.b = np.array(b).reshape(-1) - self.lnpre = np.log(2.0) + self.a * np.log(self.b) - loggamma(self.a) + self.b = array(b, dtype=self.dtype).reshape(-1) + self.calc_lnpre() if isinstance(self.a, (float, int)) and isinstance( self.b, (float, int) ): @@ -66,12 +92,13 @@ def update_arguments(self, a=None, b=None, **kwargs): return self def mean_var(self, mean, var): - mean, var = np.exp(mean), np.exp(2.0 * np.sqrt(var)) - min_v = mean - np.sqrt(var) * 2.0 + mean = exp(mean) + var = exp(2.0 * sqrt(var)) + min_v = mean - sqrt(var) * 2.0 return self.update_arguments(a=min_v, b=min_v) def min_max(self, min_v, max_v): - b = np.exp(2.0 * min_v) + b = exp(2.0 * min_v) return self.update_arguments(a=b, b=b) def copy(self): @@ -80,7 +107,7 @@ def copy(self): def get_arguments(self): "Get the arguments of the class itself." # Get the arguments given to the class in the initialization - arg_kwargs = dict(a=self.a, b=self.b) + arg_kwargs = dict(a=self.a, b=self.b, dtype=self.dtype) # Get the constants made within the class constant_kwargs = dict() # Get the objects made within the class diff --git a/catlearn/regression/gp/pdistributions/normal.py b/catlearn/regression/gp/pdistributions/normal.py index 4789b66d..375b90a8 100644 --- a/catlearn/regression/gp/pdistributions/normal.py +++ b/catlearn/regression/gp/pdistributions/normal.py @@ -1,9 +1,9 @@ -import numpy as np +from numpy import array, asarray, log, ndarray, pi, sum as sum_, sqrt from .pdistributions import Prior_distribution class Normal_prior(Prior_distribution): - def __init__(self, mu=0.0, std=10.0, **kwargs): + def __init__(self, mu=0.0, std=10.0, dtype=float, **kwargs): """ Independent Normal prior distribution used for each type of hyperparameters in log-space. @@ -17,23 +17,33 @@ def __init__(self, mu=0.0, std=10.0, **kwargs): The mean of the normal distribution. std: float or (H) array The standard deviation of the normal distribution. + dtype: type + The data type of the arrays. """ - self.update_arguments(mu=mu, std=std, **kwargs) + self.update_arguments(mu=mu, std=std, dtype=dtype, **kwargs) def ln_pdf(self, x): ln_pdf = ( - -np.log(self.std) - - 0.5 * np.log(2.0 * np.pi) + -log(self.std) + - 0.5 * log(2.0 * pi) - 0.5 * ((x - self.mu) / self.std) ** 2 ) if self.nosum: return ln_pdf - return np.sum(ln_pdf, axis=-1) + return sum_(ln_pdf, axis=-1) def ln_deriv(self, x): return -(x - self.mu) / self.std**2 - def update_arguments(self, mu=None, std=None, **kwargs): + def set_dtype(self, dtype, **kwargs): + super().set_dtype(dtype, **kwargs) + if hasattr(self, "mu") and isinstance(self.mu, ndarray): + self.mu = asarray(self.mu, dtype=self.dtype) + if hasattr(self, "std") and isinstance(self.std, ndarray): + self.std = asarray(self.std, dtype=self.dtype) + return self + + def update_arguments(self, mu=None, std=None, dtype=None, **kwargs): """ Update the object with its arguments. The existing arguments are used if they are not given. @@ -43,20 +53,26 @@ def update_arguments(self, mu=None, std=None, **kwargs): The mean of the normal distribution. std: float or (H) array The standard deviation of the normal distribution. + dtype: type + The data type of the arrays. Returns: self: The updated object itself. """ + # Set the arguments for the parent class + super().update_arguments( + dtype=dtype, + ) if mu is not None: if isinstance(mu, (float, int)): self.mu = mu else: - self.mu = np.array(mu).reshape(-1) + self.mu = array(mu, dtype=self.dtype).reshape(-1) if std is not None: if isinstance(std, (float, int)): self.std = std else: - self.std = np.array(std).reshape(-1) + self.std = array(std, dtype=self.dtype).reshape(-1) if isinstance(self.mu, (float, int)) and isinstance( self.std, (float, int) ): @@ -66,16 +82,16 @@ def update_arguments(self, mu=None, std=None, **kwargs): return self def mean_var(self, mean, var): - return self.update_arguments(mu=mean, std=np.sqrt(var)) + return self.update_arguments(mu=mean, std=sqrt(var)) def min_max(self, min_v, max_v): mu = 0.5 * (min_v + max_v) - return self.update_arguments(mu=mu, std=np.sqrt(2.0) * (max_v - mu)) + return self.update_arguments(mu=mu, std=sqrt(2.0) * (max_v - mu)) def get_arguments(self): "Get the arguments of the class itself." # Get the arguments given to the class in the initialization - arg_kwargs = dict(mu=self.mu, std=self.std) + arg_kwargs = dict(mu=self.mu, std=self.std, dtype=self.dtype) # Get the constants made within the class constant_kwargs = dict() # Get the objects made within the class diff --git a/catlearn/regression/gp/pdistributions/pdistributions.py b/catlearn/regression/gp/pdistributions/pdistributions.py index f2ef668f..9a02b796 100644 --- a/catlearn/regression/gp/pdistributions/pdistributions.py +++ b/catlearn/regression/gp/pdistributions/pdistributions.py @@ -1,8 +1,8 @@ -import numpy as np +from numpy import exp class Prior_distribution: - def __init__(self, **kwargs): + def __init__(self, dtype=float, **kwargs): """ Prior probability distribution used for each type of hyperparameters in log-space. @@ -10,8 +10,12 @@ def __init__(self, **kwargs): it is given in the axis=-1. If multiple values (M) of the hyperparameter(/s) are calculated simultaneously, it has to be in a (M,H) array. + + Parameters: + dtype: type + The data type of the arrays. """ - self.update_arguments(**kwargs) + self.update_arguments(dtype=dtype, **kwargs) def pdf(self, x): """ @@ -33,7 +37,7 @@ def pdf(self, x): (M) array: M values of the probability density function if M different values is given. """ - return np.exp(self.ln_pdf(x)) + return exp(self.ln_pdf(x)) def deriv(self, x): "The derivative of the probability density function as respect to x." @@ -68,14 +72,36 @@ def ln_deriv(self, x): """ raise NotImplementedError() - def update_arguments(self, **kwargs): + def set_dtype(self, dtype, **kwargs): + """ + Set the data type of the arrays. + + Parameters: + dtype: type + The data type of the arrays. + + Returns: + self: The updated object itself. + """ + # Set the data type + self.dtype = dtype + return self + + def update_arguments(self, dtype=None, **kwargs): """ Update the object with its arguments. The existing arguments are used if they are not given. + Parameters: + dtype: type + The data type of the arrays. + Returns: self: The updated object itself. """ + # Set the data type + if dtype is not None or not hasattr(self, "dtype"): + self.set_dtype(dtype=dtype) return self def mean_var(self, mean, var): @@ -95,7 +121,7 @@ def min_max(self, min_v, max_v): def get_arguments(self): "Get the arguments of the class itself." # Get the arguments given to the class in the initialization - arg_kwargs = dict() + arg_kwargs = dict(dtype=self.dtype) # Get the constants made within the class constant_kwargs = dict() # Get the objects made within the class diff --git a/catlearn/regression/gp/pdistributions/uniform.py b/catlearn/regression/gp/pdistributions/uniform.py index 7b876f38..388877ff 100644 --- a/catlearn/regression/gp/pdistributions/uniform.py +++ b/catlearn/regression/gp/pdistributions/uniform.py @@ -1,9 +1,19 @@ -import numpy as np +from numpy import ( + array, + asarray, + log, + inf, + nan_to_num, + ndarray, + sum as sum_, + sqrt, + where, +) from .pdistributions import Prior_distribution class Uniform_prior(Prior_distribution): - def __init__(self, start=-18.0, end=18.0, prob=1.0, **kwargs): + def __init__(self, start=-18.0, end=18.0, prob=1.0, dtype=float, **kwargs): """ Uniform prior distribution used for each type of hyperparameters in log-space. @@ -21,24 +31,49 @@ def __init__(self, start=-18.0, end=18.0, prob=1.0, **kwargs): the hyperparameter in log-space. prob: float or (H) array The non-zero prior distribution value. + dtype: type + The data type of the arrays. """ - self.update_arguments(start=start, end=end, prob=prob, **kwargs) + self.update_arguments( + start=start, + end=end, + prob=prob, + dtype=dtype, + **kwargs, + ) def ln_pdf(self, x): - ln_0 = -np.log(np.nan_to_num(np.inf)) - ln_pdf = np.where( + ln_0 = -log(nan_to_num(inf)) + ln_pdf = where( x >= self.start, - np.where(x <= self.end, np.log(self.prob), ln_0), + where(x <= self.end, log(self.prob), ln_0), ln_0, ) if self.nosum: return ln_pdf - return np.sum(ln_pdf, axis=-1) + return sum_(ln_pdf, axis=-1) def ln_deriv(self, x): return 0.0 * x - def update_arguments(self, start=None, end=None, prob=None, **kwargs): + def set_dtype(self, dtype, **kwargs): + super().set_dtype(dtype, **kwargs) + if hasattr(self, "start") and isinstance(self.start, ndarray): + self.start = asarray(self.start, dtype=self.dtype) + if hasattr(self, "end") and isinstance(self.end, ndarray): + self.end = asarray(self.end, dtype=self.dtype) + if hasattr(self, "prob") and isinstance(self.prob, ndarray): + self.prob = asarray(self.prob, dtype=self.dtype) + return self + + def update_arguments( + self, + start=None, + end=None, + prob=None, + dtype=None, + **kwargs, + ): """ Update the object with its arguments. The existing arguments are used if they are not given. @@ -52,25 +87,31 @@ def update_arguments(self, start=None, end=None, prob=None, **kwargs): the hyperparameter in log-space. prob: float or (H) array The non-zero prior distribution value. + dtype: type + The data type of the arrays. Returns: self: The updated object itself. """ + # Set the arguments for the parent class + super().update_arguments( + dtype=dtype, + ) if start is not None: if isinstance(start, (float, int)): self.start = start else: - self.start = np.array(start).reshape(-1) + self.start = array(start, dtype=self.dtype).reshape(-1) if end is not None: if isinstance(end, (float, int)): self.end = end else: - self.end = np.array(end).reshape(-1) + self.end = array(end, dtype=self.dtype).reshape(-1) if prob is not None: if isinstance(prob, (float, int)): self.prob = prob else: - self.prob = np.array(prob).reshape(-1) + self.prob = array(prob, dtype=self.dtype).reshape(-1) if ( isinstance(self.start, (float, int)) and isinstance(self.end, (float, int)) @@ -82,7 +123,7 @@ def update_arguments(self, start=None, end=None, prob=None, **kwargs): return self def mean_var(self, mean, var): - std = np.sqrt(var) + std = sqrt(var) return self.update_arguments( start=mean - 4.0 * std, end=mean + 4.0 * std, @@ -99,7 +140,12 @@ def min_max(self, min_v, max_v): def get_arguments(self): "Get the arguments of the class itself." # Get the arguments given to the class in the initialization - arg_kwargs = dict(start=self.start, end=self.start, prob=self.prob) + arg_kwargs = dict( + start=self.start, + end=self.start, + prob=self.prob, + dtype=self.dtype, + ) # Get the constants made within the class constant_kwargs = dict() # Get the objects made within the class diff --git a/catlearn/regression/gp/pdistributions/update_pdis.py b/catlearn/regression/gp/pdistributions/update_pdis.py index 1a0373ff..35623dd7 100644 --- a/catlearn/regression/gp/pdistributions/update_pdis.py +++ b/catlearn/regression/gp/pdistributions/update_pdis.py @@ -1,4 +1,16 @@ -def update_pdis(model, parameters, X, Y, bounds=None, pdis=None, **kwargs): +from ..hpboundary.strict import StrictBoundaries + + +def update_pdis( + model, + parameters, + X, + Y, + bounds=None, + pdis=None, + dtype=float, + **kwargs, +): """ Update given prior distribution of hyperparameters from educated guesses in log space. @@ -9,9 +21,7 @@ def update_pdis(model, parameters, X, Y, bounds=None, pdis=None, **kwargs): # Make boundary conditions for updating the prior distributions if bounds is None: # Use strict educated guesses for the boundary conditions if not given - from ..hpboundary.strict import StrictBoundaries - - bounds = StrictBoundaries(log=True, use_prior_mean=True) + bounds = StrictBoundaries(log=True, use_prior_mean=True, dtype=dtype) # Update boundary conditions to the data bounds.update_bounds(model, X, Y, parameters) # Make prior distributions for hyperparameters from boundary conditions From c71fbdc79212a5db3d01f662b08724d753f89f71 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Thu, 15 May 2025 13:02:36 +0200 Subject: [PATCH 108/194] Modified tests --- tests/functions.py | 63 ++++++------ tests/test_gp_baseline.py | 30 +++--- tests/test_gp_calc.py | 67 ++++++------- tests/test_gp_ensemble.py | 66 ++++++------ tests/test_gp_fp.py | 99 +++++++++--------- tests/test_gp_hpfitter.py | 29 +++--- tests/test_gp_means.py | 21 ++-- tests/test_gp_objectivefunctions.py | 45 +++++---- tests/test_gp_optimizer.py | 150 +++++++++++++--------------- tests/test_gp_optimizer_parallel.py | 49 ++++----- tests/test_gp_pdistributions.py | 22 ++-- tests/test_gp_train.py | 97 +++++++++++------- tests/test_save_model.py | 8 +- tests/test_tp_objectivefunctions.py | 37 ++++--- tests/test_tp_optimizer.py | 124 ++++++++++++----------- tests/test_tp_optimizer_parallel.py | 48 +++++---- tests/test_tp_train.py | 95 +++++++++++------- 17 files changed, 552 insertions(+), 498 deletions(-) diff --git a/tests/functions.py b/tests/functions.py index 1d05f41d..f1e144f7 100644 --- a/tests/functions.py +++ b/tests/functions.py @@ -1,63 +1,70 @@ -import numpy as np +from numpy import argmax, array, concatenate, cos, linspace, sin, sqrt +from numpy.linalg import norm +from numpy.random import default_rng, Generator, RandomState +from ase import Atoms +from ase.calculators.emt import EMT + + +def get_rng(seed): + "Get the random number generator." + if isinstance(seed, int) or seed is None: + rng = default_rng(seed) + elif isinstance(seed, Generator): + rng = seed + elif isinstance(seed, RandomState): + rng = seed + return rng def create_func(gridsize=200, seed=1): "Generate the data set from a trial function" - np.random.seed(seed) - x = np.linspace(-40, 100, gridsize).reshape(-1, 1) - f = 3 * (np.sin((x / 20) ** 2) - 3 * np.sin(0.6 * x / 20) + 17) + rng = get_rng(seed) + x = linspace(-40, 100, gridsize).reshape(-1, 1) + f = 3 * (sin((x / 20) ** 2) - 3 * sin(0.6 * x / 20) + 17) g = 3 * ( - (2 * x / (20**2)) * np.cos((x / 20) ** 2) - - 3 * (0.6 / 20) * np.cos(0.6 * x / 20) + (2 * x / (20**2)) * cos((x / 20) ** 2) + - 3 * (0.6 / 20) * cos(0.6 * x / 20) ) - i_perm = np.random.permutation(list(range(len(x)))) + i_perm = rng.permutation(list(range(len(x)))) return x[i_perm], f[i_perm], g[i_perm] def create_h2_atoms(gridsize=200, seed=1): "Generate the trial data set of H2 ASE atoms with EMT" - from ase import Atoms - from ase.calculators.emt import EMT - - z_list = np.linspace(0.2, 4.0, gridsize) + rng = get_rng(seed) + z_list = linspace(0.2, 4.0, gridsize) atoms_list = [] energies, forces = [], [] for z in z_list: - h2 = Atoms("H2", positions=np.array([[0.0, 0.0, 0.0], [z, 0.0, 0.0]])) + h2 = Atoms("H2", positions=array([[0.0, 0.0, 0.0], [z, 0.0, 0.0]])) h2.center(vacuum=10.0) h2.calc = EMT() energies.append(h2.get_potential_energy()) forces.append(h2.get_forces().reshape(-1)) atoms_list.append(h2) - np.random.seed(seed) - i_perm = np.random.permutation(list(range(len(atoms_list)))) + i_perm = rng.permutation(list(range(len(atoms_list)))) atoms_list = [atoms_list[i] for i in i_perm] return ( atoms_list, - np.array(energies).reshape(-1, 1)[i_perm], - np.array(forces)[i_perm], + array(energies).reshape(-1, 1)[i_perm], + array(forces)[i_perm], ) def make_train_test_set(x, f, g, tr=20, te=20, use_derivatives=True): "Genterate the training and test sets" x_tr, f_tr, g_tr = x[:tr], f[:tr], g[:tr] - x_te, f_te, g_te = x[tr : tr + te], f[tr : tr + te], g[tr : tr + te] + t_all = tr + te + x_te, f_te, g_te = x[tr:t_all], f[tr:t_all], g[tr:t_all] if use_derivatives: - f_tr = np.concatenate( - [f_tr.reshape(tr, 1), g_tr.reshape(tr, -1)], - axis=1, - ) - f_te = np.concatenate( - [f_te.reshape(te, 1), g_te.reshape(te, -1)], - axis=1, - ) + f_tr = concatenate([f_tr.reshape(tr, 1), g_tr.reshape(tr, -1)], axis=1) + f_te = concatenate([f_te.reshape(te, 1), g_te.reshape(te, -1)], axis=1) return x_tr, f_tr, x_te, f_te def calculate_rmse(ytest, ypred): "Calculate the Root-mean squarred error" - return np.sqrt(np.mean((ypred - ytest) ** 2)) + return sqrt(((ypred - ytest) ** 2).mean()) def check_minima( @@ -175,11 +182,11 @@ def check_fmax(atoms, calc, fmax=0.05): atoms_c = atoms.copy() atoms_c.calc = calc forces = atoms_c.get_forces() - return np.linalg.norm(forces, axis=1).max() < fmax + return norm(forces, axis=1).max() < fmax def check_image_fmax(images, calc, fmax=0.05): "Check images from NEB has a saddle point." energies = [image.get_potential_energy() for image in images] - i_max = np.argmax(energies) + i_max = argmax(energies) return check_fmax(images[i_max], calc, fmax=fmax) diff --git a/tests/test_gp_baseline.py b/tests/test_gp_baseline.py index 71839b50..7fbaba31 100644 --- a/tests/test_gp_baseline.py +++ b/tests/test_gp_baseline.py @@ -1,5 +1,4 @@ import unittest -import numpy as np from .functions import create_h2_atoms, make_train_test_set @@ -24,15 +23,18 @@ def test_predict(self): ) from catlearn.regression.gp.baseline import ( BaselineCalculator, + BornRepulsionCalculator, RepulsionCalculator, MieCalculator, ) + # Set random seed to give the same results every time + seed = 1 # Create the data set - x, f, g = create_h2_atoms(gridsize=50, seed=1) + x, f, g = create_h2_atoms(gridsize=50, seed=seed) # Whether to learn from the derivatives use_derivatives = True - x_tr, f_tr, x_te, f_te = make_train_test_set( + x_tr, _, x_te, f_te = make_train_test_set( x, f, g, @@ -43,10 +45,6 @@ def test_predict(self): # Make the hyperparameter fitter optimizer = ScipyOptimizer( maxiter=500, - jac=True, - method="l-bfgs-b", - use_bounds=False, - tol=1e-8, ) hpfitter = HyperparameterFitter( func=LogLikelihood(), @@ -55,11 +53,12 @@ def test_predict(self): # Define the list of baseline objects that are tested baseline_list = [ BaselineCalculator(), - RepulsionCalculator(r_scale=0.7), + BornRepulsionCalculator(), + RepulsionCalculator(), MieCalculator(), ] # Make a list of the error values that the test compares to - error_list = [0.00165, 1.93820, 3.33650] + error_list = [2.12101, 3.21252, 0.26580, 1.08779] # Test the baseline objects for index, baseline in enumerate(baseline_list): with self.subTest(baseline=baseline): @@ -75,15 +74,12 @@ def test_predict(self): ) # Make the fingerprint fp = Cartesian( - reduce_dimensions=True, use_derivatives=use_derivatives, ) # Set up the database database = Database( fingerprint=fp, - reduce_dimensions=True, use_derivatives=use_derivatives, - negative_forces=True, use_fingerprint=True, ) # Define the machine learning model @@ -93,15 +89,13 @@ def test_predict(self): optimize=True, baseline=baseline, ) - # Set random seed to give the same results every time - np.random.seed(1) - # Construct the machine learning calculator and add the data + # Construct the machine learning calculator mlcalc = MLCalculator( mlmodel=mlmodel, - calculate_uncertainty=True, - calculate_forces=True, - verbose=False, ) + # Set the random seed for the calculator + mlcalc.set_seed(seed=seed) + # Add the training data to the calculator mlcalc.add_training(x_tr) # Test if the right number of training points is added self.assertTrue(mlcalc.get_training_set_size() == 10) diff --git a/tests/test_gp_calc.py b/tests/test_gp_calc.py index 970b82e1..7284037b 100644 --- a/tests/test_gp_calc.py +++ b/tests/test_gp_calc.py @@ -1,5 +1,4 @@ import unittest -import numpy as np from .functions import create_h2_atoms, make_train_test_set @@ -30,20 +29,23 @@ def test_predict(self): ) from catlearn.regression.gp.calculator import MLModel, MLCalculator + # Set random seed to give the same results every time + seed = 1 # Create the data set - x, f, g = create_h2_atoms(gridsize=50, seed=1) + x, f, g = create_h2_atoms(gridsize=50, seed=seed) # Whether to learn from the derivatives use_derivatives = True - x_tr, f_tr, x_te, f_te = make_train_test_set( - x, f, g, tr=10, te=1, use_derivatives=use_derivatives + x_tr, _, x_te, f_te = make_train_test_set( + x, + f, + g, + tr=10, + te=1, + use_derivatives=use_derivatives, ) # Make the hyperparameter fitter optimizer = ScipyOptimizer( maxiter=500, - jac=True, - method="l-bfgs-b", - use_bounds=False, - tol=1e-8, ) hpfitter = HyperparameterFitter( func=LogLikelihood(), @@ -122,24 +124,24 @@ def test_predict(self): ] # Make a list of the error values that the test compares to error_list = [ - 0.00166, - 0.00166, - 0.00359, - 0.00359, - 0.00003, - 0.00003, - 0.000002, - 0.000002, - 0.000018, - 0.00003, - 0.01270, - 0.02064, - 0.00655, - 0.00102, - 0.000002, - 0.000002, - 0.000002, - 0.000002, + 2.12101, + 2.12101, + 0.34274, + 0.34274, + 1.96252, + 0.34274, + 0.71961, + 0.71964, + 0.89345, + 0.35748, + 5.04941, + 6.25126, + 7.38147, + 9.47098, + 1.76462, + 1.76442, + 1.76462, + 1.76442, ] # Test the database objects for index, (data, use_fingerprint, data_kwarg) in enumerate( @@ -162,15 +164,12 @@ def test_predict(self): ) # Make the fingerprint fp = Cartesian( - reduce_dimensions=True, use_derivatives=use_derivatives, ) # Set up the database database = data( fingerprint=fp, - reduce_dimensions=True, use_derivatives=use_derivatives, - negative_forces=True, use_fingerprint=use_fingerprint, **data_kwarg ) @@ -180,16 +179,14 @@ def test_predict(self): database=database, optimize=True, baseline=None, - verbose=False, ) - # Set random seed to give the same results every time - np.random.seed(1) - # Construct the machine learning calculator and add the data + # Construct the machine learning calculator mlcalc = MLCalculator( mlmodel=mlmodel, - calculate_uncertainty=True, - calculate_forces=True, ) + # Set the random seed for the calculator + mlcalc.set_seed(seed=seed) + # Add the training data to the calculator mlcalc.add_training(x_tr) # Test if the right number of training points is added if index in [0, 1]: diff --git a/tests/test_gp_ensemble.py b/tests/test_gp_ensemble.py index 3bed9d01..ece91ca5 100644 --- a/tests/test_gp_ensemble.py +++ b/tests/test_gp_ensemble.py @@ -18,8 +18,10 @@ def test_variance_ensemble(self): from catlearn.regression.gp.ensemble import EnsembleClustering from catlearn.regression.gp.ensemble.clustering import K_means + # Set random seed to give the same results every time + seed = 1 # Create the data set - x, f, g = create_func() + x, f, g = create_func(seed=seed) # Whether to learn from the derivatives use_derivatives = False x_tr, f_tr, x_te, f_te = make_train_test_set( @@ -36,11 +38,14 @@ def test_variance_ensemble(self): use_derivatives=use_derivatives, ) # Construct the clustering object - clustering = K_means(k=4, maxiter=20, tol=1e-3, metric="euclidean") + clustering = K_means( + n_clusters=4, + maxiter=20, + ) # Define the list of whether to use variance as the ensemble method var_list = [False, True] # Make a list of the error values that the test compares to - error_list = [3.90019, 1.73281] + error_list = [4.61352, 0.45865] for index, use_variance_ensemble in enumerate(var_list): with self.subTest(use_variance_ensemble=use_variance_ensemble): # Construct the ensemble model @@ -50,11 +55,11 @@ def test_variance_ensemble(self): use_variance_ensemble=use_variance_ensemble, ) # Set random seed to give the same results every time - np.random.seed(1) + enmodel.set_seed(seed=seed) # Train the machine learning model enmodel.train(x_tr, f_tr) # Predict the energies - ypred, var, var_deriv = enmodel.predict( + ypred, _, _ = enmodel.predict( x_te, get_variance=False, get_derivatives=False, @@ -80,8 +85,10 @@ def test_clustering(self): RandomClustering_number, ) + # Set random seed to give the same results every time + seed = 1 # Create the data set - x, f, g = create_func() + x, f, g = create_func(seed=seed) # Whether to learn from the derivatives use_derivatives = False x_tr, f_tr, x_te, f_te = make_train_test_set( @@ -99,29 +106,24 @@ def test_clustering(self): ) # Define the list of clustering objects that are tested clustering_list = [ - K_means(k=4, maxiter=20, tol=1e-3, metric="euclidean"), + K_means(n_clusters=4, maxiter=20), K_means_auto( min_data=6, max_data=12, maxiter=20, - tol=1e-3, - metric="euclidean", ), K_means_number( data_number=12, maxiter=20, - tol=1e-3, - metric="euclidean", ), FixedClustering( centroids=np.array([[-30.0], [60.0]]), - metric="euclidean", ), RandomClustering(n_clusters=4, equal_size=True), RandomClustering_number(data_number=12), ] # Make a list of the error values that the test compares to - error_list = [1.73289, 1.75136, 1.73401, 1.74409, 1.88037, 0.61394] + error_list = [0.45865, 0.62425, 0.61887, 0.61937, 0.70159, 0.67982] # Test the baseline objects for index, clustering in enumerate(clustering_list): with self.subTest(clustering=clustering): @@ -132,11 +134,11 @@ def test_clustering(self): use_variance_ensemble=True, ) # Set random seed to give the same results every time - np.random.seed(1) + enmodel.set_seed(seed=seed) # Train the machine learning model enmodel.train(x_tr, f_tr) # Predict the energies and uncertainties - ypred, var, var_deriv = enmodel.predict( + ypred, _, _ = enmodel.predict( x_te, get_variance=True, get_derivatives=False, @@ -162,8 +164,10 @@ def test_variance_ensemble(self): from catlearn.regression.gp.ensemble import EnsembleClustering from catlearn.regression.gp.ensemble.clustering import K_means + # Set random seed to give the same results every time + seed = 1 # Create the data set - x, f, g = create_func() + x, f, g = create_func(seed=seed) # Whether to learn from the derivatives use_derivatives = True x_tr, f_tr, x_te, f_te = make_train_test_set( @@ -180,11 +184,11 @@ def test_variance_ensemble(self): use_derivatives=use_derivatives, ) # Construct the clustering object - clustering = K_means(k=4, maxiter=20, tol=1e-3, metric="euclidean") + clustering = K_means(n_clusters=4, maxiter=20) # Define the list of whether to use variance as the ensemble method var_list = [False, True] # Make a list of the error values that the test compares to - error_list = [3.66417, 0.17265] + error_list = [4.3318, 0.18866] for index, use_variance_ensemble in enumerate(var_list): with self.subTest(use_variance_ensemble=use_variance_ensemble): # Construct the ensemble model @@ -194,11 +198,11 @@ def test_variance_ensemble(self): use_variance_ensemble=use_variance_ensemble, ) # Set random seed to give the same results every time - np.random.seed(1) + enmodel.set_seed(seed=seed) # Train the machine learning model enmodel.train(x_tr, f_tr) # Predict the energies - ypred, var, var_deriv = enmodel.predict( + ypred, _, _ = enmodel.predict( x_te, get_variance=False, get_derivatives=False, @@ -224,8 +228,10 @@ def test_clustering(self): RandomClustering_number, ) + # Set random seed to give the same results every time + seed = 1 # Create the data set - x, f, g = create_func() + x, f, g = create_func(seed=seed) # Whether to learn from the derivatives use_derivatives = True x_tr, f_tr, x_te, f_te = make_train_test_set( @@ -243,25 +249,19 @@ def test_clustering(self): ) # Define the list of clustering objects that are tested clustering_list = [ - K_means(k=4, maxiter=20, tol=1e-3, metric="euclidean"), + K_means(n_clusters=4, maxiter=20), K_means_auto( min_data=6, max_data=12, maxiter=20, - tol=1e-3, - metric="euclidean", - ), - K_means_number( - data_number=12, maxiter=20, tol=1e-3, metric="euclidean" - ), - FixedClustering( - centroids=np.array([[-30.0], [60.0]]), metric="euclidean" ), + K_means_number(data_number=12, maxiter=20), + FixedClustering(centroids=np.array([[-30.0], [60.0]])), RandomClustering(n_clusters=4, equal_size=True), RandomClustering_number(data_number=12), ] # Make a list of the error values that the test compares to - error_list = [0.17265, 0.15492, 0.14095, 0.16393, 0.59046, 0.24236] + error_list = [0.18866, 0.19777, 0.19715, 0.19717, 0.47726, 0.18779] # Test the baseline objects for index, clustering in enumerate(clustering_list): with self.subTest(clustering=clustering): @@ -272,11 +272,11 @@ def test_clustering(self): use_variance_ensemble=True, ) # Set random seed to give the same results every time - np.random.seed(1) + enmodel.set_seed(seed=seed) # Train the machine learning model enmodel.train(x_tr, f_tr) # Predict the energies and uncertainties - ypred, var, var_deriv = enmodel.predict( + ypred, _, _ = enmodel.predict( x_te, get_variance=True, get_derivatives=False, diff --git a/tests/test_gp_fp.py b/tests/test_gp_fp.py index 5531d948..d08fc593 100644 --- a/tests/test_gp_fp.py +++ b/tests/test_gp_fp.py @@ -1,5 +1,4 @@ import unittest -import numpy as np from .functions import create_h2_atoms, make_train_test_set, calculate_rmse @@ -18,17 +17,20 @@ def test_predict_var(self): from catlearn.regression.gp.kernel import SE from catlearn.regression.gp.fingerprint import ( Cartesian, + Distances, InvDistances, InvDistances2, - SortedDistances, + SortedInvDistances, SumDistances, SumDistancesPower, MeanDistances, MeanDistancesPower, ) + # Set random seed to give the same results every time + seed = 1 # Create the data set - x, f, g = create_h2_atoms(gridsize=50, seed=1) + x, f, g = create_h2_atoms(gridsize=50, seed=seed) # Whether to learn from the derivatives use_derivatives = False # Construct the Gaussian process @@ -40,10 +42,10 @@ def test_predict_var(self): # Define the list of fingerprint objects that are tested fp_kwarg_list = [ Cartesian(reduce_dimensions=True, use_derivatives=use_derivatives), - InvDistances( + Distances( reduce_dimensions=True, use_derivatives=use_derivatives, - mic=True, + periodic_softmax=True, ), InvDistances( reduce_dimensions=True, @@ -53,45 +55,47 @@ def test_predict_var(self): InvDistances2( reduce_dimensions=True, use_derivatives=use_derivatives, - mic=True, + periodic_softmax=True, ), - SortedDistances( + SortedInvDistances( reduce_dimensions=True, use_derivatives=use_derivatives, - mic=True, + periodic_softmax=True, ), SumDistances( reduce_dimensions=True, use_derivatives=use_derivatives, - mic=True, + periodic_softmax=True, ), SumDistancesPower( reduce_dimensions=True, use_derivatives=use_derivatives, - mic=True, + periodic_softmax=True, + power=4, ), MeanDistances( reduce_dimensions=True, use_derivatives=use_derivatives, - mic=True, + periodic_softmax=True, ), MeanDistancesPower( reduce_dimensions=True, use_derivatives=use_derivatives, - mic=True, + periodic_softmax=False, + power=4, ), ] # Make a list of the error values that the test compares to error_list = [ - 1.35605, - 0.65313, - 0.65313, - 0.77297, - 0.65313, - 0.65313, - 0.45222, - 0.65313, - 0.45222, + 21.47374, + 18.9718, + 24.42522, + 122.74292, + 24.42522, + 83.87483, + 63.01758, + 12.08484, + 61.98995, ] # Test the fingerprint objects for index, fp in enumerate(fp_kwarg_list): @@ -106,8 +110,8 @@ def test_predict_var(self): te=10, use_derivatives=use_derivatives, ) - # Set random seed to give the same results every time - np.random.seed(1) + # Set the random seed + gp.set_seed(seed=seed) # Train the machine learning model gp.train(x_tr, f_tr) # Predict the energies and uncertainties @@ -137,17 +141,20 @@ def test_predict_var(self): from catlearn.regression.gp.kernel import SE from catlearn.regression.gp.fingerprint import ( Cartesian, + Distances, InvDistances, InvDistances2, - SortedDistances, + SortedInvDistances, SumDistances, SumDistancesPower, MeanDistances, MeanDistancesPower, ) + # Set random seed to give the same results every time + seed = 1 # Create the data set - x, f, g = create_h2_atoms(gridsize=50, seed=1) + x, f, g = create_h2_atoms(gridsize=50, seed=seed) # Whether to True from the derivatives use_derivatives = True # Construct the Gaussian process @@ -159,10 +166,10 @@ def test_predict_var(self): # Define the list of fingerprint objects that are tested fp_kwarg_list = [ Cartesian(reduce_dimensions=True, use_derivatives=use_derivatives), - InvDistances( + Distances( reduce_dimensions=True, use_derivatives=use_derivatives, - mic=True, + periodic_softmax=True, ), InvDistances( reduce_dimensions=True, @@ -172,45 +179,47 @@ def test_predict_var(self): InvDistances2( reduce_dimensions=True, use_derivatives=use_derivatives, - mic=True, + periodic_softmax=True, ), - SortedDistances( + SortedInvDistances( reduce_dimensions=True, use_derivatives=use_derivatives, - mic=True, + periodic_softmax=True, ), SumDistances( reduce_dimensions=True, use_derivatives=use_derivatives, - mic=True, + periodic_softmax=True, ), SumDistancesPower( reduce_dimensions=True, use_derivatives=use_derivatives, - mic=True, + periodic_softmax=True, + power=4, ), MeanDistances( reduce_dimensions=True, use_derivatives=use_derivatives, - mic=True, + periodic_softmax=True, ), MeanDistancesPower( reduce_dimensions=True, use_derivatives=use_derivatives, - mic=True, + periodic_softmax=False, + power=4, ), ] # Make a list of the error values that the test compares to error_list = [ - 22.75648, - 9.90152, - 9.90152, - 4.62743, - 9.90152, - 9.90152, - 8.60277, - 9.90152, - 8.60277, + 38.74501, + 39.72866, + 84.47651, + 632.09643, + 84.47651, + 65.53034, + 132.05894, + 72.97461, + 81.84043, ] # Test the fingerprint objects for index, fp in enumerate(fp_kwarg_list): @@ -225,8 +234,8 @@ def test_predict_var(self): te=10, use_derivatives=use_derivatives, ) - # Set random seed to give the same results every time - np.random.seed(1) + # Set random seed + gp.set_seed(seed=seed) # Train the machine learning model gp.train(x_tr, f_tr) # Predict the energies and uncertainties diff --git a/tests/test_gp_hpfitter.py b/tests/test_gp_hpfitter.py index cab04d74..1269a486 100644 --- a/tests/test_gp_hpfitter.py +++ b/tests/test_gp_hpfitter.py @@ -1,5 +1,4 @@ import unittest -import numpy as np from .functions import create_func, make_train_test_set, check_minima @@ -23,11 +22,13 @@ def test_hpfitters_noderiv(self): FBPMGP, ) + # Set random seed to give the same results every time + seed = 1 # Create the data set - x, f, g = create_func() + x, f, g = create_func(seed=seed) # Whether to learn from the derivatives use_derivatives = False - x_tr, f_tr, x_te, f_te = make_train_test_set( + x_tr, f_tr, _, _ = make_train_test_set( x, f, g, @@ -38,10 +39,6 @@ def test_hpfitters_noderiv(self): # Make the optimizer optimizer = ScipyOptimizer( maxiter=500, - jac=True, - method="l-bfgs-b", - use_bounds=False, - tol=1e-12, ) # Define the list of hyperparameter fitter objects that are tested hpfitter_list = [ @@ -61,7 +58,7 @@ def test_hpfitters_noderiv(self): opt_tr_size=10, optimizer=optimizer, ), - FBPMGP(Q=None, n_test=50, ngrid=80, bounds=None), + FBPMGP(Q=None, n_test=50, ngrid=80), ] # Test the hyperparameter fitter objects for index, hpfitter in enumerate(hpfitter_list): @@ -73,7 +70,7 @@ def test_hpfitters_noderiv(self): use_derivatives=use_derivatives, ) # Set random seed to give the same results every time - np.random.seed(1) + gp.set_seed(seed) # Optimize the hyperparameters sol = gp.optimize( x_tr, @@ -109,11 +106,13 @@ def test_hpfitters_deriv(self): FBPMGP, ) + # Set random seed to give the same results every time + seed = 1 # Create the data set - x, f, g = create_func() + x, f, g = create_func(seed=seed) # Whether to learn from the derivatives use_derivatives = True - x_tr, f_tr, x_te, f_te = make_train_test_set( + x_tr, f_tr, _, _ = make_train_test_set( x, f, g, @@ -124,10 +123,6 @@ def test_hpfitters_deriv(self): # Make the optimizer optimizer = ScipyOptimizer( maxiter=500, - jac=True, - method="l-bfgs-b", - use_bounds=False, - tol=1e-12, ) # Define the list of hyperparameter fitter objects that are tested hpfitter_list = [ @@ -147,7 +142,7 @@ def test_hpfitters_deriv(self): opt_tr_size=10, optimizer=optimizer, ), - FBPMGP(Q=None, n_test=50, ngrid=80, bounds=None), + FBPMGP(Q=None, n_test=50, ngrid=80), ] # Test the hyperparameter fitter objects for index, hpfitter in enumerate(hpfitter_list): @@ -159,7 +154,7 @@ def test_hpfitters_deriv(self): use_derivatives=use_derivatives, ) # Set random seed to give the same results every time - np.random.seed(1) + gp.set_seed(seed) # Optimize the hyperparameters sol = gp.optimize( x_tr, diff --git a/tests/test_gp_means.py b/tests/test_gp_means.py index 6cfbc47b..9f519625 100644 --- a/tests/test_gp_means.py +++ b/tests/test_gp_means.py @@ -1,5 +1,4 @@ import unittest -import numpy as np from .functions import create_func, make_train_test_set, calculate_rmse @@ -23,8 +22,10 @@ def test_means_noderiv(self): Prior_first, ) + # Set random seed to give the same results every time + seed = 1 # Create the data set - x, f, g = create_func() + x, f, g = create_func(seed=seed) # Whether to learn from the derivatives use_derivatives = False x_tr, f_tr, x_te, f_te = make_train_test_set( @@ -45,7 +46,7 @@ def test_means_noderiv(self): Prior_first, ] # Make a list of the error values that the test compares to - error_list = [3.14787, 1.75102, 1.77025, 1.95093, 1.65956, 1.74474] + error_list = [2.54619, 0.88457, 0.91272, 1.19668, 0.60103, 0.90832] # Test the prior mean objects for index, prior in enumerate(priors): with self.subTest(prior=prior): @@ -56,11 +57,11 @@ def test_means_noderiv(self): use_derivatives=use_derivatives, ) # Set random seed to give the same results every time - np.random.seed(1) + gp.set_seed(seed) # Train the machine learning model gp.train(x_tr, f_tr) # Predict the energies and uncertainties - ypred, var, var_deriv = gp.predict( + ypred, _, _ = gp.predict( x_te, get_variance=True, get_derivatives=False, @@ -85,8 +86,10 @@ def test_means_deriv(self): Prior_first, ) + # Set random seed to give the same results every time + seed = 1 # Create the data set - x, f, g = create_func() + x, f, g = create_func(seed=seed) # Whether to learn from the derivatives use_derivatives = True x_tr, f_tr, x_te, f_te = make_train_test_set( @@ -107,7 +110,7 @@ def test_means_deriv(self): Prior_first, ] # Make a list of the error values that the test compares to - error_list = [0.09202, 0.13723, 0.13594, 0.12673, 0.14712, 0.13768] + error_list = [0.78570, 0.20550, 0.21498, 0.31329, 0.12582, 0.21349] # Test the prior mean objects for index, prior in enumerate(priors): with self.subTest(prior=prior): @@ -118,11 +121,11 @@ def test_means_deriv(self): use_derivatives=use_derivatives, ) # Set random seed to give the same results every time - np.random.seed(1) + gp.set_seed(seed) # Train the machine learning model gp.train(x_tr, f_tr) # Predict the energies and uncertainties - ypred, var, var_deriv = gp.predict( + ypred, _, _ = gp.predict( x_te, get_variance=True, get_derivatives=False, diff --git a/tests/test_gp_objectivefunctions.py b/tests/test_gp_objectivefunctions.py index 111acf93..9612c838 100644 --- a/tests/test_gp_objectivefunctions.py +++ b/tests/test_gp_objectivefunctions.py @@ -1,5 +1,4 @@ import unittest -import numpy as np from .functions import create_func, make_train_test_set, check_minima @@ -25,11 +24,13 @@ def test_local(self): GPE, ) + # Set random seed to give the same results every time + seed = 1 # Create the data set - x, f, g = create_func() + x, f, g = create_func(seed=seed) # Whether to learn from the derivatives use_derivatives = False - x_tr, f_tr, x_te, f_te = make_train_test_set( + x_tr, f_tr, _, _ = make_train_test_set( x, f, g, @@ -51,9 +52,6 @@ def test_local(self): optimizer = ScipyOptimizer( maxiter=500, jac=True, - method="l-bfgs-b", - use_bounds=False, - tol=1e-12, ) # Test the objective function objects for obj_func in obj_list: @@ -70,7 +68,7 @@ def test_local(self): use_derivatives=use_derivatives, ) # Set random seed to give the same results every time - np.random.seed(1) + gp.set_seed(seed) # Optimize the hyperparameters sol = gp.optimize( x_tr, @@ -112,13 +110,18 @@ def test_line_search_scale(self): FactorizedLogLikelihoodSVD, FactorizedGPP, ) - from catlearn.regression.gp.hpboundary import HPBoundaries + from catlearn.regression.gp.hpboundary import ( + HPBoundaries, + VariableTransformation, + ) + # Set random seed to give the same results every time + seed = 1 # Create the data set - x, f, g = create_func() + x, f, g = create_func(seed=seed) # Whether to learn from the derivatives use_derivatives = False - x_tr, f_tr, x_te, f_te = make_train_test_set( + x_tr, f_tr, _, _ = make_train_test_set( x, f, g, @@ -126,6 +129,8 @@ def test_line_search_scale(self): te=1, use_derivatives=use_derivatives, ) + # Make the default boundaries for the hyperparameters + default_bounds = VariableTransformation() # Make fixed boundary conditions for one of the tests fixed_bounds = HPBoundaries( bounds_dict=dict( @@ -137,7 +142,11 @@ def test_line_search_scale(self): ) # Make the optimizers line_optimizer = FineGridSearch( - tol=1e-5, loops=3, ngrid=80, optimize=True, multiple_min=False + tol=1e-5, + loops=3, + ngrid=80, + optimize=True, + multiple_min=False, ) optimizer = FactorizedOptimizer( line_optimizer=line_optimizer, @@ -148,7 +157,7 @@ def test_line_search_scale(self): # Define the list of objective function objects that are tested obj_list = [ ( - None, + default_bounds, FactorizedLogLikelihood( modification=False, ngrid=250, @@ -156,7 +165,7 @@ def test_line_search_scale(self): ), ), ( - None, + default_bounds, FactorizedLogLikelihood( modification=True, ngrid=250, @@ -164,7 +173,7 @@ def test_line_search_scale(self): ), ), ( - None, + default_bounds, FactorizedLogLikelihood( modification=False, ngrid=80, @@ -172,7 +181,7 @@ def test_line_search_scale(self): ), ), ( - None, + default_bounds, FactorizedLogLikelihood( modification=False, ngrid=80, @@ -188,7 +197,7 @@ def test_line_search_scale(self): ), ), ( - None, + default_bounds, FactorizedLogLikelihoodSVD( modification=False, ngrid=250, @@ -196,7 +205,7 @@ def test_line_search_scale(self): ), ), ( - None, + default_bounds, FactorizedGPP( modification=False, ngrid=250, @@ -220,7 +229,7 @@ def test_line_search_scale(self): use_derivatives=use_derivatives, ) # Set random seed to give the same results every time - np.random.seed(1) + gp.set_seed(seed) # Optimize the hyperparameters sol = gp.optimize( x_tr, diff --git a/tests/test_gp_optimizer.py b/tests/test_gp_optimizer.py index f6c3e6dd..b7d9a010 100644 --- a/tests/test_gp_optimizer.py +++ b/tests/test_gp_optimizer.py @@ -1,5 +1,4 @@ import unittest -import numpy as np from .functions import create_func, make_train_test_set, check_minima @@ -16,11 +15,13 @@ def test_function(self): from catlearn.regression.gp.objectivefunctions.gp import LogLikelihood from catlearn.regression.gp.hpfitter import HyperparameterFitter + # Set random seed to give the same results every time + seed = 1 # Create the data set - x, f, g = create_func() + x, f, g = create_func(seed=seed) # Whether to learn from the derivatives use_derivatives = False - x_tr, f_tr, x_te, f_te = make_train_test_set( + x_tr, f_tr, _, _ = make_train_test_set( x, f, g, @@ -40,7 +41,7 @@ def test_function(self): use_derivatives=use_derivatives, ) # Set random seed to give the same results every time - np.random.seed(1) + gp.set_seed(seed=seed) # Optimize the hyperparameters sol = gp.optimize( x_tr, @@ -51,7 +52,7 @@ def test_function(self): verbose=False, ) # Test the solution is correct - self.assertTrue(abs(sol["fun"] - 393.422) < 1e-2) + self.assertTrue(abs(sol["fun"] - 196.837) < 1e-2) def test_local_jac(self): """ @@ -65,11 +66,13 @@ def test_local_jac(self): ) from catlearn.regression.gp.hpfitter import HyperparameterFitter + # Set random seed to give the same results every time + seed = 1 # Create the data set - x, f, g = create_func() + x, f, g = create_func(seed=seed) # Whether to learn from the derivatives use_derivatives = False - x_tr, f_tr, x_te, f_te = make_train_test_set( + x_tr, f_tr, _, _ = make_train_test_set( x, f, g, @@ -81,9 +84,6 @@ def test_local_jac(self): optimizer = ScipyOptimizer( maxiter=500, jac=True, - method="l-bfgs-b", - use_bounds=False, - tol=1e-12, ) # Construct the hyperparameter fitter hpfitter = HyperparameterFitter( @@ -97,7 +97,7 @@ def test_local_jac(self): use_derivatives=use_derivatives, ) # Set random seed to give the same results every time - np.random.seed(1) + gp.set_seed(seed=seed) # Optimize the hyperparameters sol = gp.optimize( x_tr, @@ -128,11 +128,13 @@ def test_local_nojac(self): from catlearn.regression.gp.objectivefunctions.gp import LogLikelihood from catlearn.regression.gp.hpfitter import HyperparameterFitter + # Set random seed to give the same results every time + seed = 1 # Create the data set - x, f, g = create_func() + x, f, g = create_func(seed=seed) # Whether to learn from the derivatives use_derivatives = False - x_tr, f_tr, x_te, f_te = make_train_test_set( + x_tr, f_tr, _, _ = make_train_test_set( x, f, g, @@ -144,9 +146,6 @@ def test_local_nojac(self): optimizer = ScipyOptimizer( maxiter=500, jac=False, - method="l-bfgs-b", - use_bounds=False, - tol=1e-12, ) # Construct the hyperparameter fitter hpfitter = HyperparameterFitter( @@ -160,7 +159,7 @@ def test_local_nojac(self): use_derivatives=use_derivatives, ) # Set random seed to give the same results every time - np.random.seed(1) + gp.set_seed(seed=seed) # Optimize the hyperparameters sol = gp.optimize( x_tr, @@ -189,11 +188,13 @@ def test_local_prior(self): from catlearn.regression.gp.hpfitter import HyperparameterFitter from catlearn.regression.gp.pdistributions import Normal_prior + # Set random seed to give the same results every time + seed = 1 # Create the data set - x, f, g = create_func() + x, f, g = create_func(seed=seed) # Whether to learn from the derivatives use_derivatives = False - x_tr, f_tr, x_te, f_te = make_train_test_set( + x_tr, f_tr, _, _ = make_train_test_set( x, f, g, @@ -204,10 +205,6 @@ def test_local_prior(self): # Make the optimizer optimizer = ScipyPriorOptimizer( maxiter=500, - jac=True, - method="l-bfgs-b", - use_bounds=False, - tol=1e-12, ) # Construct the hyperparameter fitter hpfitter = HyperparameterFitter( @@ -226,7 +223,7 @@ def test_local_prior(self): noise=Normal_prior(mu=-4.0, std=2.0), ) # Set random seed to give the same results every time - np.random.seed(1) + gp.set_seed(seed=seed) # Optimize the hyperparameters sol = gp.optimize( x_tr, @@ -256,11 +253,13 @@ def test_local_ed_guess(self): from catlearn.regression.gp.hpfitter import HyperparameterFitter from catlearn.regression.gp.hpboundary.strict import StrictBoundaries + # Set random seed to give the same results every time + seed = 1 # Create the data set - x, f, g = create_func() + x, f, g = create_func(seed=seed) # Whether to learn from the derivatives use_derivatives = False - x_tr, f_tr, x_te, f_te = make_train_test_set( + x_tr, f_tr, _, _ = make_train_test_set( x, f, g, @@ -289,7 +288,7 @@ def test_local_ed_guess(self): use_derivatives=use_derivatives, ) # Set random seed to give the same results every time - np.random.seed(1) + gp.set_seed(seed=seed) # Optimize the hyperparameters sol = gp.optimize( x_tr, @@ -325,11 +324,13 @@ def test_random(self): VariableTransformation, ) + # Set random seed to give the same results every time + seed = 1 # Create the data set - x, f, g = create_func() + x, f, g = create_func(seed=seed) # Whether to learn from the derivatives use_derivatives = False - x_tr, f_tr, x_te, f_te = make_train_test_set( + x_tr, f_tr, _, _ = make_train_test_set( x, f, g, @@ -341,9 +342,6 @@ def test_random(self): local_optimizer = ScipyOptimizer( maxiter=500, jac=True, - method="l-bfgs-b", - use_bounds=False, - tol=1e-12, ) # Make the global optimizer optimizer = RandomSamplingOptimizer( @@ -360,7 +358,7 @@ def test_random(self): ) # Define test list of arguments for the random sampling optimizer bounds_list = [ - VariableTransformation(bounds=None), + VariableTransformation(), EducatedBoundaries(), HPBoundaries(bounds_dict=bounds_dict), ] @@ -380,7 +378,7 @@ def test_random(self): use_derivatives=use_derivatives, ) # Set random seed to give the same results every time - np.random.seed(1) + gp.set_seed(seed=seed) # Optimize the hyperparameters sol = gp.optimize( x_tr, @@ -416,11 +414,13 @@ def test_grid(self): VariableTransformation, ) + # Set random seed to give the same results every time + seed = 1 # Create the data set - x, f, g = create_func() + x, f, g = create_func(seed=seed) # Whether to learn from the derivatives use_derivatives = False - x_tr, f_tr, x_te, f_te = make_train_test_set( + x_tr, f_tr, _, _ = make_train_test_set( x, f, g, @@ -432,14 +432,11 @@ def test_grid(self): local_optimizer = ScipyOptimizer( maxiter=500, jac=True, - method="l-bfgs-b", - use_bounds=False, - tol=1e-12, ) # Make the global optimizer kwargs opt_kwargs = dict(maxiter=500, n_each_dim=5, parallel=False) # Make the boundary conditions for the tests - bounds_trans = VariableTransformation(bounds=None) + bounds_trans = VariableTransformation() bounds_ed = EducatedBoundaries() fixed_bounds = HPBoundaries( bounds_dict=dict( @@ -500,7 +497,7 @@ def test_grid(self): use_derivatives=use_derivatives, ) # Set random seed to give the same results every time - np.random.seed(1) + gp.set_seed(seed=seed) # Optimize the hyperparameters sol = gp.optimize( x_tr, @@ -536,11 +533,13 @@ def test_line(self): VariableTransformation, ) + # Set random seed to give the same results every time + seed = 1 # Create the data set - x, f, g = create_func() + x, f, g = create_func(seed=seed) # Whether to learn from the derivatives use_derivatives = False - x_tr, f_tr, x_te, f_te = make_train_test_set( + x_tr, f_tr, _, _ = make_train_test_set( x, f, g, @@ -552,14 +551,11 @@ def test_line(self): local_optimizer = ScipyOptimizer( maxiter=500, jac=True, - method="l-bfgs-b", - use_bounds=False, - tol=1e-12, ) # Make the global optimizer kwargs opt_kwargs = dict(maxiter=500, n_each_dim=10, loops=3, parallel=False) # Make the boundary conditions for the tests - bounds_trans = VariableTransformation(bounds=None) + bounds_trans = VariableTransformation() bounds_ed = EducatedBoundaries() fixed_bounds = HPBoundaries( bounds_dict=dict( @@ -620,7 +616,7 @@ def test_line(self): use_derivatives=use_derivatives, ) # Set random seed to give the same results every time - np.random.seed(1) + gp.set_seed(seed=seed) # Optimize the hyperparameters sol = gp.optimize( x_tr, @@ -648,11 +644,13 @@ def test_basin(self): from catlearn.regression.gp.objectivefunctions.gp import LogLikelihood from catlearn.regression.gp.hpfitter import HyperparameterFitter + # Set random seed to give the same results every time + seed = 1 # Create the data set - x, f, g = create_func() + x, f, g = create_func(seed=seed) # Whether to learn from the derivatives use_derivatives = False - x_tr, f_tr, x_te, f_te = make_train_test_set( + x_tr, f_tr, _, _ = make_train_test_set( x, f, g, @@ -688,7 +686,7 @@ def test_basin(self): use_derivatives=use_derivatives, ) # Set random seed to give the same results every time - np.random.seed(1) + gp.set_seed(seed=seed) # Optimize the hyperparameters sol = gp.optimize( x_tr, @@ -720,11 +718,13 @@ def test_annealling(self): EducatedBoundaries, ) + # Set random seed to give the same results every time + seed = 1 # Create the data set - x, f, g = create_func() + x, f, g = create_func(seed=seed) # Whether to learn from the derivatives use_derivatives = False - x_tr, f_tr, x_te, f_te = make_train_test_set( + x_tr, f_tr, _, _ = make_train_test_set( x, f, g, @@ -734,19 +734,10 @@ def test_annealling(self): ) # Make the dictionary of the optimization local_kwargs = dict(tol=1e-12, method="L-BFGS-B") - opt_kwargs = dict( - initial_temp=5230.0, - restart_temp_ratio=2e-05, - visit=2.62, - accept=-5.0, - seed=None, - no_local_search=False, - ) # Make the optimizer optimizer = AnneallingOptimizer( - maxiter=500, + maxiter=5000, jac=False, - opt_kwargs=opt_kwargs, local_kwargs=local_kwargs, ) # Make the boundary conditions for the tests @@ -777,7 +768,7 @@ def test_annealling(self): use_derivatives=use_derivatives, ) # Set random seed to give the same results every time - np.random.seed(1) + gp.set_seed(seed=seed) # Optimize the hyperparameters sol = gp.optimize( x_tr, @@ -812,11 +803,13 @@ def test_annealling_trans(self): VariableTransformation, ) + # Set random seed to give the same results every time + seed = 1 # Create the data set - x, f, g = create_func() + x, f, g = create_func(seed=seed) # Whether to learn from the derivatives use_derivatives = False - x_tr, f_tr, x_te, f_te = make_train_test_set( + x_tr, f_tr, _, _ = make_train_test_set( x, f, g, @@ -826,23 +819,14 @@ def test_annealling_trans(self): ) # Make the dictionary of the optimization local_kwargs = dict(tol=1e-12, method="L-BFGS-B") - opt_kwargs = dict( - initial_temp=5230.0, - restart_temp_ratio=2e-05, - visit=2.62, - accept=-5.0, - seed=None, - no_local_search=False, - ) # Make the optimizer optimizer = AnneallingTransOptimizer( - maxiter=500, + maxiter=5000, jac=False, - opt_kwargs=opt_kwargs, local_kwargs=local_kwargs, ) # Make the boundary conditions for the tests - bounds_trans = VariableTransformation(bounds=None) + bounds_trans = VariableTransformation() fixed_bounds = HPBoundaries( bounds_dict=dict( length=[[-3.0, 3.0]], @@ -870,7 +854,7 @@ def test_annealling_trans(self): use_derivatives=use_derivatives, ) # Set random seed to give the same results every time - np.random.seed(1) + gp.set_seed(seed=seed) # Optimize the hyperparameters sol = gp.optimize( x_tr, @@ -913,11 +897,13 @@ def test_line_search_scale(self): VariableTransformation, ) + # Set random seed to give the same results every time + seed = 1 # Create the data set - x, f, g = create_func() + x, f, g = create_func(seed=seed) # Whether to learn from the derivatives use_derivatives = False - x_tr, f_tr, x_te, f_te = make_train_test_set( + x_tr, f_tr, _, _ = make_train_test_set( x, f, g, @@ -928,7 +914,7 @@ def test_line_search_scale(self): # Make the dictionary of the optimization opt_kwargs = dict(maxiter=500, jac=False, tol=1e-5, parallel=False) # Make the boundary conditions for the tests - bounds_trans = VariableTransformation(bounds=None) + bounds_trans = VariableTransformation() bounds_ed = EducatedBoundaries() fixed_bounds = HPBoundaries( bounds_dict=dict( @@ -1028,7 +1014,7 @@ def test_line_search_scale(self): use_derivatives=use_derivatives, ) # Set random seed to give the same results every time - np.random.seed(1) + gp.set_seed(seed=seed) # Optimize the hyperparameters sol = gp.optimize( x_tr, diff --git a/tests/test_gp_optimizer_parallel.py b/tests/test_gp_optimizer_parallel.py index 7cc94e76..f0373cee 100644 --- a/tests/test_gp_optimizer_parallel.py +++ b/tests/test_gp_optimizer_parallel.py @@ -1,5 +1,4 @@ import unittest -import numpy as np from .functions import create_func, make_train_test_set, check_minima @@ -22,11 +21,13 @@ def test_random(self): from catlearn.regression.gp.objectivefunctions.gp import LogLikelihood from catlearn.regression.gp.hpfitter import HyperparameterFitter + # Set random seed to give the same results every time + seed = 1 # Create the data set - x, f, g = create_func() + x, f, g = create_func(seed=seed) # Whether to learn from the derivatives use_derivatives = False - x_tr, f_tr, x_te, f_te = make_train_test_set( + x_tr, f_tr, _, _ = make_train_test_set( x, f, g, @@ -37,10 +38,6 @@ def test_random(self): # Make the local optimizer local_optimizer = ScipyOptimizer( maxiter=500, - jac=True, - method="l-bfgs-b", - use_bounds=False, - tol=1e-12, ) # Make the global optimizer optimizer = RandomSamplingOptimizer( @@ -61,7 +58,7 @@ def test_random(self): use_derivatives=use_derivatives, ) # Set random seed to give the same results every time - np.random.seed(1) + gp.set_seed(seed=seed) # Optimize the hyperparameters sol = gp.optimize( x_tr, @@ -95,11 +92,13 @@ def test_grid(self): VariableTransformation, ) + # Set random seed to give the same results every time + seed = 1 # Create the data set - x, f, g = create_func() + x, f, g = create_func(seed=seed) # Whether to learn from the derivatives use_derivatives = False - x_tr, f_tr, x_te, f_te = make_train_test_set( + x_tr, f_tr, _, _ = make_train_test_set( x, f, g, @@ -110,10 +109,6 @@ def test_grid(self): # Make the local optimizer local_optimizer = ScipyOptimizer( maxiter=500, - jac=True, - method="l-bfgs-b", - use_bounds=False, - tol=1e-12, ) # Make the global optimizer optimizer = GridOptimizer( @@ -124,7 +119,7 @@ def test_grid(self): parallel=True, ) # Make the boundary conditions for the tests - bounds_trans = VariableTransformation(bounds=None) + bounds_trans = VariableTransformation() # Construct the hyperparameter fitter hpfitter = HyperparameterFitter( func=LogLikelihood(), @@ -138,7 +133,7 @@ def test_grid(self): use_derivatives=use_derivatives, ) # Set random seed to give the same results every time - np.random.seed(1) + gp.set_seed(seed=seed) # Optimize the hyperparameters sol = gp.optimize( x_tr, @@ -170,8 +165,10 @@ def test_line(self): from catlearn.regression.gp.hpfitter import HyperparameterFitter from catlearn.regression.gp.hpboundary import VariableTransformation + # Set random seed to give the same results every time + seed = 1 # Create the data set - x, f, g = create_func() + x, f, g = create_func(seed=seed) # Whether to learn from the derivatives use_derivatives = False x_tr, f_tr, x_te, f_te = make_train_test_set( @@ -185,10 +182,6 @@ def test_line(self): # Make the local optimizer local_optimizer = ScipyOptimizer( maxiter=500, - jac=True, - method="l-bfgs-b", - use_bounds=False, - tol=1e-12, ) # Make the global optimizer optimizer = IterativeLineOptimizer( @@ -200,7 +193,7 @@ def test_line(self): parallel=True, ) # Make the boundary conditions for the tests - bounds_trans = VariableTransformation(bounds=None) + bounds_trans = VariableTransformation() # Construct the hyperparameter fitter hpfitter = HyperparameterFitter( func=LogLikelihood(), @@ -214,7 +207,7 @@ def test_line(self): use_derivatives=use_derivatives, ) # Set random seed to give the same results every time - np.random.seed(1) + gp.set_seed(seed=seed) # Optimize the hyperparameters sol = gp.optimize( x_tr, @@ -251,11 +244,13 @@ def test_line_search_scale(self): from catlearn.regression.gp.hpfitter import HyperparameterFitter from catlearn.regression.gp.hpboundary import VariableTransformation + # Set random seed to give the same results every time + seed = 1 # Create the data set - x, f, g = create_func() + x, f, g = create_func(seed=seed) # Whether to learn from the derivatives use_derivatives = False - x_tr, f_tr, x_te, f_te = make_train_test_set( + x_tr, f_tr, _, _ = make_train_test_set( x, f, g, @@ -264,7 +259,7 @@ def test_line_search_scale(self): use_derivatives=use_derivatives, ) # Make the boundary conditions for the tests - bounds_trans = VariableTransformation(bounds=None) + bounds_trans = VariableTransformation() # Make the line optimizer line_optimizer = FineGridSearch( optimize=True, @@ -295,7 +290,7 @@ def test_line_search_scale(self): use_derivatives=use_derivatives, ) # Set random seed to give the same results every time - np.random.seed(1) + gp.set_seed(seed=seed) # Optimize the hyperparameters sol = gp.optimize( x_tr, diff --git a/tests/test_gp_pdistributions.py b/tests/test_gp_pdistributions.py index 2f746f95..995ba525 100644 --- a/tests/test_gp_pdistributions.py +++ b/tests/test_gp_pdistributions.py @@ -1,5 +1,4 @@ import unittest -import numpy as np from .functions import create_func, make_train_test_set, check_minima @@ -24,11 +23,13 @@ def test_local_prior(self): ) from catlearn.regression.gp.hpboundary import StrictBoundaries + # Set random seed to give the same results every time + seed = 1 # Create the data set - x, f, g = create_func() + x, f, g = create_func(seed=seed) # Whether to learn from the derivatives use_derivatives = False - x_tr, f_tr, x_te, f_te = make_train_test_set( + x_tr, f_tr, _, _ = make_train_test_set( x, f, g, @@ -40,9 +41,6 @@ def test_local_prior(self): optimizer = ScipyOptimizer( maxiter=500, jac=True, - method="l-bfgs-b", - use_bounds=False, - tol=1e-12, ) # Define the list of prior distribution objects that are tested test_pdis = [ @@ -76,7 +74,7 @@ def test_local_prior(self): use_derivatives=use_derivatives, ) # Set random seed to give the same results every time - np.random.seed(1) + gp.set_seed(seed=seed) # Optimize the hyperparameters sol = gp.optimize( x_tr, @@ -117,11 +115,13 @@ def test_global_prior(self): ) from catlearn.regression.gp.hpboundary import VariableTransformation + # Set random seed to give the same results every time + seed = 1 # Create the data set - x, f, g = create_func() + x, f, g = create_func(seed=seed) # Whether to learn from the derivatives use_derivatives = False - x_tr, f_tr, x_te, f_te = make_train_test_set( + x_tr, f_tr, _, _ = make_train_test_set( x, f, g, @@ -160,7 +160,7 @@ def test_global_prior(self): (True, Invgamma_prior(a=[1e-5], b=[1e-5])), ] # Test the prior distributions - for index, (use_update_pdis, pdis_d) in enumerate(test_pdis): + for use_update_pdis, pdis_d in test_pdis: with self.subTest(use_update_pdis=use_update_pdis, pdis_d=pdis_d): # Construct the prior distribution objects pdis = dict(length=pdis_d.copy(), noise=pdis_d.copy()) @@ -178,7 +178,7 @@ def test_global_prior(self): use_derivatives=use_derivatives, ) # Set random seed to give the same results every time - np.random.seed(1) + gp.set_seed(seed=seed) # Optimize the hyperparameters sol = gp.optimize( x_tr, diff --git a/tests/test_gp_train.py b/tests/test_gp_train.py index 3df10bb8..0c47e363 100644 --- a/tests/test_gp_train.py +++ b/tests/test_gp_train.py @@ -1,5 +1,4 @@ import unittest -import numpy as np from .functions import create_func, make_train_test_set, calculate_rmse @@ -25,11 +24,13 @@ def test_train(self): "Test if the GP can be trained." from catlearn.regression.gp.models import GaussianProcess + # Set random seed to give the same results every time + seed = 1 # Create the data set - x, f, g = create_func() + x, f, g = create_func(seed=seed) # Whether to learn from the derivatives use_derivatives = False - x_tr, f_tr, x_te, f_te = make_train_test_set( + x_tr, f_tr, _, _ = make_train_test_set( x, f, g, @@ -49,8 +50,10 @@ def test_predict1(self): "Test if the GP can predict one test point." from catlearn.regression.gp.models import GaussianProcess + # Set random seed to give the same results every time + seed = 1 # Create the data set - x, f, g = create_func() + x, f, g = create_func(seed=seed) # Whether to learn from the derivatives use_derivatives = False x_tr, f_tr, x_te, f_te = make_train_test_set( @@ -69,7 +72,7 @@ def test_predict1(self): # Train the machine learning model gp.train(x_tr, f_tr) # Predict the energy - ypred, var, var_deriv = gp.predict( + ypred, _, _ = gp.predict( x_te, get_variance=False, get_derivatives=False, @@ -77,14 +80,16 @@ def test_predict1(self): ) # Test the prediction energy errors error = calculate_rmse(f_te[:, 0], ypred[:, 0]) - self.assertTrue(abs(error - 0.02650) < 1e-4) + self.assertTrue(abs(error - 0.00859) < 1e-4) def test_predict(self): "Test if the GP can predict multiple test points." from catlearn.regression.gp.models import GaussianProcess + # Set random seed to give the same results every time + seed = 1 # Create the data set - x, f, g = create_func() + x, f, g = create_func(seed=seed) # Whether to learn from the derivatives use_derivatives = False x_tr, f_tr, x_te, f_te = make_train_test_set( @@ -103,7 +108,7 @@ def test_predict(self): # Train the machine learning model gp.train(x_tr, f_tr) # Predict the energies - ypred, var, var_deriv = gp.predict( + ypred, _, _ = gp.predict( x_te, get_variance=False, get_derivatives=False, @@ -111,14 +116,16 @@ def test_predict(self): ) # Test the prediction energy errors error = calculate_rmse(f_te[:, 0], ypred[:, 0]) - self.assertTrue(abs(error - 1.75102) < 1e-4) + self.assertTrue(abs(error - 0.88457) < 1e-4) def test_predict_var(self): "Test if the GP can predict variance of multiple test point." from catlearn.regression.gp.models import GaussianProcess + # Set random seed to give the same results every time + seed = 1 # Create the data set - x, f, g = create_func() + x, f, g = create_func(seed=seed) # Whether to learn from the derivatives use_derivatives = False x_tr, f_tr, x_te, f_te = make_train_test_set( @@ -137,7 +144,7 @@ def test_predict_var(self): # Train the machine learning model gp.train(x_tr, f_tr) # Predict the energies and uncertainties - ypred, var, var_deriv = gp.predict( + ypred, _, _ = gp.predict( x_te, get_variance=True, get_derivatives=False, @@ -145,7 +152,7 @@ def test_predict_var(self): ) # Test the prediction energy errors error = calculate_rmse(f_te[:, 0], ypred[:, 0]) - self.assertTrue(abs(error - 1.75102) < 1e-4) + self.assertTrue(abs(error - 0.88457) < 1e-4) def test_predict_var_n(self): """ @@ -154,8 +161,10 @@ def test_predict_var_n(self): """ from catlearn.regression.gp.models import GaussianProcess + # Set random seed to give the same results every time + seed = 1 # Create the data set - x, f, g = create_func() + x, f, g = create_func(seed=seed) # Whether to learn from the derivatives use_derivatives = False x_tr, f_tr, x_te, f_te = make_train_test_set( @@ -174,7 +183,7 @@ def test_predict_var_n(self): # Train the machine learning model gp.train(x_tr, f_tr) # Predict the energies and uncertainties - ypred, var, var_deriv = gp.predict( + ypred, _, _ = gp.predict( x_te, get_variance=True, get_derivatives=False, @@ -182,14 +191,16 @@ def test_predict_var_n(self): ) # Test the prediction energy errors error = calculate_rmse(f_te[:, 0], ypred[:, 0]) - self.assertTrue(abs(error - 1.75102) < 1e-4) + self.assertTrue(abs(error - 0.88457) < 1e-4) def test_predict_derivatives(self): "Test if the GP can predict derivatives of multiple test points." from catlearn.regression.gp.models import GaussianProcess + # Set random seed to give the same results every time + seed = 1 # Create the data set - x, f, g = create_func() + x, f, g = create_func(seed=seed) # Whether to learn from the derivatives use_derivatives = False x_tr, f_tr, x_te, f_te = make_train_test_set( @@ -208,17 +219,17 @@ def test_predict_derivatives(self): # Train the machine learning model gp.train(x_tr, f_tr) # Predict the energies, derivatives, and uncertainties - ypred, var, var_deriv = gp.predict( + ypred, _, _ = gp.predict( x_te, get_variance=True, get_derivatives=True, include_noise=False, ) # Check that the derivatives are predicted - self.assertTrue(np.shape(ypred)[1] == 2) + self.assertTrue(ypred.shape[1] == 2) # Test the prediction energy errors error = calculate_rmse(f_te[:, 0], ypred[:, 0]) - self.assertTrue(abs(error - 1.75102) < 1e-4) + self.assertTrue(abs(error - 0.88457) < 1e-4) class TestGPTrainPredictDerivatives(unittest.TestCase): @@ -231,11 +242,13 @@ def test_train(self): "Test if the GP can be trained." from catlearn.regression.gp.models import GaussianProcess + # Set random seed to give the same results every time + seed = 1 # Create the data set - x, f, g = create_func() + x, f, g = create_func(seed=seed) # Whether to learn from the derivatives use_derivatives = True - x_tr, f_tr, x_te, f_te = make_train_test_set( + x_tr, f_tr, _, _ = make_train_test_set( x, f, g, @@ -255,8 +268,10 @@ def test_predict1(self): "Test if the GP can predict one test point." from catlearn.regression.gp.models import GaussianProcess + # Set random seed to give the same results every time + seed = 1 # Create the data set - x, f, g = create_func() + x, f, g = create_func(seed=seed) # Whether to learn from the derivatives use_derivatives = True x_tr, f_tr, x_te, f_te = make_train_test_set( @@ -275,7 +290,7 @@ def test_predict1(self): # Train the machine learning model gp.train(x_tr, f_tr) # Predict the energy - ypred, var, var_deriv = gp.predict( + ypred, _, _ = gp.predict( x_te, get_variance=False, get_derivatives=False, @@ -283,14 +298,16 @@ def test_predict1(self): ) # Test the prediction energy errors error = calculate_rmse(f_te[:, 0], ypred[:, 0]) - self.assertTrue(abs(error - 0.00218) < 1e-4) + self.assertTrue(abs(error - 0.00038) < 1e-4) def test_predict(self): "Test if the GP can predict multiple test points." from catlearn.regression.gp.models import GaussianProcess + # Set random seed to give the same results every time + seed = 1 # Create the data set - x, f, g = create_func() + x, f, g = create_func(seed=seed) # Whether to learn from the derivatives use_derivatives = True x_tr, f_tr, x_te, f_te = make_train_test_set( @@ -309,7 +326,7 @@ def test_predict(self): # Train the machine learning model gp.train(x_tr, f_tr) # Predict the energies - ypred, var, var_deriv = gp.predict( + ypred, _, _ = gp.predict( x_te, get_variance=False, get_derivatives=False, @@ -317,14 +334,16 @@ def test_predict(self): ) # Test the prediction energy errors error = calculate_rmse(f_te[:, 0], ypred[:, 0]) - self.assertTrue(abs(error - 0.13723) < 1e-4) + self.assertTrue(abs(error - 0.2055) < 1e-4) def test_predict_var(self): "Test if the GP can predict variance of multiple test points." from catlearn.regression.gp.models import GaussianProcess + # Set random seed to give the same results every time + seed = 1 # Create the data set - x, f, g = create_func() + x, f, g = create_func(seed=seed) # Whether to learn from the derivatives use_derivatives = True x_tr, f_tr, x_te, f_te = make_train_test_set( @@ -343,7 +362,7 @@ def test_predict_var(self): # Train the machine learning model gp.train(x_tr, f_tr) # Predict the energies and uncertainties - ypred, var, var_deriv = gp.predict( + ypred, _, _ = gp.predict( x_te, get_variance=True, get_derivatives=False, @@ -351,7 +370,7 @@ def test_predict_var(self): ) # Test the prediction energy errors error = calculate_rmse(f_te[:, 0], ypred[:, 0]) - self.assertTrue(abs(error - 0.13723) < 1e-4) + self.assertTrue(abs(error - 0.20550) < 1e-4) def test_predict_var_n(self): """ @@ -360,8 +379,10 @@ def test_predict_var_n(self): """ from catlearn.regression.gp.models import GaussianProcess + # Set random seed to give the same results every time + seed = 1 # Create the data set - x, f, g = create_func() + x, f, g = create_func(seed=seed) # Whether to learn from the derivatives use_derivatives = True x_tr, f_tr, x_te, f_te = make_train_test_set( @@ -380,7 +401,7 @@ def test_predict_var_n(self): # Train the machine learning model gp.train(x_tr, f_tr) # Predict the energies and uncertainties - ypred, var, var_deriv = gp.predict( + ypred, _, _ = gp.predict( x_te, get_variance=True, get_derivatives=False, @@ -388,14 +409,16 @@ def test_predict_var_n(self): ) # Test the prediction energy errors error = calculate_rmse(f_te[:, 0], ypred[:, 0]) - self.assertTrue(abs(error - 0.13723) < 1e-4) + self.assertTrue(abs(error - 0.20550) < 1e-4) def test_predict_derivatives(self): "Test if the GP can predict derivatives of multiple test points." from catlearn.regression.gp.models import GaussianProcess + # Set random seed to give the same results every time + seed = 1 # Create the data set - x, f, g = create_func() + x, f, g = create_func(seed=seed) # Whether to learn from the derivatives use_derivatives = True x_tr, f_tr, x_te, f_te = make_train_test_set( @@ -414,17 +437,17 @@ def test_predict_derivatives(self): # Train the machine learning model gp.train(x_tr, f_tr) # Predict the energies, derivatives, and uncertainties - ypred, var, var_deriv = gp.predict( + ypred, _, _ = gp.predict( x_te, get_variance=True, get_derivatives=True, include_noise=False, ) # Check that the derivatives are predicted - self.assertTrue(np.shape(ypred)[1] == 2) + self.assertTrue(ypred.shape[1] == 2) # Test the prediction energy errors error = calculate_rmse(f_te[:, 0], ypred[:, 0]) - self.assertTrue(abs(error - 0.13723) < 1e-4) + self.assertTrue(abs(error - 0.20550) < 1e-4) if __name__ == "__main__": diff --git a/tests/test_save_model.py b/tests/test_save_model.py index b073968b..9c55472b 100644 --- a/tests/test_save_model.py +++ b/tests/test_save_model.py @@ -14,8 +14,10 @@ def test_save_model(self): """ from catlearn.regression.gp.models import GaussianProcess + # Set random seed to give the same results every time + seed = 1 # Create the data set - x, f, g = create_func() + x, f, g = create_func(seed=seed) # Whether to learn from the derivatives use_derivatives = False x_tr, f_tr, x_te, f_te = make_train_test_set( @@ -42,7 +44,7 @@ def test_save_model(self): ) gp2 = gp2.load_model("test_model.pkl") # Predict the energy - ypred, var, var_deriv = gp2.predict( + ypred, _, _ = gp2.predict( x_te, get_variance=False, get_derivatives=False, @@ -50,7 +52,7 @@ def test_save_model(self): ) # Test the prediction energy errors error = calculate_rmse(f_te[:, 0], ypred[:, 0]) - self.assertTrue(abs(error - 0.02650) < 1e-4) + self.assertTrue(abs(error - 0.00859) < 1e-4) if __name__ == "__main__": diff --git a/tests/test_tp_objectivefunctions.py b/tests/test_tp_objectivefunctions.py index 0bf51f69..95dfdd8b 100644 --- a/tests/test_tp_objectivefunctions.py +++ b/tests/test_tp_objectivefunctions.py @@ -1,5 +1,4 @@ import unittest -import numpy as np from .functions import create_func, make_train_test_set, check_minima @@ -19,11 +18,13 @@ def test_local(self): from catlearn.regression.gp.hpfitter import HyperparameterFitter from catlearn.regression.gp.objectivefunctions.tp import LogLikelihood + # Set random seed to give the same results every time + seed = 1 # Create the data set - x, f, g = create_func() + x, f, g = create_func(seed=seed) # Whether to learn from the derivatives use_derivatives = False - x_tr, f_tr, x_te, f_te = make_train_test_set( + x_tr, f_tr, _, _ = make_train_test_set( x, f, g, @@ -35,9 +36,6 @@ def test_local(self): optimizer = ScipyOptimizer( maxiter=500, jac=True, - method="l-bfgs-b", - use_bounds=False, - tol=1e-12, ) # Construct the hyperparameter fitter hpfitter = HyperparameterFitter( @@ -51,7 +49,7 @@ def test_local(self): use_derivatives=use_derivatives, ) # Set random seed to give the same results every time - np.random.seed(1) + tp.set_seed(seed=seed) # Optimize the hyperparameters sol = tp.optimize( x_tr, @@ -92,13 +90,18 @@ def test_line_search_scale(self): FactorizedLogLikelihood, FactorizedLogLikelihoodSVD, ) - from catlearn.regression.gp.hpboundary import HPBoundaries + from catlearn.regression.gp.hpboundary import ( + HPBoundaries, + VariableTransformation, + ) + # Set random seed to give the same results every time + seed = 1 # Create the data set - x, f, g = create_func() + x, f, g = create_func(seed=seed) # Whether to learn from the derivatives use_derivatives = False - x_tr, f_tr, x_te, f_te = make_train_test_set( + x_tr, f_tr, _, _ = make_train_test_set( x, f, g, @@ -106,6 +109,8 @@ def test_line_search_scale(self): te=1, use_derivatives=use_derivatives, ) + # Make the default boundaries for the hyperparameters + default_bounds = VariableTransformation() # Make fixed boundary conditions for one of the tests fixed_bounds = HPBoundaries( bounds_dict=dict( @@ -132,7 +137,7 @@ def test_line_search_scale(self): # Define the list of objective function objects that are tested obj_list = [ ( - None, + default_bounds, FactorizedLogLikelihood( modification=False, ngrid=250, @@ -140,7 +145,7 @@ def test_line_search_scale(self): ), ), ( - None, + default_bounds, FactorizedLogLikelihood( modification=True, ngrid=250, @@ -148,7 +153,7 @@ def test_line_search_scale(self): ), ), ( - None, + default_bounds, FactorizedLogLikelihood( modification=False, ngrid=80, @@ -156,7 +161,7 @@ def test_line_search_scale(self): ), ), ( - None, + default_bounds, FactorizedLogLikelihood( modification=False, ngrid=80, @@ -172,7 +177,7 @@ def test_line_search_scale(self): ), ), ( - None, + default_bounds, FactorizedLogLikelihoodSVD( modification=False, ngrid=250, @@ -196,7 +201,7 @@ def test_line_search_scale(self): use_derivatives=use_derivatives, ) # Set random seed to give the same results every time - np.random.seed(1) + tp.set_seed(seed=seed) # Optimize the hyperparameters sol = tp.optimize( x_tr, diff --git a/tests/test_tp_optimizer.py b/tests/test_tp_optimizer.py index 9d0f75c3..67693b4c 100644 --- a/tests/test_tp_optimizer.py +++ b/tests/test_tp_optimizer.py @@ -1,5 +1,4 @@ import unittest -import numpy as np from .functions import create_func, make_train_test_set, check_minima @@ -16,11 +15,13 @@ def test_function(self): from catlearn.regression.gp.objectivefunctions.tp import LogLikelihood from catlearn.regression.gp.hpfitter import HyperparameterFitter + # Set random seed to give the same results every time + seed = 1 # Create the data set - x, f, g = create_func() + x, f, g = create_func(seed=seed) # Whether to learn from the derivatives use_derivatives = False - x_tr, f_tr, x_te, f_te = make_train_test_set( + x_tr, f_tr, _, _ = make_train_test_set( x, f, g, @@ -40,7 +41,7 @@ def test_function(self): use_derivatives=use_derivatives, ) # Set random seed to give the same results every time - np.random.seed(1) + tp.set_seed(seed=seed) # Optimize the hyperparameters sol = tp.optimize( x_tr, @@ -51,7 +52,7 @@ def test_function(self): verbose=False, ) # Test the solution is correct - self.assertTrue(abs(sol["fun"] - 502.256) < 1e-2) + self.assertTrue(abs(sol["fun"] - 487.618) < 1e-2) def test_local_jac(self): """ @@ -63,11 +64,13 @@ def test_local_jac(self): from catlearn.regression.gp.objectivefunctions.tp import LogLikelihood from catlearn.regression.gp.hpfitter import HyperparameterFitter + # Set random seed to give the same results every time + seed = 1 # Create the data set - x, f, g = create_func() + x, f, g = create_func(seed=seed) # Whether to learn from the derivatives use_derivatives = False - x_tr, f_tr, x_te, f_te = make_train_test_set( + x_tr, f_tr, _, _ = make_train_test_set( x, f, g, @@ -79,9 +82,6 @@ def test_local_jac(self): optimizer = ScipyOptimizer( maxiter=500, jac=True, - method="l-bfgs-b", - use_bounds=False, - tol=1e-12, ) # Construct the hyperparameter fitter hpfitter = HyperparameterFitter( @@ -95,7 +95,7 @@ def test_local_jac(self): use_derivatives=use_derivatives, ) # Set random seed to give the same results every time - np.random.seed(1) + tp.set_seed(seed=seed) # Optimize the hyperparameters sol = tp.optimize( x_tr, @@ -126,11 +126,13 @@ def test_local_nojac(self): from catlearn.regression.gp.objectivefunctions.tp import LogLikelihood from catlearn.regression.gp.hpfitter import HyperparameterFitter + # Set random seed to give the same results every time + seed = 1 # Create the data set - x, f, g = create_func() + x, f, g = create_func(seed=seed) # Whether to learn from the derivatives use_derivatives = False - x_tr, f_tr, x_te, f_te = make_train_test_set( + x_tr, f_tr, _, _ = make_train_test_set( x, f, g, @@ -142,9 +144,6 @@ def test_local_nojac(self): optimizer = ScipyOptimizer( maxiter=500, jac=False, - method="l-bfgs-b", - use_bounds=False, - tol=1e-12, ) # Construct the hyperparameter fitter hpfitter = HyperparameterFitter( @@ -158,7 +157,7 @@ def test_local_nojac(self): use_derivatives=use_derivatives, ) # Set random seed to give the same results every time - np.random.seed(1) + tp.set_seed(seed=seed) # Optimize the hyperparameters sol = tp.optimize( x_tr, @@ -189,11 +188,13 @@ def test_local_prior(self): from catlearn.regression.gp.hpfitter import HyperparameterFitter from catlearn.regression.gp.pdistributions import Normal_prior + # Set random seed to give the same results every time + seed = 1 # Create the data set - x, f, g = create_func() + x, f, g = create_func(seed=seed) # Whether to learn from the derivatives use_derivatives = False - x_tr, f_tr, x_te, f_te = make_train_test_set( + x_tr, f_tr, _, _ = make_train_test_set( x, f, g, @@ -226,7 +227,7 @@ def test_local_prior(self): noise=Normal_prior(mu=-4.0, std=2.0), ) # Set random seed to give the same results every time - np.random.seed(1) + tp.set_seed(seed=seed) # Optimize the hyperparameters sol = tp.optimize( x_tr, @@ -255,11 +256,13 @@ def test_local_ed_guess(self): from catlearn.regression.gp.hpfitter import HyperparameterFitter from catlearn.regression.gp.hpboundary import StrictBoundaries + # Set random seed to give the same results every time + seed = 1 # Create the data set - x, f, g = create_func() + x, f, g = create_func(seed=seed) # Whether to learn from the derivatives use_derivatives = False - x_tr, f_tr, x_te, f_te = make_train_test_set( + x_tr, f_tr, _, _ = make_train_test_set( x, f, g, @@ -288,7 +291,7 @@ def test_local_ed_guess(self): use_derivatives=use_derivatives, ) # Set random seed to give the same results every time - np.random.seed(1) + tp.set_seed(seed=seed) # Optimize the hyperparameters sol = tp.optimize( x_tr, @@ -324,11 +327,13 @@ def test_random(self): VariableTransformation, ) + # Set random seed to give the same results every time + seed = 1 # Create the data set - x, f, g = create_func() + x, f, g = create_func(seed=seed) # Whether to learn from the derivatives use_derivatives = False - x_tr, f_tr, x_te, f_te = make_train_test_set( + x_tr, f_tr, _, _ = make_train_test_set( x, f, g, @@ -340,9 +345,6 @@ def test_random(self): local_optimizer = ScipyOptimizer( maxiter=500, jac=True, - method="l-bfgs-b", - use_bounds=False, - tol=1e-12, ) # Make the global optimizer optimizer = RandomSamplingOptimizer( @@ -359,7 +361,7 @@ def test_random(self): ) # Define test list of arguments for the random sampling optimizer bounds_list = [ - VariableTransformation(bounds=None), + VariableTransformation(), EducatedBoundaries(), HPBoundaries(bounds_dict=bounds_dict), ] @@ -379,7 +381,7 @@ def test_random(self): use_derivatives=use_derivatives, ) # Set random seed to give the same results every time - np.random.seed(1) + tp.set_seed(seed=seed) # Optimize the hyperparameters sol = tp.optimize( x_tr, @@ -415,11 +417,13 @@ def test_grid(self): VariableTransformation, ) + # Set random seed to give the same results every time + seed = 1 # Create the data set - x, f, g = create_func() + x, f, g = create_func(seed=seed) # Whether to learn from the derivatives use_derivatives = False - x_tr, f_tr, x_te, f_te = make_train_test_set( + x_tr, f_tr, _, _ = make_train_test_set( x, f, g, @@ -431,14 +435,11 @@ def test_grid(self): local_optimizer = ScipyOptimizer( maxiter=500, jac=True, - method="l-bfgs-b", - use_bounds=False, - tol=1e-12, ) # Make the global optimizer kwargs opt_kwargs = dict(maxiter=500, n_each_dim=5, parallel=False) # Make the boundary conditions for the tests - bounds_trans = VariableTransformation(bounds=None) + bounds_trans = VariableTransformation() bounds_ed = EducatedBoundaries() fixed_bounds = HPBoundaries( bounds_dict=dict( @@ -499,7 +500,7 @@ def test_grid(self): use_derivatives=use_derivatives, ) # Set random seed to give the same results every time - np.random.seed(1) + tp.set_seed(seed=seed) # Optimize the hyperparameters sol = tp.optimize( x_tr, @@ -535,11 +536,13 @@ def test_line(self): VariableTransformation, ) + # Set random seed to give the same results every time + seed = 1 # Create the data set - x, f, g = create_func() + x, f, g = create_func(seed=seed) # Whether to learn from the derivatives use_derivatives = False - x_tr, f_tr, x_te, f_te = make_train_test_set( + x_tr, f_tr, _, _ = make_train_test_set( x, f, g, @@ -551,14 +554,11 @@ def test_line(self): local_optimizer = ScipyOptimizer( maxiter=500, jac=True, - method="l-bfgs-b", - use_bounds=False, - tol=1e-12, ) # Make the global optimizer kwargs opt_kwargs = dict(maxiter=500, n_each_dim=10, loops=3, parallel=False) # Make the boundary conditions for the tests - bounds_trans = VariableTransformation(bounds=None) + bounds_trans = VariableTransformation() bounds_ed = EducatedBoundaries() fixed_bounds = HPBoundaries( bounds_dict=dict( @@ -619,7 +619,7 @@ def test_line(self): use_derivatives=use_derivatives, ) # Set random seed to give the same results every time - np.random.seed(1) + tp.set_seed(seed=seed) # Optimize the hyperparameters sol = tp.optimize( x_tr, @@ -647,11 +647,13 @@ def test_basin(self): from catlearn.regression.gp.objectivefunctions.tp import LogLikelihood from catlearn.regression.gp.hpfitter import HyperparameterFitter + # Set random seed to give the same results every time + seed = 1 # Create the data set - x, f, g = create_func() + x, f, g = create_func(seed=seed) # Whether to learn from the derivatives use_derivatives = False - x_tr, f_tr, x_te, f_te = make_train_test_set( + x_tr, f_tr, _, _ = make_train_test_set( x, f, g, @@ -687,7 +689,7 @@ def test_basin(self): use_derivatives=use_derivatives, ) # Set random seed to give the same results every time - np.random.seed(1) + tp.set_seed(seed=seed) # Optimize the hyperparameters sol = tp.optimize( x_tr, @@ -719,11 +721,13 @@ def test_annealling(self): EducatedBoundaries, ) + # Set random seed to give the same results every time + seed = 1 # Create the data set - x, f, g = create_func() + x, f, g = create_func(seed=seed) # Whether to learn from the derivatives use_derivatives = False - x_tr, f_tr, x_te, f_te = make_train_test_set( + x_tr, f_tr, _, _ = make_train_test_set( x, f, g, @@ -776,7 +780,7 @@ def test_annealling(self): use_derivatives=use_derivatives, ) # Set random seed to give the same results every time - np.random.seed(1) + tp.set_seed(seed=seed) # Optimize the hyperparameters sol = tp.optimize( x_tr, @@ -811,11 +815,13 @@ def test_annealling_trans(self): VariableTransformation, ) + # Set random seed to give the same results every time + seed = 1 # Create the data set - x, f, g = create_func() + x, f, g = create_func(seed=seed) # Whether to learn from the derivatives use_derivatives = False - x_tr, f_tr, x_te, f_te = make_train_test_set( + x_tr, f_tr, _, _ = make_train_test_set( x, f, g, @@ -841,7 +847,7 @@ def test_annealling_trans(self): local_kwargs=local_kwargs, ) # Make the boundary conditions for the tests - bounds_trans = VariableTransformation(bounds=None) + bounds_trans = VariableTransformation() fixed_bounds = HPBoundaries( bounds_dict=dict( length=[[-3.0, 3.0]], @@ -869,7 +875,7 @@ def test_annealling_trans(self): use_derivatives=use_derivatives, ) # Set random seed to give the same results every time - np.random.seed(1) + tp.set_seed(seed=seed) # Optimize the hyperparameters sol = tp.optimize( x_tr, @@ -912,11 +918,13 @@ def test_line_search_scale(self): VariableTransformation, ) + # Set random seed to give the same results every time + seed = 1 # Create the data set - x, f, g = create_func() + x, f, g = create_func(seed=seed) # Whether to learn from the derivatives use_derivatives = False - x_tr, f_tr, x_te, f_te = make_train_test_set( + x_tr, f_tr, _, _ = make_train_test_set( x, f, g, @@ -927,7 +935,7 @@ def test_line_search_scale(self): # Make the dictionary of the optimization opt_kwargs = dict(maxiter=500, jac=False, tol=1e-5, parallel=False) # Make the boundary conditions for the tests - bounds_trans = VariableTransformation(bounds=None) + bounds_trans = VariableTransformation() bounds_ed = EducatedBoundaries() fixed_bounds = HPBoundaries( bounds_dict=dict( @@ -1027,7 +1035,7 @@ def test_line_search_scale(self): use_derivatives=use_derivatives, ) # Set random seed to give the same results every time - np.random.seed(1) + tp.set_seed(seed=seed) # Optimize the hyperparameters sol = tp.optimize( x_tr, diff --git a/tests/test_tp_optimizer_parallel.py b/tests/test_tp_optimizer_parallel.py index 36732649..ee7e1cf1 100644 --- a/tests/test_tp_optimizer_parallel.py +++ b/tests/test_tp_optimizer_parallel.py @@ -1,5 +1,4 @@ import unittest -import numpy as np from .functions import create_func, make_train_test_set, check_minima @@ -22,11 +21,13 @@ def test_random(self): from catlearn.regression.gp.objectivefunctions.tp import LogLikelihood from catlearn.regression.gp.hpfitter import HyperparameterFitter + # Set random seed to give the same results every time + seed = 1 # Create the data set - x, f, g = create_func() + x, f, g = create_func(seed=seed) # Whether to learn from the derivatives use_derivatives = False - x_tr, f_tr, x_te, f_te = make_train_test_set( + x_tr, f_tr, _, _ = make_train_test_set( x, f, g, @@ -38,9 +39,6 @@ def test_random(self): local_optimizer = ScipyOptimizer( maxiter=500, jac=True, - method="l-bfgs-b", - use_bounds=False, - tol=1e-12, ) # Make the global optimizer optimizer = RandomSamplingOptimizer( @@ -61,7 +59,7 @@ def test_random(self): use_derivatives=use_derivatives, ) # Set random seed to give the same results every time - np.random.seed(1) + tp.set_seed(seed=seed) # Optimize the hyperparameters sol = tp.optimize( x_tr, @@ -95,11 +93,13 @@ def test_grid(self): from catlearn.regression.gp.hpfitter import HyperparameterFitter from catlearn.regression.gp.hpboundary import VariableTransformation + # Set random seed to give the same results every time + seed = 1 # Create the data set - x, f, g = create_func() + x, f, g = create_func(seed=seed) # Whether to learn from the derivatives use_derivatives = False - x_tr, f_tr, x_te, f_te = make_train_test_set( + x_tr, f_tr, _, _ = make_train_test_set( x, f, g, @@ -111,9 +111,6 @@ def test_grid(self): local_optimizer = ScipyOptimizer( maxiter=500, jac=True, - method="l-bfgs-b", - use_bounds=False, - tol=1e-12, ) # Make the global optimizer optimizer = GridOptimizer( @@ -124,7 +121,7 @@ def test_grid(self): parallel=True, ) # Make the boundary conditions for the tests - bounds_trans = VariableTransformation(bounds=None) + bounds_trans = VariableTransformation() # Construct the hyperparameter fitter hpfitter = HyperparameterFitter( func=LogLikelihood(), @@ -138,7 +135,7 @@ def test_grid(self): use_derivatives=use_derivatives, ) # Set random seed to give the same results every time - np.random.seed(1) + tp.set_seed(seed=seed) # Optimize the hyperparameters sol = tp.optimize( x_tr, @@ -170,11 +167,13 @@ def test_line(self): from catlearn.regression.gp.hpfitter import HyperparameterFitter from catlearn.regression.gp.hpboundary import VariableTransformation + # Set random seed to give the same results every time + seed = 1 # Create the data set - x, f, g = create_func() + x, f, g = create_func(seed=seed) # Whether to learn from the derivatives use_derivatives = False - x_tr, f_tr, x_te, f_te = make_train_test_set( + x_tr, f_tr, _, _ = make_train_test_set( x, f, g, @@ -186,9 +185,6 @@ def test_line(self): local_optimizer = ScipyOptimizer( maxiter=500, jac=True, - method="l-bfgs-b", - use_bounds=False, - tol=1e-12, ) # Make the global optimizer optimizer = IterativeLineOptimizer( @@ -200,7 +196,7 @@ def test_line(self): parallel=True, ) # Make the boundary conditions for the tests - bounds_trans = VariableTransformation(bounds=None) + bounds_trans = VariableTransformation() # Construct the hyperparameter fitter hpfitter = HyperparameterFitter( func=LogLikelihood(), @@ -214,7 +210,7 @@ def test_line(self): use_derivatives=use_derivatives, ) # Set random seed to give the same results every time - np.random.seed(1) + tp.set_seed(seed=seed) # Optimize the hyperparameters sol = tp.optimize( x_tr, @@ -251,11 +247,13 @@ def test_line_search_scale(self): from catlearn.regression.gp.hpfitter import HyperparameterFitter from catlearn.regression.gp.hpboundary import VariableTransformation + # Set random seed to give the same results every time + seed = 1 # Create the data set - x, f, g = create_func() + x, f, g = create_func(seed=seed) # Whether to learn from the derivatives use_derivatives = False - x_tr, f_tr, x_te, f_te = make_train_test_set( + x_tr, f_tr, _, _ = make_train_test_set( x, f, g, @@ -264,7 +262,7 @@ def test_line_search_scale(self): use_derivatives=use_derivatives, ) # Make the boundary conditions for the tests - bounds_trans = VariableTransformation(bounds=None) + bounds_trans = VariableTransformation() # Make the line optimizer line_optimizer = FineGridSearch( optimize=True, @@ -295,7 +293,7 @@ def test_line_search_scale(self): use_derivatives=use_derivatives, ) # Set random seed to give the same results every time - np.random.seed(1) + tp.set_seed(seed=seed) # Optimize the hyperparameters sol = tp.optimize( x_tr, diff --git a/tests/test_tp_train.py b/tests/test_tp_train.py index ceaf83c0..45e91cee 100644 --- a/tests/test_tp_train.py +++ b/tests/test_tp_train.py @@ -1,5 +1,4 @@ import unittest -import numpy as np from .functions import create_func, make_train_test_set, calculate_rmse @@ -22,11 +21,13 @@ def test_train(self): "Test if the TP can be trained." from catlearn.regression.gp.models import TProcess + # Set random seed to give the same results every time + seed = 1 # Create the data set - x, f, g = create_func() + x, f, g = create_func(seed=seed) # Whether to learn from the derivatives use_derivatives = False - x_tr, f_tr, x_te, f_te = make_train_test_set( + x_tr, f_tr, _, _ = make_train_test_set( x, f, g, @@ -43,8 +44,10 @@ def test_predict1(self): "Test if the TP can predict one test point." from catlearn.regression.gp.models import TProcess + # Set random seed to give the same results every time + seed = 1 # Create the data set - x, f, g = create_func() + x, f, g = create_func(seed=seed) # Whether to learn from the derivatives use_derivatives = False x_tr, f_tr, x_te, f_te = make_train_test_set( @@ -60,7 +63,7 @@ def test_predict1(self): # Train the machine learning model tp.train(x_tr, f_tr) # Predict the energy - ypred, var, var_deriv = tp.predict( + ypred, _, _ = tp.predict( x_te, get_variance=False, get_derivatives=False, @@ -68,14 +71,16 @@ def test_predict1(self): ) # Test the prediction energy errors error = calculate_rmse(f_te[:, 0], ypred[:, 0]) - self.assertTrue(abs(error - 0.02650) < 1e-4) + self.assertTrue(abs(error - 0.00859) < 1e-4) def test_predict(self): "Test if the TP can predict multiple test points." from catlearn.regression.gp.models import TProcess + # Set random seed to give the same results every time + seed = 1 # Create the data set - x, f, g = create_func() + x, f, g = create_func(seed=seed) # Whether to learn from the derivatives use_derivatives = False x_tr, f_tr, x_te, f_te = make_train_test_set( @@ -91,7 +96,7 @@ def test_predict(self): # Train the machine learning model tp.train(x_tr, f_tr) # Predict the energies - ypred, var, var_deriv = tp.predict( + ypred, _, _ = tp.predict( x_te, get_variance=False, get_derivatives=False, @@ -99,14 +104,16 @@ def test_predict(self): ) # Test the prediction energy errors error = calculate_rmse(f_te[:, 0], ypred[:, 0]) - self.assertTrue(abs(error - 1.75102) < 1e-4) + self.assertTrue(abs(error - 0.88457) < 1e-4) def test_predict_var(self): "Test if the TP can predict variance of multiple test point." from catlearn.regression.gp.models import TProcess + # Set random seed to give the same results every time + seed = 1 # Create the data set - x, f, g = create_func() + x, f, g = create_func(seed=seed) # Whether to learn from the derivatives use_derivatives = False x_tr, f_tr, x_te, f_te = make_train_test_set( @@ -122,7 +129,7 @@ def test_predict_var(self): # Train the machine learning model tp.train(x_tr, f_tr) # Predict the energies and uncertainties - ypred, var, var_deriv = tp.predict( + ypred, _, _ = tp.predict( x_te, get_variance=True, get_derivatives=False, @@ -130,7 +137,7 @@ def test_predict_var(self): ) # Test the prediction energy errors error = calculate_rmse(f_te[:, 0], ypred[:, 0]) - self.assertTrue(abs(error - 1.75102) < 1e-4) + self.assertTrue(abs(error - 0.88457) < 1e-4) def test_predict_var_n(self): """ @@ -139,8 +146,10 @@ def test_predict_var_n(self): """ from catlearn.regression.gp.models import TProcess + # Set random seed to give the same results every time + seed = 1 # Create the data set - x, f, g = create_func() + x, f, g = create_func(seed=seed) # Whether to learn from the derivatives use_derivatives = False x_tr, f_tr, x_te, f_te = make_train_test_set( @@ -156,7 +165,7 @@ def test_predict_var_n(self): # Train the machine learning model tp.train(x_tr, f_tr) # Predict the energies and uncertainties - ypred, var, var_deriv = tp.predict( + ypred, _, _ = tp.predict( x_te, get_variance=True, get_derivatives=False, @@ -164,14 +173,16 @@ def test_predict_var_n(self): ) # Test the prediction energy errors error = calculate_rmse(f_te[:, 0], ypred[:, 0]) - self.assertTrue(abs(error - 1.75102) < 1e-4) + self.assertTrue(abs(error - 0.88457) < 1e-4) def test_predict_derivatives(self): "Test if the TP can predict derivatives of multiple test points." from catlearn.regression.gp.models import TProcess + # Set random seed to give the same results every time + seed = 1 # Create the data set - x, f, g = create_func() + x, f, g = create_func(seed=seed) # Whether to learn from the derivatives use_derivatives = False x_tr, f_tr, x_te, f_te = make_train_test_set( @@ -187,17 +198,17 @@ def test_predict_derivatives(self): # Train the machine learning model tp.train(x_tr, f_tr) # Predict the energies, derivatives, and uncertainties - ypred, var, var_deriv = tp.predict( + ypred, _, _ = tp.predict( x_te, get_variance=True, get_derivatives=True, include_noise=False, ) # Check that the derivatives are predicted - self.assertTrue(np.shape(ypred)[1] == 2) + self.assertTrue(ypred.shape[1] == 2) # Test the prediction energy errors error = calculate_rmse(f_te[:, 0], ypred[:, 0]) - self.assertTrue(abs(error - 1.75102) < 1e-4) + self.assertTrue(abs(error - 0.88457) < 1e-4) class TestTPTrainPredictDerivatives(unittest.TestCase): @@ -210,8 +221,10 @@ def test_train(self): "Test if the TP can be trained." from catlearn.regression.gp.models import TProcess + # Set random seed to give the same results every time + seed = 1 # Create the data set - x, f, g = create_func() + x, f, g = create_func(seed=seed) # Whether to learn from the derivatives use_derivatives = True x_tr, f_tr, x_te, f_te = make_train_test_set( @@ -231,8 +244,10 @@ def test_predict1(self): "Test if the TP can predict one test point." from catlearn.regression.gp.models import TProcess + # Set random seed to give the same results every time + seed = 1 # Create the data set - x, f, g = create_func() + x, f, g = create_func(seed=seed) # Whether to learn from the derivatives use_derivatives = True x_tr, f_tr, x_te, f_te = make_train_test_set( @@ -248,7 +263,7 @@ def test_predict1(self): # Train the machine learning model tp.train(x_tr, f_tr) # Predict the energy - ypred, var, var_deriv = tp.predict( + ypred, _, _ = tp.predict( x_te, get_variance=False, get_derivatives=False, @@ -256,14 +271,16 @@ def test_predict1(self): ) # Test the prediction energy errors error = calculate_rmse(f_te[:, 0], ypred[:, 0]) - self.assertTrue(abs(error - 0.00218) < 1e-4) + self.assertTrue(abs(error - 0.00038) < 1e-4) def test_predict(self): "Test if the TP can predict multiple test points." from catlearn.regression.gp.models import TProcess + # Set random seed to give the same results every time + seed = 1 # Create the data set - x, f, g = create_func() + x, f, g = create_func(seed=seed) # Whether to learn from the derivatives use_derivatives = True x_tr, f_tr, x_te, f_te = make_train_test_set( @@ -279,7 +296,7 @@ def test_predict(self): # Train the machine learning model tp.train(x_tr, f_tr) # Predict the energies - ypred, var, var_deriv = tp.predict( + ypred, _, _ = tp.predict( x_te, get_variance=False, get_derivatives=False, @@ -287,14 +304,16 @@ def test_predict(self): ) # Test the prediction energy errors error = calculate_rmse(f_te[:, 0], ypred[:, 0]) - self.assertTrue(abs(error - 0.13723) < 1e-4) + self.assertTrue(abs(error - 0.20550) < 1e-4) def test_predict_var(self): "Test if the TP can predict variance of multiple test points." from catlearn.regression.gp.models import TProcess + # Set random seed to give the same results every time + seed = 1 # Create the data set - x, f, g = create_func() + x, f, g = create_func(seed=seed) # Whether to learn from the derivatives use_derivatives = True x_tr, f_tr, x_te, f_te = make_train_test_set( @@ -310,7 +329,7 @@ def test_predict_var(self): # Train the machine learning model tp.train(x_tr, f_tr) # Predict the energies and uncertainties - ypred, var, var_deriv = tp.predict( + ypred, _, _ = tp.predict( x_te, get_variance=True, get_derivatives=False, @@ -318,7 +337,7 @@ def test_predict_var(self): ) # Test the prediction energy errors error = calculate_rmse(f_te[:, 0], ypred[:, 0]) - self.assertTrue(abs(error - 0.13723) < 1e-4) + self.assertTrue(abs(error - 0.20550) < 1e-4) def test_predict_var_n(self): """ @@ -327,8 +346,10 @@ def test_predict_var_n(self): """ from catlearn.regression.gp.models import TProcess + # Set random seed to give the same results every time + seed = 1 # Create the data set - x, f, g = create_func() + x, f, g = create_func(seed=seed) # Whether to learn from the derivatives use_derivatives = True x_tr, f_tr, x_te, f_te = make_train_test_set( @@ -344,7 +365,7 @@ def test_predict_var_n(self): # Train the machine learning model tp.train(x_tr, f_tr) # Predict the energies and uncertainties - ypred, var, var_deriv = tp.predict( + ypred, _, _ = tp.predict( x_te, get_variance=True, get_derivatives=False, @@ -352,14 +373,16 @@ def test_predict_var_n(self): ) # Test the prediction energy errors error = calculate_rmse(f_te[:, 0], ypred[:, 0]) - self.assertTrue(abs(error - 0.13723) < 1e-4) + self.assertTrue(abs(error - 0.20550) < 1e-4) def test_predict_derivatives(self): "Test if the TP can predict derivatives of multiple test points." from catlearn.regression.gp.models import TProcess + # Set random seed to give the same results every time + seed = 1 # Create the data set - x, f, g = create_func() + x, f, g = create_func(seed=seed) # Whether to learn from the derivatives use_derivatives = True x_tr, f_tr, x_te, f_te = make_train_test_set( @@ -375,17 +398,17 @@ def test_predict_derivatives(self): # Train the machine learning model tp.train(x_tr, f_tr) # Predict the energies, derivatives, and uncertainties - ypred, var, var_deriv = tp.predict( + ypred, _, _ = tp.predict( x_te, get_variance=True, get_derivatives=True, include_noise=False, ) # Check that the derivatives are predicted - self.assertTrue(np.shape(ypred)[1] == 2) + self.assertTrue(ypred.shape[1] == 2) # Test the prediction energy errors error = calculate_rmse(f_te[:, 0], ypred[:, 0]) - self.assertTrue(abs(error - 0.13723) < 1e-4) + self.assertTrue(abs(error - 0.20550) < 1e-4) if __name__ == "__main__": From 71f7996f39a03e9368404db3e4aab5a9a32ddca4 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Thu, 15 May 2025 17:09:25 +0200 Subject: [PATCH 109/194] dtype debug --- catlearn/regression/gp/baseline/idpp.py | 3 ++- catlearn/regression/gp/baseline/mie.py | 2 +- catlearn/regression/gp/fingerprint/cartesian.py | 2 +- catlearn/regression/gp/fingerprint/fingerprint.py | 2 +- catlearn/regression/gp/fingerprint/geometry.py | 12 ++++++------ 5 files changed, 11 insertions(+), 10 deletions(-) diff --git a/catlearn/regression/gp/baseline/idpp.py b/catlearn/regression/gp/baseline/idpp.py index b1c9a1fe..ff389e10 100644 --- a/catlearn/regression/gp/baseline/idpp.py +++ b/catlearn/regression/gp/baseline/idpp.py @@ -10,7 +10,7 @@ def __init__( wrap=False, mic=False, use_forces=True, - dtype=None, + dtype=float, **kwargs, ): """ @@ -76,6 +76,7 @@ def update_arguments( self: The updated object itself. """ super().update_arguments( + reduce_dimensions=False, use_forces=use_forces, dtype=dtype, ) diff --git a/catlearn/regression/gp/baseline/mie.py b/catlearn/regression/gp/baseline/mie.py index dee50f68..1201d8e6 100644 --- a/catlearn/regression/gp/baseline/mie.py +++ b/catlearn/regression/gp/baseline/mie.py @@ -22,7 +22,7 @@ def __init__( denergy=0.1, power_r=8, power_a=6, - dtype=None, + dtype=float, **kwargs, ): """ diff --git a/catlearn/regression/gp/fingerprint/cartesian.py b/catlearn/regression/gp/fingerprint/cartesian.py index 9cc4deec..8e7eabe1 100644 --- a/catlearn/regression/gp/fingerprint/cartesian.py +++ b/catlearn/regression/gp/fingerprint/cartesian.py @@ -8,7 +8,7 @@ def __init__( self, reduce_dimensions=True, use_derivatives=True, - dtype=None, + dtype=float, **kwargs, ): """ diff --git a/catlearn/regression/gp/fingerprint/fingerprint.py b/catlearn/regression/gp/fingerprint/fingerprint.py index 7fb11242..a696a92e 100644 --- a/catlearn/regression/gp/fingerprint/fingerprint.py +++ b/catlearn/regression/gp/fingerprint/fingerprint.py @@ -8,7 +8,7 @@ def __init__( self, reduce_dimensions=True, use_derivatives=True, - dtype=None, + dtype=float, **kwargs, ): """ diff --git a/catlearn/regression/gp/fingerprint/geometry.py b/catlearn/regression/gp/fingerprint/geometry.py index 45a057c3..39a536de 100644 --- a/catlearn/regression/gp/fingerprint/geometry.py +++ b/catlearn/regression/gp/fingerprint/geometry.py @@ -180,7 +180,7 @@ def get_ncells( cell_cutoff=4.0, atomic_numbers=None, remove0=False, - dtype=None, + dtype=float, **kwargs, ): """ @@ -251,7 +251,7 @@ def get_full_distance_matrix( mic=False, all_ncells=False, cell_cutoff=4.0, - dtype=None, + dtype=float, **kwargs, ): """ @@ -347,7 +347,7 @@ def get_all_distances( mic=False, all_ncells=False, cell_cutoff=4.0, - dtype=None, + dtype=float, **kwargs, ): """ @@ -544,7 +544,7 @@ def get_covalent_distances( masked, nmi_ind, nmj_ind, - dtype=None, + dtype=float, **kwargs, ): """ @@ -585,7 +585,7 @@ def get_covalent_distances( return covdis -def mic_distance(dist_vec, cell, pbc, use_vector=False, dtype=None, **kwargs): +def mic_distance(dist_vec, cell, pbc, use_vector=False, dtype=float, **kwargs): """ Get the minimum image convention of the distances. @@ -706,7 +706,7 @@ def mic_general_distance( cell, pbc_nc, use_vector=False, - dtype=None, + dtype=float, **kwargs, ): """ From 1d63d077e405da10557163a4acd302929815a064 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Thu, 15 May 2025 17:10:14 +0200 Subject: [PATCH 110/194] Use item(0) instead of [0][0] --- catlearn/regression/gp/calculator/mlmodel.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/catlearn/regression/gp/calculator/mlmodel.py b/catlearn/regression/gp/calculator/mlmodel.py index 2b0320e0..c2dffc61 100644 --- a/catlearn/regression/gp/calculator/mlmodel.py +++ b/catlearn/regression/gp/calculator/mlmodel.py @@ -372,7 +372,7 @@ def model_prediction( use_derivatives=get_forces, ) # Extract the energy - energy = y[0][0] + energy = y.item(0) # Extract the forces if they are requested if get_forces: forces = -y[0][1:] @@ -380,10 +380,10 @@ def model_prediction( forces = None # Get the uncertainties if they are requested if get_uncertainty: - unc = sqrt(var[0][0]) + unc = sqrt(var.item(0)) # Get the uncertainty of the forces if they are requested if get_force_uncertainties and get_forces: - unc_forces = sqrt(unc[0][1:]) + unc_forces = sqrt(var[0][1:]) else: unc_forces = None # Get the derivatives of the predicted uncertainty From b44ad6dbf5ad44a1288df0a3d222301299cde2ae Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Thu, 15 May 2025 17:11:03 +0200 Subject: [PATCH 111/194] =?UTF-8?q?Use=20Python=E2=80=99s=20rounding=20for?= =?UTF-8?q?=20float?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- catlearn/regression/gp/calculator/mlcalc.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/catlearn/regression/gp/calculator/mlcalc.py b/catlearn/regression/gp/calculator/mlcalc.py index 92fca055..a3e5d71b 100644 --- a/catlearn/regression/gp/calculator/mlcalc.py +++ b/catlearn/regression/gp/calculator/mlcalc.py @@ -502,7 +502,10 @@ def store_properties(self, results, **kwargs): if key in self.implemented_properties: # Round the predictions if needed if self.round_pred is not None: - value = round_(value, self.round_pred) + if isinstance(value, float): + value = round(value, self.round_pred) + else: + value = round_(value, self.round_pred) # Save the properties in the results self.results[key] = value return self.results From afcb835c07c7e9b96c69a31142aa6177127492b5 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Thu, 15 May 2025 17:11:32 +0200 Subject: [PATCH 112/194] Use rounding in tests and test BOCalc --- tests/test_gp_baseline.py | 7 ++- tests/test_gp_calc.py | 126 ++++++++++++++++++++++++++++++++------ 2 files changed, 113 insertions(+), 20 deletions(-) diff --git a/tests/test_gp_baseline.py b/tests/test_gp_baseline.py index 7fbaba31..534b7fbf 100644 --- a/tests/test_gp_baseline.py +++ b/tests/test_gp_baseline.py @@ -49,6 +49,7 @@ def test_predict(self): hpfitter = HyperparameterFitter( func=LogLikelihood(), optimizer=optimizer, + round_hp=3, ) # Define the list of baseline objects that are tested baseline_list = [ @@ -58,7 +59,7 @@ def test_predict(self): MieCalculator(), ] # Make a list of the error values that the test compares to - error_list = [2.12101, 3.21252, 0.26580, 1.08779] + error_list = [2.11773, 3.21230, 0.26532, 1.08910] # Test the baseline objects for index, baseline in enumerate(baseline_list): with self.subTest(baseline=baseline): @@ -81,6 +82,7 @@ def test_predict(self): fingerprint=fp, use_derivatives=use_derivatives, use_fingerprint=True, + round_targets=5, ) # Define the machine learning model mlmodel = MLModel( @@ -92,6 +94,7 @@ def test_predict(self): # Construct the machine learning calculator mlcalc = MLCalculator( mlmodel=mlmodel, + round_pred=5, ) # Set the random seed for the calculator mlcalc.set_seed(seed=seed) @@ -109,7 +112,7 @@ def test_predict(self): atoms.get_forces() # Test the prediction energy error for a single test system error = abs(f_te.item(0) - energy) - self.assertTrue(abs(error - error_list[index]) < 1e-4) + self.assertTrue(abs(error - error_list[index]) < 1e-2) if __name__ == "__main__": diff --git a/tests/test_gp_calc.py b/tests/test_gp_calc.py index 7284037b..3a697341 100644 --- a/tests/test_gp_calc.py +++ b/tests/test_gp_calc.py @@ -46,10 +46,12 @@ def test_predict(self): # Make the hyperparameter fitter optimizer = ScipyOptimizer( maxiter=500, + jac=True, ) hpfitter = HyperparameterFitter( func=LogLikelihood(), optimizer=optimizer, + round_hp=3, ) # Set the maximum number of points to use for the reduced databases npoints = 8 @@ -124,24 +126,24 @@ def test_predict(self): ] # Make a list of the error values that the test compares to error_list = [ - 2.12101, - 2.12101, - 0.34274, - 0.34274, - 1.96252, - 0.34274, - 0.71961, - 0.71964, - 0.89345, - 0.35748, - 5.04941, - 6.25126, - 7.38147, + 2.11773, + 2.11773, + 0.33617, + 0.33617, + 1.95853, + 0.33617, + 0.71664, + 0.71664, + 0.89497, + 0.35126, + 5.04806, + 6.25093, + 7.38153, 9.47098, - 1.76462, - 1.76442, - 1.76462, - 1.76442, + 1.76828, + 1.76828, + 1.76828, + 1.76828, ] # Test the database objects for index, (data, use_fingerprint, data_kwarg) in enumerate( @@ -171,6 +173,7 @@ def test_predict(self): fingerprint=fp, use_derivatives=use_derivatives, use_fingerprint=use_fingerprint, + round_targets=5, **data_kwarg ) # Define the machine learning model @@ -183,6 +186,7 @@ def test_predict(self): # Construct the machine learning calculator mlcalc = MLCalculator( mlmodel=mlmodel, + round_pred=5, ) # Set the random seed for the calculator mlcalc.set_seed(seed=seed) @@ -215,7 +219,93 @@ def test_predict(self): atoms.get_forces() # Test the prediction energy error for a single test system error = abs(f_te.item(0) - energy) - self.assertTrue(abs(error - error_list[index]) < 1e-4) + self.assertTrue(abs(error - error_list[index]) < 1e-2) + + def test_bayesian_calc(self): + "Test if the GP bayesian calculator can predict energy and forces." + from catlearn.regression.gp.models import GaussianProcess + from catlearn.regression.gp.kernel import SE + from catlearn.regression.gp.optimizers import ScipyOptimizer + from catlearn.regression.gp.objectivefunctions.gp import LogLikelihood + from catlearn.regression.gp.hpfitter import HyperparameterFitter + from catlearn.regression.gp.fingerprint import Cartesian + from catlearn.regression.gp.calculator import Database + from catlearn.regression.gp.calculator import MLModel, BOCalculator + + # Set random seed to give the same results every time + seed = 1 + # Create the data set + x, f, g = create_h2_atoms(gridsize=50, seed=seed) + # Whether to learn from the derivatives + use_derivatives = True + x_tr, _, x_te, f_te = make_train_test_set( + x, + f, + g, + tr=10, + te=1, + use_derivatives=use_derivatives, + ) + # Make the hyperparameter fitter + optimizer = ScipyOptimizer( + maxiter=500, + jac=True, + ) + hpfitter = HyperparameterFitter( + func=LogLikelihood(), + optimizer=optimizer, + round_hp=3, + ) + # Make the fingerprint + use_fingerprint = True + fp = Cartesian( + use_derivatives=use_derivatives, + ) + # Set up the database + database = Database( + fingerprint=fp, + use_derivatives=use_derivatives, + use_fingerprint=use_fingerprint, + round_targets=5, + ) + # Construct the Gaussian process + gp = GaussianProcess( + hp=dict(length=2.0), + use_derivatives=use_derivatives, + kernel=SE( + use_derivatives=use_derivatives, + use_fingerprint=use_fingerprint, + ), + hpfitter=hpfitter, + ) + # Define the machine learning model + mlmodel = MLModel( + model=gp, + database=database, + optimize=True, + baseline=None, + ) + # Construct the machine learning calculator + mlcalc = BOCalculator( + mlmodel=mlmodel, + kappa=2.0, + round_pred=5, + ) + # Set the random seed for the calculator + mlcalc.set_seed(seed=seed) + # Add the training data to the calculator + mlcalc.add_training(x_tr) + # Train the machine learning calculator + mlcalc.train_model() + # Use a single test system for calculating the energy + # and forces with the machine learning calculator + atoms = x_te[0].copy() + atoms.calc = mlcalc + energy = atoms.get_potential_energy() + atoms.get_forces() + # Test the prediction energy error for a single test system + error = abs(f_te.item(0) - energy) + self.assertTrue(abs(error - 1.32997) < 1e-2) if __name__ == "__main__": From f05610779098e82a790b23cbbc15d0d629dcef1f Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Fri, 16 May 2025 13:27:48 +0200 Subject: [PATCH 113/194] Debug of AnneallingOptimizer --- catlearn/regression/gp/optimizers/globaloptimizer.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/catlearn/regression/gp/optimizers/globaloptimizer.py b/catlearn/regression/gp/optimizers/globaloptimizer.py index 36351d2b..9238c9b0 100644 --- a/catlearn/regression/gp/optimizers/globaloptimizer.py +++ b/catlearn/regression/gp/optimizers/globaloptimizer.py @@ -1779,7 +1779,6 @@ def __init__( restart_temp_ratio=2e-05, visit=2.62, accept=-5.0, - seed=None, no_local_search=False, ) # Set default arguments for SciPy's local minimizer @@ -1997,7 +1996,6 @@ def __init__( restart_temp_ratio=2e-05, visit=2.62, accept=-5.0, - seed=None, no_local_search=False, ) # Set default arguments for SciPy's local minimizer From 7776e754ec39dfbd45c434abcd3e4423ab3fbdac Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Fri, 16 May 2025 15:56:46 +0200 Subject: [PATCH 114/194] Check if the atoms in the calculator is the same --- catlearn/regression/gp/calculator/__init__.py | 3 +- .../regression/gp/calculator/copy_atoms.py | 100 +++++++++++++++--- 2 files changed, 90 insertions(+), 13 deletions(-) diff --git a/catlearn/regression/gp/calculator/__init__.py b/catlearn/regression/gp/calculator/__init__.py index b7c5cda6..a1d51992 100644 --- a/catlearn/regression/gp/calculator/__init__.py +++ b/catlearn/regression/gp/calculator/__init__.py @@ -1,5 +1,5 @@ from .database import Database -from .copy_atoms import copy_atoms +from .copy_atoms import compare_atoms, copy_atoms from .database_reduction import ( DatabaseReduction, DatabaseDistance, @@ -23,6 +23,7 @@ __all__ = [ "Database", + "compare_atoms", "copy_atoms", "DatabaseReduction", "DatabaseDistance", diff --git a/catlearn/regression/gp/calculator/copy_atoms.py b/catlearn/regression/gp/calculator/copy_atoms.py index 96031a30..c1deaf56 100644 --- a/catlearn/regression/gp/calculator/copy_atoms.py +++ b/catlearn/regression/gp/calculator/copy_atoms.py @@ -1,39 +1,115 @@ -from numpy import array, isscalar, ndarray +from numpy import array, asarray, isscalar, ndarray from ase.calculators.calculator import Calculator, PropertyNotImplementedError def copy_atoms(atoms, results={}, **kwargs): """ - Copy the atoms object together with the calculated properties. + Copy the atoms instance together with the calculated properties. Parameters: - atoms : ASE Atoms - The ASE Atoms object with a calculator that is copied. - results : dict (optional) + atoms: ASE Atoms instance + The ASE Atoms instance with a calculator that is copied. + results: dict (optional) The properties to be saved in the calculator. If not given, the properties are taken from the calculator. Returns: - atoms0 : ASE Atoms - The copy of the Atoms object with saved data in the calculator. + atoms0: ASE Atoms instance + The copy of the Atoms instance with saved data in the calculator. """ # Check if results are given if not isinstance(results, dict) or len(results) == 0: # Save the properties calculated - if atoms.calc is not None: - results = atoms.calc.results.copy() - # Copy the ASE Atoms object + if atoms.calc is not None and atoms.calc.atoms is not None: + if compare_atoms(atoms, atoms.calc.atoms): + results = atoms.calc.results.copy() + # Copy the ASE Atoms instance atoms0 = atoms.copy() # Store the properties in a calculator atoms0.calc = StoredDataCalculator(atoms, **results) return atoms0 +def compare_atoms( + atoms0, + atoms1, + tol=1e-8, + properties_to_check=["atoms", "positions"], + **kwargs, +): + """ + Compare two atoms instances. + + Parameters: + atoms0: ASE Atoms instance + The first ASE Atoms instance. + atoms1: ASE Atoms + The second ASE Atoms instance. + tol: float (optional) + The tolerance for the comparison. + properties_to_check: list (optional) + The properties to be compared. + + Returns: + bool: True if the atoms instances are equal otherwise False. + """ + # Check if the number of atoms is equal + if len(atoms0) != len(atoms1): + return False + # Check if the chemical symbols are equal + if "atoms" in properties_to_check: + if not ( + asarray(atoms0.get_chemical_symbols()) + == asarray(atoms1.get_chemical_symbols()) + ).all(): + return False + # Check if the positions are equal + if "positions" in properties_to_check: + if abs(atoms0.get_positions() - atoms1.get_positions()).max() > tol: + return False + # Check if the cell is equal + if "cell" in properties_to_check: + if abs(atoms0.get_cell() - atoms1.get_cell()).max() > tol: + return False + # Check if the pbc is equal + if "pbc" in properties_to_check: + if not (asarray(atoms0.get_pbc()) == asarray(atoms1.get_pbc())).all(): + return False + # Check if the initial charges are equal + if "initial_charges" in properties_to_check: + if ( + abs( + atoms0.get_initial_charges() - atoms1.get_initial_charges() + ).max() + > tol + ): + return False + # Check if the initial magnetic moments are equal + if "initial_magnetic_moments" in properties_to_check: + if ( + abs( + atoms0.get_initial_magnetic_moments() + - atoms1.get_initial_magnetic_moments() + ).max() + > tol + ): + return False + # Check if the momenta are equal + if "momenta" in properties_to_check: + if abs(atoms0.get_momenta() - atoms1.get_momenta()).max() > tol: + return False + # Check if the velocities are equal + if "velocities" in properties_to_check: + if abs(atoms0.get_velocities() - atoms1.get_velocities()).max() > tol: + return False + return True + + class StoredDataCalculator(Calculator): """ A special calculator that store the data (results) of a single configuration. - It will raise an exception if the atoms object is changed. + It will raise an exception if the atoms instance is changed. """ name = "unknown" @@ -89,7 +165,7 @@ def get_uncertainty(self, atoms=None, **kwargs): Get the predicted uncertainty of the energy. Parameters: - atoms : ASE Atoms (optional) + atoms: ASE Atoms (optional) The ASE Atoms instance which is used if the uncertainty is not stored. From deaa8fc9f8c0cb2b2f8ee3a4b0635f3f32ff832a Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Mon, 19 May 2025 09:17:46 +0200 Subject: [PATCH 115/194] Update and debug tests --- tests/test_gp_baseline.py | 4 +- tests/test_gp_calc.py | 32 +++++------ tests/test_gp_ensemble.py | 16 +++--- tests/test_gp_fp.py | 44 +++++++-------- tests/test_gp_hpfitter.py | 4 +- tests/test_gp_means.py | 8 +-- tests/test_gp_objectivefunctions.py | 10 ++-- tests/test_gp_optimizer.py | 56 +++++++++---------- tests/test_gp_optimizer_parallel.py | 8 +-- tests/test_gp_pdistributions.py | 4 +- tests/test_gp_train.py | 46 ++++++++-------- tests/test_save_model.py | 6 +- tests/test_tp_objectivefunctions.py | 9 ++- tests/test_tp_optimizer.py | 53 ++++++++---------- tests/test_tp_optimizer_parallel.py | 8 +-- tests/test_tp_train.py | 85 +++++++++++++++++++++-------- 16 files changed, 212 insertions(+), 181 deletions(-) diff --git a/tests/test_gp_baseline.py b/tests/test_gp_baseline.py index 534b7fbf..3aa58655 100644 --- a/tests/test_gp_baseline.py +++ b/tests/test_gp_baseline.py @@ -59,13 +59,13 @@ def test_predict(self): MieCalculator(), ] # Make a list of the error values that the test compares to - error_list = [2.11773, 3.21230, 0.26532, 1.08910] + error_list = [0.47624, 3.21230, 5.03338, 0.38677] # Test the baseline objects for index, baseline in enumerate(baseline_list): with self.subTest(baseline=baseline): # Construct the Gaussian process gp = GaussianProcess( - hp=dict(length=2.0), + hp=dict(length=[2.0], noise=[-5.0], prefactor=[0.0]), use_derivatives=use_derivatives, kernel=SE( use_derivatives=use_derivatives, diff --git a/tests/test_gp_calc.py b/tests/test_gp_calc.py index 3a697341..652bd095 100644 --- a/tests/test_gp_calc.py +++ b/tests/test_gp_calc.py @@ -126,24 +126,24 @@ def test_predict(self): ] # Make a list of the error values that the test compares to error_list = [ - 2.11773, - 2.11773, - 0.33617, - 0.33617, - 1.95853, - 0.33617, + 0.47624, + 0.47624, + 5.19594, + 5.19594, + 1.95852, + 5.19594, 0.71664, 0.71664, 0.89497, - 0.35126, - 5.04806, - 6.25093, + 5.23694, + 1.13717, + 3.52768, 7.38153, 9.47098, - 1.76828, - 1.76828, - 1.76828, - 1.76828, + 0.38060, + 0.38060, + 0.38060, + 0.38060, ] # Test the database objects for index, (data, use_fingerprint, data_kwarg) in enumerate( @@ -156,7 +156,7 @@ def test_predict(self): ): # Construct the Gaussian process gp = GaussianProcess( - hp=dict(length=2.0), + hp=dict(length=[2.0], noise=[-5.0], prefactor=[0.0]), use_derivatives=use_derivatives, kernel=SE( use_derivatives=use_derivatives, @@ -270,7 +270,7 @@ def test_bayesian_calc(self): ) # Construct the Gaussian process gp = GaussianProcess( - hp=dict(length=2.0), + hp=dict(length=[2.0], noise=[-5.0], prefactor=[0.0]), use_derivatives=use_derivatives, kernel=SE( use_derivatives=use_derivatives, @@ -305,7 +305,7 @@ def test_bayesian_calc(self): atoms.get_forces() # Test the prediction energy error for a single test system error = abs(f_te.item(0) - energy) - self.assertTrue(abs(error - 1.32997) < 1e-2) + self.assertTrue(abs(error - 1.05160) < 1e-2) if __name__ == "__main__": diff --git a/tests/test_gp_ensemble.py b/tests/test_gp_ensemble.py index ece91ca5..c5f1864e 100644 --- a/tests/test_gp_ensemble.py +++ b/tests/test_gp_ensemble.py @@ -34,7 +34,7 @@ def test_variance_ensemble(self): ) # Construct the Gaussian process gp = GaussianProcess( - hp=dict(length=2.0), + hp=dict(length=[2.0], noise=[-5.0], prefactor=[0.0]), use_derivatives=use_derivatives, ) # Construct the clustering object @@ -45,7 +45,7 @@ def test_variance_ensemble(self): # Define the list of whether to use variance as the ensemble method var_list = [False, True] # Make a list of the error values that the test compares to - error_list = [4.61352, 0.45865] + error_list = [4.61443, 0.48256] for index, use_variance_ensemble in enumerate(var_list): with self.subTest(use_variance_ensemble=use_variance_ensemble): # Construct the ensemble model @@ -101,7 +101,7 @@ def test_clustering(self): ) # Construct the Gaussian process gp = GaussianProcess( - hp=dict(length=2.0), + hp=dict(length=[2.0], noise=[-5.0], prefactor=[0.0]), use_derivatives=use_derivatives, ) # Define the list of clustering objects that are tested @@ -123,7 +123,7 @@ def test_clustering(self): RandomClustering_number(data_number=12), ] # Make a list of the error values that the test compares to - error_list = [0.45865, 0.62425, 0.61887, 0.61937, 0.70159, 0.67982] + error_list = [0.48256, 0.63066, 0.62649, 0.62650, 0.70163, 0.67975] # Test the baseline objects for index, clustering in enumerate(clustering_list): with self.subTest(clustering=clustering): @@ -180,7 +180,7 @@ def test_variance_ensemble(self): ) # Construct the Gaussian process gp = GaussianProcess( - hp=dict(length=2.0), + hp=dict(length=[2.0], noise=[-5.0], prefactor=[0.0]), use_derivatives=use_derivatives, ) # Construct the clustering object @@ -188,7 +188,7 @@ def test_variance_ensemble(self): # Define the list of whether to use variance as the ensemble method var_list = [False, True] # Make a list of the error values that the test compares to - error_list = [4.3318, 0.18866] + error_list = [4.51161, 0.37817] for index, use_variance_ensemble in enumerate(var_list): with self.subTest(use_variance_ensemble=use_variance_ensemble): # Construct the ensemble model @@ -244,7 +244,7 @@ def test_clustering(self): ) # Construct the Gaussian process gp = GaussianProcess( - hp=dict(length=2.0), + hp=dict(length=[2.0], noise=[-5.0], prefactor=[0.0]), use_derivatives=use_derivatives, ) # Define the list of clustering objects that are tested @@ -261,7 +261,7 @@ def test_clustering(self): RandomClustering_number(data_number=12), ] # Make a list of the error values that the test compares to - error_list = [0.18866, 0.19777, 0.19715, 0.19717, 0.47726, 0.18779] + error_list = [0.37817, 0.38854, 0.38641, 0.38640, 0.47864, 0.36700] # Test the baseline objects for index, clustering in enumerate(clustering_list): with self.subTest(clustering=clustering): diff --git a/tests/test_gp_fp.py b/tests/test_gp_fp.py index d08fc593..e82dac64 100644 --- a/tests/test_gp_fp.py +++ b/tests/test_gp_fp.py @@ -35,7 +35,7 @@ def test_predict_var(self): use_derivatives = False # Construct the Gaussian process gp = GaussianProcess( - hp=dict(length=2.0), + hp=dict(length=[2.0], noise=[-5.0], prefactor=[0.0]), use_derivatives=use_derivatives, kernel=SE(use_derivatives=use_derivatives, use_fingerprint=True), ) @@ -87,15 +87,15 @@ def test_predict_var(self): ] # Make a list of the error values that the test compares to error_list = [ - 21.47374, - 18.9718, - 24.42522, - 122.74292, - 24.42522, - 83.87483, - 63.01758, - 12.08484, - 61.98995, + 23.51556, + 22.50691, + 10.00542, + 56.04324, + 10.00542, + 6.712740, + 13.49250, + 20.04389, + 1.880300, ] # Test the fingerprint objects for index, fp in enumerate(fp_kwarg_list): @@ -115,7 +115,7 @@ def test_predict_var(self): # Train the machine learning model gp.train(x_tr, f_tr) # Predict the energies and uncertainties - ypred, var, var_deriv = gp.predict( + ypred, _, _ = gp.predict( x_te, get_variance=True, get_derivatives=False, @@ -159,7 +159,7 @@ def test_predict_var(self): use_derivatives = True # Construct the Gaussian process gp = GaussianProcess( - hp=dict(length=2.0), + hp=dict(length=[2.0], noise=[-5.0], prefactor=[0.0]), use_derivatives=use_derivatives, kernel=SE(use_derivatives=use_derivatives, use_fingerprint=True), ) @@ -211,15 +211,15 @@ def test_predict_var(self): ] # Make a list of the error values that the test compares to error_list = [ - 38.74501, - 39.72866, - 84.47651, - 632.09643, - 84.47651, - 65.53034, - 132.05894, - 72.97461, - 81.84043, + 37.64770, + 39.70638, + 69.16602, + 58.86160, + 69.16602, + 73.85387, + 69.11083, + 63.00867, + 70.55665, ] # Test the fingerprint objects for index, fp in enumerate(fp_kwarg_list): @@ -239,7 +239,7 @@ def test_predict_var(self): # Train the machine learning model gp.train(x_tr, f_tr) # Predict the energies and uncertainties - ypred, var, var_deriv = gp.predict( + ypred, _, _ = gp.predict( x_te, get_variance=True, get_derivatives=False, diff --git a/tests/test_gp_hpfitter.py b/tests/test_gp_hpfitter.py index 1269a486..208cf5cd 100644 --- a/tests/test_gp_hpfitter.py +++ b/tests/test_gp_hpfitter.py @@ -65,7 +65,7 @@ def test_hpfitters_noderiv(self): with self.subTest(hpfitter=hpfitter): # Construct the Gaussian process gp = GaussianProcess( - hp=dict(length=2.0), + hp=dict(length=[2.0], noise=[-5.0], prefactor=[0.0]), hpfitter=hpfitter, use_derivatives=use_derivatives, ) @@ -149,7 +149,7 @@ def test_hpfitters_deriv(self): with self.subTest(hpfitter=hpfitter): # Construct the Gaussian process gp = GaussianProcess( - hp=dict(length=2.0), + hp=dict(length=[2.0], noise=[-5.0], prefactor=[0.0]), hpfitter=hpfitter, use_derivatives=use_derivatives, ) diff --git a/tests/test_gp_means.py b/tests/test_gp_means.py index 9f519625..3f43264f 100644 --- a/tests/test_gp_means.py +++ b/tests/test_gp_means.py @@ -46,14 +46,14 @@ def test_means_noderiv(self): Prior_first, ] # Make a list of the error values that the test compares to - error_list = [2.54619, 0.88457, 0.91272, 1.19668, 0.60103, 0.90832] + error_list = [2.61859, 0.89152, 0.91990, 1.21032, 0.61772, 0.91545] # Test the prior mean objects for index, prior in enumerate(priors): with self.subTest(prior=prior): # Construct the Gaussian process gp = GaussianProcess( prior=prior(), - hp=dict(length=2.0), + hp=dict(length=[2.0], noise=[-5.0], prefactor=[0.0]), use_derivatives=use_derivatives, ) # Set random seed to give the same results every time @@ -110,14 +110,14 @@ def test_means_deriv(self): Prior_first, ] # Make a list of the error values that the test compares to - error_list = [0.78570, 0.20550, 0.21498, 0.31329, 0.12582, 0.21349] + error_list = [1.14773, 0.40411, 0.41732, 0.54772, 0.26334, 0.41526] # Test the prior mean objects for index, prior in enumerate(priors): with self.subTest(prior=prior): # Construct the Gaussian process gp = GaussianProcess( prior=prior(), - hp=dict(length=2.0), + hp=dict(length=[2.0], noise=[-5.0], prefactor=[0.0]), use_derivatives=use_derivatives, ) # Set random seed to give the same results every time diff --git a/tests/test_gp_objectivefunctions.py b/tests/test_gp_objectivefunctions.py index 9612c838..0dd45bff 100644 --- a/tests/test_gp_objectivefunctions.py +++ b/tests/test_gp_objectivefunctions.py @@ -63,7 +63,7 @@ def test_local(self): ) # Construct the Gaussian process gp = GaussianProcess( - hp=dict(length=2.0), + hp=dict(length=[2.0], noise=[-5.0], prefactor=[0.0]), hpfitter=hpfitter, use_derivatives=use_derivatives, ) @@ -134,9 +134,9 @@ def test_line_search_scale(self): # Make fixed boundary conditions for one of the tests fixed_bounds = HPBoundaries( bounds_dict=dict( - length=[[-3.0, 3.0]], - noise=[[-8.0, 0.0]], - prefactor=[[-2.0, 4.0]], + length=[[-1.0, 3.0]], + noise=[[-4.0, -1.0]], + prefactor=[[0.0, 2.0]], ), log=True, ) @@ -224,7 +224,7 @@ def test_line_search_scale(self): ) # Construct the Gaussian process gp = GaussianProcess( - hp=dict(length=2.0), + hp=dict(length=[2.0], noise=[-5.0], prefactor=[0.0]), hpfitter=hpfitter, use_derivatives=use_derivatives, ) diff --git a/tests/test_gp_optimizer.py b/tests/test_gp_optimizer.py index b7d9a010..fa6f5e03 100644 --- a/tests/test_gp_optimizer.py +++ b/tests/test_gp_optimizer.py @@ -36,7 +36,7 @@ def test_function(self): ) # Construct the Gaussian process gp = GaussianProcess( - hp=dict(length=2.0), + hp=dict(length=[2.0], noise=[-5.0], prefactor=[0.0]), hpfitter=hpfitter, use_derivatives=use_derivatives, ) @@ -52,7 +52,7 @@ def test_function(self): verbose=False, ) # Test the solution is correct - self.assertTrue(abs(sol["fun"] - 196.837) < 1e-2) + self.assertTrue(abs(sol["fun"] - 197.54480) < 1e-2) def test_local_jac(self): """ @@ -92,7 +92,7 @@ def test_local_jac(self): ) # Construct the Gaussian process gp = GaussianProcess( - hp=dict(length=2.0), + hp=dict(length=[2.0], noise=[-5.0], prefactor=[0.0]), hpfitter=hpfitter, use_derivatives=use_derivatives, ) @@ -154,7 +154,7 @@ def test_local_nojac(self): ) # Construct the Gaussian process gp = GaussianProcess( - hp=dict(length=2.0), + hp=dict(length=[2.0], noise=[-5.0], prefactor=[0.0]), hpfitter=hpfitter, use_derivatives=use_derivatives, ) @@ -213,7 +213,7 @@ def test_local_prior(self): ) # Construct the Gaussian process gp = GaussianProcess( - hp=dict(length=2.0), + hp=dict(length=[2.0], noise=[-5.0], prefactor=[0.0]), hpfitter=hpfitter, use_derivatives=use_derivatives, ) @@ -283,7 +283,7 @@ def test_local_ed_guess(self): ) # Construct the Gaussian process gp = GaussianProcess( - hp=dict(length=2.0), + hp=dict(length=[2.0], noise=[-5.0], prefactor=[0.0]), hpfitter=hpfitter, use_derivatives=use_derivatives, ) @@ -373,7 +373,7 @@ def test_random(self): ) # Construct the Gaussian process gp = GaussianProcess( - hp=dict(length=2.0), + hp=dict(length=[2.0], noise=[-5.0], prefactor=[0.0]), hpfitter=hpfitter, use_derivatives=use_derivatives, ) @@ -440,9 +440,9 @@ def test_grid(self): bounds_ed = EducatedBoundaries() fixed_bounds = HPBoundaries( bounds_dict=dict( - length=[[-3.0, 3.0]], - noise=[[-8.0, 0.0]], - prefactor=[[-2.0, 4.0]], + length=[[-1.0, 3.0]], + noise=[[-4.0, -1.0]], + prefactor=[[0.0, 2.0]], ), log=True, ) @@ -492,7 +492,7 @@ def test_grid(self): ) # Construct the Gaussian process gp = GaussianProcess( - hp=dict(length=2.0), + hp=dict(length=[2.0], noise=[-5.0], prefactor=[0.0]), hpfitter=hpfitter, use_derivatives=use_derivatives, ) @@ -559,9 +559,9 @@ def test_line(self): bounds_ed = EducatedBoundaries() fixed_bounds = HPBoundaries( bounds_dict=dict( - length=[[-3.0, 3.0]], - noise=[[-8.0, 0.0]], - prefactor=[[-2.0, 4.0]], + length=[[-1.0, 3.0]], + noise=[[-4.0, -1.0]], + prefactor=[[0.0, 2.0]], ), log=True, ) @@ -611,7 +611,7 @@ def test_line(self): ) # Construct the Gaussian process gp = GaussianProcess( - hp=dict(length=2.0), + hp=dict(length=[2.0], noise=[-5.0], prefactor=[0.0]), hpfitter=hpfitter, use_derivatives=use_derivatives, ) @@ -681,7 +681,7 @@ def test_basin(self): ) # Construct the Gaussian process gp = GaussianProcess( - hp=dict(length=2.0), + hp=dict(length=[2.0], noise=[-5.0], prefactor=[0.0]), hpfitter=hpfitter, use_derivatives=use_derivatives, ) @@ -744,9 +744,9 @@ def test_annealling(self): bounds_ed = EducatedBoundaries() fixed_bounds = HPBoundaries( bounds_dict=dict( - length=[[-3.0, 3.0]], - noise=[[-8.0, 0.0]], - prefactor=[[-2.0, 4.0]], + length=[[-1.0, 3.0]], + noise=[[-4.0, -1.0]], + prefactor=[[0.0, 2.0]], ), log=True, ) @@ -763,7 +763,7 @@ def test_annealling(self): ) # Construct the Gaussian process gp = GaussianProcess( - hp=dict(length=2.0), + hp=dict(length=[2.0], noise=[-5.0], prefactor=[0.0]), hpfitter=hpfitter, use_derivatives=use_derivatives, ) @@ -829,9 +829,9 @@ def test_annealling_trans(self): bounds_trans = VariableTransformation() fixed_bounds = HPBoundaries( bounds_dict=dict( - length=[[-3.0, 3.0]], - noise=[[-8.0, 0.0]], - prefactor=[[-2.0, 4.0]], + length=[[-1.0, 3.0]], + noise=[[-4.0, -1.0]], + prefactor=[[0.0, 2.0]], ), log=True, ) @@ -849,7 +849,7 @@ def test_annealling_trans(self): ) # Construct the Gaussian process gp = GaussianProcess( - hp=dict(length=2.0), + hp=dict(length=[2.0], noise=[-5.0], prefactor=[0.0]), hpfitter=hpfitter, use_derivatives=use_derivatives, ) @@ -918,9 +918,9 @@ def test_line_search_scale(self): bounds_ed = EducatedBoundaries() fixed_bounds = HPBoundaries( bounds_dict=dict( - length=[[-3.0, 3.0]], - noise=[[-8.0, 0.0]], - prefactor=[[-2.0, 4.0]], + length=[[-1.0, 3.0]], + noise=[[-4.0, -1.0]], + prefactor=[[0.0, 2.0]], ), log=True, ) @@ -1009,7 +1009,7 @@ def test_line_search_scale(self): ) # Construct the Gaussian process gp = GaussianProcess( - hp=dict(length=2.0), + hp=dict(length=[2.0], noise=[-5.0], prefactor=[0.0]), hpfitter=hpfitter, use_derivatives=use_derivatives, ) diff --git a/tests/test_gp_optimizer_parallel.py b/tests/test_gp_optimizer_parallel.py index f0373cee..68d7197e 100644 --- a/tests/test_gp_optimizer_parallel.py +++ b/tests/test_gp_optimizer_parallel.py @@ -53,7 +53,7 @@ def test_random(self): ) # Construct the Gaussian process gp = GaussianProcess( - hp=dict(length=2.0), + hp=dict(length=[2.0], noise=[-5.0], prefactor=[0.0]), hpfitter=hpfitter, use_derivatives=use_derivatives, ) @@ -128,7 +128,7 @@ def test_grid(self): ) # Construct the Gaussian process gp = GaussianProcess( - hp=dict(length=2.0), + hp=dict(length=[2.0], noise=[-5.0], prefactor=[0.0]), hpfitter=hpfitter, use_derivatives=use_derivatives, ) @@ -202,7 +202,7 @@ def test_line(self): ) # Construct the Gaussian process gp = GaussianProcess( - hp=dict(length=2.0), + hp=dict(length=[2.0], noise=[-5.0], prefactor=[0.0]), hpfitter=hpfitter, use_derivatives=use_derivatives, ) @@ -285,7 +285,7 @@ def test_line_search_scale(self): ) # Construct the Gaussian process gp = GaussianProcess( - hp=dict(length=2.0), + hp=dict(length=[2.0], noise=[-5.0], prefactor=[0.0]), hpfitter=hpfitter, use_derivatives=use_derivatives, ) diff --git a/tests/test_gp_pdistributions.py b/tests/test_gp_pdistributions.py index 995ba525..db2f9e5f 100644 --- a/tests/test_gp_pdistributions.py +++ b/tests/test_gp_pdistributions.py @@ -69,7 +69,7 @@ def test_local_prior(self): ) # Construct the Gaussian process gp = GaussianProcess( - hp=dict(length=2.0), + hp=dict(length=[2.0], noise=[-5.0], prefactor=[0.0]), hpfitter=hpfitter, use_derivatives=use_derivatives, ) @@ -173,7 +173,7 @@ def test_global_prior(self): ) # Construct the Gaussian process gp = GaussianProcess( - hp=dict(length=2.0), + hp=dict(length=[2.0], noise=[-5.0], prefactor=[0.0]), hpfitter=hpfitter, use_derivatives=use_derivatives, ) diff --git a/tests/test_gp_train.py b/tests/test_gp_train.py index 0c47e363..1f85f7d2 100644 --- a/tests/test_gp_train.py +++ b/tests/test_gp_train.py @@ -16,7 +16,7 @@ def test_gp(self): use_derivatives = False # Construct the Gaussian process GaussianProcess( - hp=dict(length=2.0), + hp=dict(length=[2.0], noise=[-5.0], prefactor=[0.0]), use_derivatives=use_derivatives, ) @@ -40,7 +40,7 @@ def test_train(self): ) # Construct the Gaussian process gp = GaussianProcess( - hp=dict(length=2.0), + hp=dict(length=[2.0], noise=[-5.0], prefactor=[0.0]), use_derivatives=use_derivatives, ) # Train the machine learning model @@ -66,7 +66,7 @@ def test_predict1(self): ) # Construct the Gaussian process gp = GaussianProcess( - hp=dict(length=2.0), + hp=dict(length=[2.0], noise=[-5.0], prefactor=[0.0]), use_derivatives=use_derivatives, ) # Train the machine learning model @@ -80,7 +80,7 @@ def test_predict1(self): ) # Test the prediction energy errors error = calculate_rmse(f_te[:, 0], ypred[:, 0]) - self.assertTrue(abs(error - 0.00859) < 1e-4) + self.assertTrue(abs(error - 0.00069) < 1e-4) def test_predict(self): "Test if the GP can predict multiple test points." @@ -102,7 +102,7 @@ def test_predict(self): ) # Construct the Gaussian process gp = GaussianProcess( - hp=dict(length=2.0), + hp=dict(length=[2.0], noise=[-5.0], prefactor=[0.0]), use_derivatives=use_derivatives, ) # Train the machine learning model @@ -116,7 +116,7 @@ def test_predict(self): ) # Test the prediction energy errors error = calculate_rmse(f_te[:, 0], ypred[:, 0]) - self.assertTrue(abs(error - 0.88457) < 1e-4) + self.assertTrue(abs(error - 0.89152) < 1e-4) def test_predict_var(self): "Test if the GP can predict variance of multiple test point." @@ -138,7 +138,7 @@ def test_predict_var(self): ) # Construct the Gaussian process gp = GaussianProcess( - hp=dict(length=2.0), + hp=dict(length=[2.0], noise=[-5.0], prefactor=[0.0]), use_derivatives=use_derivatives, ) # Train the machine learning model @@ -152,7 +152,7 @@ def test_predict_var(self): ) # Test the prediction energy errors error = calculate_rmse(f_te[:, 0], ypred[:, 0]) - self.assertTrue(abs(error - 0.88457) < 1e-4) + self.assertTrue(abs(error - 0.89152) < 1e-4) def test_predict_var_n(self): """ @@ -177,7 +177,7 @@ def test_predict_var_n(self): ) # Construct the Gaussian process gp = GaussianProcess( - hp=dict(length=2.0), + hp=dict(length=[2.0], noise=[-5.0], prefactor=[0.0]), use_derivatives=use_derivatives, ) # Train the machine learning model @@ -191,7 +191,7 @@ def test_predict_var_n(self): ) # Test the prediction energy errors error = calculate_rmse(f_te[:, 0], ypred[:, 0]) - self.assertTrue(abs(error - 0.88457) < 1e-4) + self.assertTrue(abs(error - 0.89152) < 1e-4) def test_predict_derivatives(self): "Test if the GP can predict derivatives of multiple test points." @@ -213,7 +213,7 @@ def test_predict_derivatives(self): ) # Construct the Gaussian process gp = GaussianProcess( - hp=dict(length=2.0), + hp=dict(length=[2.0], noise=[-5.0], prefactor=[0.0]), use_derivatives=use_derivatives, ) # Train the machine learning model @@ -229,7 +229,7 @@ def test_predict_derivatives(self): self.assertTrue(ypred.shape[1] == 2) # Test the prediction energy errors error = calculate_rmse(f_te[:, 0], ypred[:, 0]) - self.assertTrue(abs(error - 0.88457) < 1e-4) + self.assertTrue(abs(error - 0.89152) < 1e-4) class TestGPTrainPredictDerivatives(unittest.TestCase): @@ -258,7 +258,7 @@ def test_train(self): ) # Construct the Gaussian process gp = GaussianProcess( - hp=dict(length=2.0), + hp=dict(length=[2.0], noise=[-5.0], prefactor=[0.0]), use_derivatives=use_derivatives, ) # Train the machine learning model @@ -284,7 +284,7 @@ def test_predict1(self): ) # Construct the Gaussian process gp = GaussianProcess( - hp=dict(length=2.0), + hp=dict(length=[2.0], noise=[-5.0], prefactor=[0.0]), use_derivatives=use_derivatives, ) # Train the machine learning model @@ -298,7 +298,7 @@ def test_predict1(self): ) # Test the prediction energy errors error = calculate_rmse(f_te[:, 0], ypred[:, 0]) - self.assertTrue(abs(error - 0.00038) < 1e-4) + self.assertTrue(abs(error - 0.00233) < 1e-4) def test_predict(self): "Test if the GP can predict multiple test points." @@ -320,7 +320,7 @@ def test_predict(self): ) # Construct the Gaussian process gp = GaussianProcess( - hp=dict(length=2.0), + hp=dict(length=[2.0], noise=[-5.0], prefactor=[0.0]), use_derivatives=use_derivatives, ) # Train the machine learning model @@ -334,7 +334,7 @@ def test_predict(self): ) # Test the prediction energy errors error = calculate_rmse(f_te[:, 0], ypred[:, 0]) - self.assertTrue(abs(error - 0.2055) < 1e-4) + self.assertTrue(abs(error - 0.40411) < 1e-4) def test_predict_var(self): "Test if the GP can predict variance of multiple test points." @@ -356,7 +356,7 @@ def test_predict_var(self): ) # Construct the Gaussian process gp = GaussianProcess( - hp=dict(length=2.0), + hp=dict(length=[2.0], noise=[-5.0], prefactor=[0.0]), use_derivatives=use_derivatives, ) # Train the machine learning model @@ -370,7 +370,7 @@ def test_predict_var(self): ) # Test the prediction energy errors error = calculate_rmse(f_te[:, 0], ypred[:, 0]) - self.assertTrue(abs(error - 0.20550) < 1e-4) + self.assertTrue(abs(error - 0.40411) < 1e-4) def test_predict_var_n(self): """ @@ -395,7 +395,7 @@ def test_predict_var_n(self): ) # Construct the Gaussian process gp = GaussianProcess( - hp=dict(length=2.0), + hp=dict(length=[2.0], noise=[-5.0], prefactor=[0.0]), use_derivatives=use_derivatives, ) # Train the machine learning model @@ -409,7 +409,7 @@ def test_predict_var_n(self): ) # Test the prediction energy errors error = calculate_rmse(f_te[:, 0], ypred[:, 0]) - self.assertTrue(abs(error - 0.20550) < 1e-4) + self.assertTrue(abs(error - 0.40411) < 1e-4) def test_predict_derivatives(self): "Test if the GP can predict derivatives of multiple test points." @@ -431,7 +431,7 @@ def test_predict_derivatives(self): ) # Construct the Gaussian process gp = GaussianProcess( - hp=dict(length=2.0), + hp=dict(length=[2.0], noise=[-5.0], prefactor=[0.0]), use_derivatives=use_derivatives, ) # Train the machine learning model @@ -447,7 +447,7 @@ def test_predict_derivatives(self): self.assertTrue(ypred.shape[1] == 2) # Test the prediction energy errors error = calculate_rmse(f_te[:, 0], ypred[:, 0]) - self.assertTrue(abs(error - 0.20550) < 1e-4) + self.assertTrue(abs(error - 0.40411) < 1e-4) if __name__ == "__main__": diff --git a/tests/test_save_model.py b/tests/test_save_model.py index 9c55472b..8bb855c4 100644 --- a/tests/test_save_model.py +++ b/tests/test_save_model.py @@ -30,7 +30,7 @@ def test_save_model(self): ) # Construct the Gaussian process gp = GaussianProcess( - hp=dict(length=2.0), + hp=dict(length=[2.0], noise=[-5.0], prefactor=[0.0]), use_derivatives=use_derivatives, ) # Train the machine learning model @@ -39,7 +39,7 @@ def test_save_model(self): gp.save_model("test_model.pkl") # Load the model gp2 = GaussianProcess( - hp=dict(length=2.0), + hp=dict(length=[2.0], noise=[-5.0], prefactor=[0.0]), use_derivatives=use_derivatives, ) gp2 = gp2.load_model("test_model.pkl") @@ -52,7 +52,7 @@ def test_save_model(self): ) # Test the prediction energy errors error = calculate_rmse(f_te[:, 0], ypred[:, 0]) - self.assertTrue(abs(error - 0.00859) < 1e-4) + self.assertTrue(abs(error - 0.00069) < 1e-4) if __name__ == "__main__": diff --git a/tests/test_tp_objectivefunctions.py b/tests/test_tp_objectivefunctions.py index 95dfdd8b..e234b07f 100644 --- a/tests/test_tp_objectivefunctions.py +++ b/tests/test_tp_objectivefunctions.py @@ -44,7 +44,7 @@ def test_local(self): ) # Construct the Student t process tp = TProcess( - hp=dict(length=2.0), + hp=dict(length=[2.0], noise=[-5.0]), hpfitter=hpfitter, use_derivatives=use_derivatives, ) @@ -114,9 +114,8 @@ def test_line_search_scale(self): # Make fixed boundary conditions for one of the tests fixed_bounds = HPBoundaries( bounds_dict=dict( - length=[[-3.0, 3.0]], - noise=[[-8.0, 0.0]], - prefactor=[[-2.0, 4.0]], + length=[[-1.0, 3.0]], + noise=[[-4.0, -1.0]], ), log=True, ) @@ -196,7 +195,7 @@ def test_line_search_scale(self): ) # Construct the Student t process tp = TProcess( - hp=dict(length=2.0), + hp=dict(length=[2.0], noise=[-5.0]), hpfitter=hpfitter, use_derivatives=use_derivatives, ) diff --git a/tests/test_tp_optimizer.py b/tests/test_tp_optimizer.py index 67693b4c..1a8b163a 100644 --- a/tests/test_tp_optimizer.py +++ b/tests/test_tp_optimizer.py @@ -36,7 +36,7 @@ def test_function(self): ) # Construct the Student t process tp = TProcess( - hp=dict(length=2.0), + hp=dict(length=[2.0], noise=[-5.0]), hpfitter=hpfitter, use_derivatives=use_derivatives, ) @@ -52,7 +52,7 @@ def test_function(self): verbose=False, ) # Test the solution is correct - self.assertTrue(abs(sol["fun"] - 487.618) < 1e-2) + self.assertTrue(abs(sol["fun"] - 489.88476) < 1e-2) def test_local_jac(self): """ @@ -90,7 +90,7 @@ def test_local_jac(self): ) # Construct the Student t process tp = TProcess( - hp=dict(length=2.0), + hp=dict(length=[2.0], noise=[-5.0]), hpfitter=hpfitter, use_derivatives=use_derivatives, ) @@ -152,7 +152,7 @@ def test_local_nojac(self): ) # Construct the Student t process tp = TProcess( - hp=dict(length=2.0), + hp=dict(length=[2.0], noise=[-5.0]), hpfitter=hpfitter, use_derivatives=use_derivatives, ) @@ -217,7 +217,7 @@ def test_local_prior(self): ) # Construct the Student t process tp = TProcess( - hp=dict(length=2.0), + hp=dict(length=[2.0], noise=[-5.0]), hpfitter=hpfitter, use_derivatives=use_derivatives, ) @@ -286,7 +286,7 @@ def test_local_ed_guess(self): ) # Construct the Student t process tp = TProcess( - hp=dict(length=2.0), + hp=dict(length=[2.0], noise=[-5.0]), hpfitter=hpfitter, use_derivatives=use_derivatives, ) @@ -376,7 +376,7 @@ def test_random(self): ) # Construct the Student t process tp = TProcess( - hp=dict(length=2.0), + hp=dict(length=[2.0], noise=[-5.0]), hpfitter=hpfitter, use_derivatives=use_derivatives, ) @@ -443,9 +443,8 @@ def test_grid(self): bounds_ed = EducatedBoundaries() fixed_bounds = HPBoundaries( bounds_dict=dict( - length=[[-3.0, 3.0]], - noise=[[-8.0, 0.0]], - prefactor=[[-2.0, 4.0]], + length=[[-1.0, 3.0]], + noise=[[-4.0, -1.0]], ), log=True, ) @@ -495,7 +494,7 @@ def test_grid(self): ) # Construct the Student t process tp = TProcess( - hp=dict(length=2.0), + hp=dict(length=[2.0], noise=[-5.0]), hpfitter=hpfitter, use_derivatives=use_derivatives, ) @@ -562,9 +561,8 @@ def test_line(self): bounds_ed = EducatedBoundaries() fixed_bounds = HPBoundaries( bounds_dict=dict( - length=[[-3.0, 3.0]], - noise=[[-8.0, 0.0]], - prefactor=[[-2.0, 4.0]], + length=[[-1.0, 3.0]], + noise=[[-4.0, -1.0]], ), log=True, ) @@ -614,7 +612,7 @@ def test_line(self): ) # Construct the Student t process tp = TProcess( - hp=dict(length=2.0), + hp=dict(length=[2.0], noise=[-5.0]), hpfitter=hpfitter, use_derivatives=use_derivatives, ) @@ -684,7 +682,7 @@ def test_basin(self): ) # Construct the Student t process tp = TProcess( - hp=dict(length=2.0), + hp=dict(length=[2.0], noise=[-5.0]), hpfitter=hpfitter, use_derivatives=use_derivatives, ) @@ -742,7 +740,6 @@ def test_annealling(self): restart_temp_ratio=2e-05, visit=2.62, accept=-5.0, - seed=None, no_local_search=False, ) # Make the optimizer @@ -756,9 +753,8 @@ def test_annealling(self): bounds_ed = EducatedBoundaries() fixed_bounds = HPBoundaries( bounds_dict=dict( - length=[[-3.0, 3.0]], - noise=[[-8.0, 0.0]], - prefactor=[[-2.0, 4.0]], + length=[[-1.0, 3.0]], + noise=[[-4.0, -1.0]], ), log=True, ) @@ -775,7 +771,7 @@ def test_annealling(self): ) # Construct the Student t process tp = TProcess( - hp=dict(length=2.0), + hp=dict(length=[2.0], noise=[-5.0]), hpfitter=hpfitter, use_derivatives=use_derivatives, ) @@ -836,7 +832,6 @@ def test_annealling_trans(self): restart_temp_ratio=2e-05, visit=2.62, accept=-5.0, - seed=None, no_local_search=False, ) # Make the optimizer @@ -850,9 +845,8 @@ def test_annealling_trans(self): bounds_trans = VariableTransformation() fixed_bounds = HPBoundaries( bounds_dict=dict( - length=[[-3.0, 3.0]], - noise=[[-8.0, 0.0]], - prefactor=[[-2.0, 4.0]], + length=[[-1.0, 3.0]], + noise=[[-4.0, -1.0]], ), log=True, ) @@ -870,7 +864,7 @@ def test_annealling_trans(self): ) # Construct the Student t process tp = TProcess( - hp=dict(length=2.0), + hp=dict(length=[2.0], noise=[-5.0]), hpfitter=hpfitter, use_derivatives=use_derivatives, ) @@ -939,9 +933,8 @@ def test_line_search_scale(self): bounds_ed = EducatedBoundaries() fixed_bounds = HPBoundaries( bounds_dict=dict( - length=[[-3.0, 3.0]], - noise=[[-8.0, 0.0]], - prefactor=[[-2.0, 4.0]], + length=[[-1.0, 3.0]], + noise=[[-4.0, -1.0]], ), log=True, ) @@ -1030,7 +1023,7 @@ def test_line_search_scale(self): ) # Construct the Student t process tp = TProcess( - hp=dict(length=2.0), + hp=dict(length=[2.0], noise=[-5.0]), hpfitter=hpfitter, use_derivatives=use_derivatives, ) diff --git a/tests/test_tp_optimizer_parallel.py b/tests/test_tp_optimizer_parallel.py index ee7e1cf1..b0df50bd 100644 --- a/tests/test_tp_optimizer_parallel.py +++ b/tests/test_tp_optimizer_parallel.py @@ -54,7 +54,7 @@ def test_random(self): ) # Construct the Student t process tp = TProcess( - hp=dict(length=2.0), + hp=dict(length=[2.0], noise=[-5.0]), hpfitter=hpfitter, use_derivatives=use_derivatives, ) @@ -130,7 +130,7 @@ def test_grid(self): ) # Construct the Student t process tp = TProcess( - hp=dict(length=2.0), + hp=dict(length=[2.0], noise=[-5.0]), hpfitter=hpfitter, use_derivatives=use_derivatives, ) @@ -205,7 +205,7 @@ def test_line(self): ) # Construct the Student t process tp = TProcess( - hp=dict(length=2.0), + hp=dict(length=[2.0], noise=[-5.0]), hpfitter=hpfitter, use_derivatives=use_derivatives, ) @@ -288,7 +288,7 @@ def test_line_search_scale(self): ) # Construct the Student t process tp = TProcess( - hp=dict(length=2.0), + hp=dict(length=[2.0], noise=[-5.0]), hpfitter=hpfitter, use_derivatives=use_derivatives, ) diff --git a/tests/test_tp_train.py b/tests/test_tp_train.py index 45e91cee..93916bb0 100644 --- a/tests/test_tp_train.py +++ b/tests/test_tp_train.py @@ -15,7 +15,10 @@ def test_tp(self): # Whether to learn from the derivatives use_derivatives = False # Construct the Studen t process - TProcess(hp=dict(length=2.0), use_derivatives=use_derivatives) + TProcess( + hp=dict(length=[2.0], noise=[-5.0]), + use_derivatives=use_derivatives, + ) def test_train(self): "Test if the TP can be trained." @@ -36,7 +39,10 @@ def test_train(self): use_derivatives=use_derivatives, ) # Construct the Studen t process - tp = TProcess(hp=dict(length=2.0), use_derivatives=use_derivatives) + tp = TProcess( + hp=dict(length=[2.0], noise=[-5.0]), + use_derivatives=use_derivatives, + ) # Train the machine learning model tp.train(x_tr, f_tr) @@ -59,7 +65,10 @@ def test_predict1(self): use_derivatives=use_derivatives, ) # Construct the Studen t process - tp = TProcess(hp=dict(length=2.0), use_derivatives=use_derivatives) + tp = TProcess( + hp=dict(length=[2.0], noise=[-5.0]), + use_derivatives=use_derivatives, + ) # Train the machine learning model tp.train(x_tr, f_tr) # Predict the energy @@ -71,7 +80,7 @@ def test_predict1(self): ) # Test the prediction energy errors error = calculate_rmse(f_te[:, 0], ypred[:, 0]) - self.assertTrue(abs(error - 0.00859) < 1e-4) + self.assertTrue(abs(error - 0.00069) < 1e-4) def test_predict(self): "Test if the TP can predict multiple test points." @@ -92,7 +101,10 @@ def test_predict(self): use_derivatives=use_derivatives, ) # Construct the Studen t process - tp = TProcess(hp=dict(length=2.0), use_derivatives=use_derivatives) + tp = TProcess( + hp=dict(length=[2.0], noise=[-5.0]), + use_derivatives=use_derivatives, + ) # Train the machine learning model tp.train(x_tr, f_tr) # Predict the energies @@ -104,7 +116,7 @@ def test_predict(self): ) # Test the prediction energy errors error = calculate_rmse(f_te[:, 0], ypred[:, 0]) - self.assertTrue(abs(error - 0.88457) < 1e-4) + self.assertTrue(abs(error - 0.89152) < 1e-4) def test_predict_var(self): "Test if the TP can predict variance of multiple test point." @@ -125,7 +137,10 @@ def test_predict_var(self): use_derivatives=use_derivatives, ) # Construct the Studen t process - tp = TProcess(hp=dict(length=2.0), use_derivatives=use_derivatives) + tp = TProcess( + hp=dict(length=[2.0], noise=[-5.0]), + use_derivatives=use_derivatives, + ) # Train the machine learning model tp.train(x_tr, f_tr) # Predict the energies and uncertainties @@ -137,7 +152,7 @@ def test_predict_var(self): ) # Test the prediction energy errors error = calculate_rmse(f_te[:, 0], ypred[:, 0]) - self.assertTrue(abs(error - 0.88457) < 1e-4) + self.assertTrue(abs(error - 0.89152) < 1e-4) def test_predict_var_n(self): """ @@ -161,7 +176,10 @@ def test_predict_var_n(self): use_derivatives=use_derivatives, ) # Construct the Studen t process - tp = TProcess(hp=dict(length=2.0), use_derivatives=use_derivatives) + tp = TProcess( + hp=dict(length=[2.0], noise=[-5.0]), + use_derivatives=use_derivatives, + ) # Train the machine learning model tp.train(x_tr, f_tr) # Predict the energies and uncertainties @@ -173,7 +191,7 @@ def test_predict_var_n(self): ) # Test the prediction energy errors error = calculate_rmse(f_te[:, 0], ypred[:, 0]) - self.assertTrue(abs(error - 0.88457) < 1e-4) + self.assertTrue(abs(error - 0.89152) < 1e-4) def test_predict_derivatives(self): "Test if the TP can predict derivatives of multiple test points." @@ -194,7 +212,10 @@ def test_predict_derivatives(self): use_derivatives=use_derivatives, ) # Construct the Studen t process - tp = TProcess(hp=dict(length=2.0), use_derivatives=use_derivatives) + tp = TProcess( + hp=dict(length=[2.0], noise=[-5.0]), + use_derivatives=use_derivatives, + ) # Train the machine learning model tp.train(x_tr, f_tr) # Predict the energies, derivatives, and uncertainties @@ -208,7 +229,7 @@ def test_predict_derivatives(self): self.assertTrue(ypred.shape[1] == 2) # Test the prediction energy errors error = calculate_rmse(f_te[:, 0], ypred[:, 0]) - self.assertTrue(abs(error - 0.88457) < 1e-4) + self.assertTrue(abs(error - 0.89152) < 1e-4) class TestTPTrainPredictDerivatives(unittest.TestCase): @@ -236,7 +257,10 @@ def test_train(self): use_derivatives=use_derivatives, ) # Construct the Studen t process - tp = TProcess(hp=dict(length=2.0), use_derivatives=use_derivatives) + tp = TProcess( + hp=dict(length=[2.0], noise=[-5.0]), + use_derivatives=use_derivatives, + ) # Train the machine learning model tp.train(x_tr, f_tr) @@ -259,7 +283,10 @@ def test_predict1(self): use_derivatives=use_derivatives, ) # Construct the Studen t process - tp = TProcess(hp=dict(length=2.0), use_derivatives=use_derivatives) + tp = TProcess( + hp=dict(length=[2.0], noise=[-5.0]), + use_derivatives=use_derivatives, + ) # Train the machine learning model tp.train(x_tr, f_tr) # Predict the energy @@ -271,7 +298,7 @@ def test_predict1(self): ) # Test the prediction energy errors error = calculate_rmse(f_te[:, 0], ypred[:, 0]) - self.assertTrue(abs(error - 0.00038) < 1e-4) + self.assertTrue(abs(error - 0.00233) < 1e-4) def test_predict(self): "Test if the TP can predict multiple test points." @@ -292,7 +319,10 @@ def test_predict(self): use_derivatives=use_derivatives, ) # Construct the Studen t process - tp = TProcess(hp=dict(length=2.0), use_derivatives=use_derivatives) + tp = TProcess( + hp=dict(length=[2.0], noise=[-5.0]), + use_derivatives=use_derivatives, + ) # Train the machine learning model tp.train(x_tr, f_tr) # Predict the energies @@ -304,7 +334,7 @@ def test_predict(self): ) # Test the prediction energy errors error = calculate_rmse(f_te[:, 0], ypred[:, 0]) - self.assertTrue(abs(error - 0.20550) < 1e-4) + self.assertTrue(abs(error - 0.40411) < 1e-4) def test_predict_var(self): "Test if the TP can predict variance of multiple test points." @@ -325,7 +355,10 @@ def test_predict_var(self): use_derivatives=use_derivatives, ) # Construct the Studen t process - tp = TProcess(hp=dict(length=2.0), use_derivatives=use_derivatives) + tp = TProcess( + hp=dict(length=[2.0], noise=[-5.0]), + use_derivatives=use_derivatives, + ) # Train the machine learning model tp.train(x_tr, f_tr) # Predict the energies and uncertainties @@ -337,7 +370,7 @@ def test_predict_var(self): ) # Test the prediction energy errors error = calculate_rmse(f_te[:, 0], ypred[:, 0]) - self.assertTrue(abs(error - 0.20550) < 1e-4) + self.assertTrue(abs(error - 0.40411) < 1e-4) def test_predict_var_n(self): """ @@ -361,7 +394,10 @@ def test_predict_var_n(self): use_derivatives=use_derivatives, ) # Construct the Studen t process - tp = TProcess(hp=dict(length=2.0), use_derivatives=use_derivatives) + tp = TProcess( + hp=dict(length=[2.0], noise=[-5.0]), + use_derivatives=use_derivatives, + ) # Train the machine learning model tp.train(x_tr, f_tr) # Predict the energies and uncertainties @@ -373,7 +409,7 @@ def test_predict_var_n(self): ) # Test the prediction energy errors error = calculate_rmse(f_te[:, 0], ypred[:, 0]) - self.assertTrue(abs(error - 0.20550) < 1e-4) + self.assertTrue(abs(error - 0.40411) < 1e-4) def test_predict_derivatives(self): "Test if the TP can predict derivatives of multiple test points." @@ -394,7 +430,10 @@ def test_predict_derivatives(self): use_derivatives=use_derivatives, ) # Construct the Studen t process - tp = TProcess(hp=dict(length=2.0), use_derivatives=use_derivatives) + tp = TProcess( + hp=dict(length=[2.0], noise=[-5.0]), + use_derivatives=use_derivatives, + ) # Train the machine learning model tp.train(x_tr, f_tr) # Predict the energies, derivatives, and uncertainties @@ -408,7 +447,7 @@ def test_predict_derivatives(self): self.assertTrue(ypred.shape[1] == 2) # Test the prediction energy errors error = calculate_rmse(f_te[:, 0], ypred[:, 0]) - self.assertTrue(abs(error - 0.20550) < 1e-4) + self.assertTrue(abs(error - 0.40411) < 1e-4) if __name__ == "__main__": From ac5c0b7b6aa6721c6bd2b539e02a4f6368e4bbe7 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Tue, 20 May 2025 12:41:35 +0200 Subject: [PATCH 116/194] Better docstrings --- catlearn/regression/gp/baseline/baseline.py | 8 +- .../regression/gp/baseline/bornrepulsive.py | 12 +- catlearn/regression/gp/baseline/idpp.py | 13 +- catlearn/regression/gp/baseline/mie.py | 10 +- catlearn/regression/gp/baseline/repulsive.py | 12 +- catlearn/regression/gp/calculator/bocalc.py | 10 +- .../regression/gp/calculator/copy_atoms.py | 13 +- catlearn/regression/gp/calculator/database.py | 8 +- .../gp/calculator/database_reduction.py | 413 ++++-------------- .../regression/gp/calculator/hiermodel.py | 15 +- catlearn/regression/gp/calculator/mlcalc.py | 6 +- catlearn/regression/gp/calculator/mlmodel.py | 7 +- .../gp/ensemble/clustering/clustering.py | 6 +- .../gp/ensemble/clustering/fixed.py | 8 +- .../gp/ensemble/clustering/k_means.py | 8 +- .../gp/ensemble/clustering/k_means_auto.py | 10 +- .../gp/ensemble/clustering/k_means_number.py | 10 +- .../gp/ensemble/clustering/random.py | 8 +- .../gp/ensemble/clustering/random_number.py | 9 +- catlearn/regression/gp/ensemble/ensemble.py | 8 +- .../gp/ensemble/ensemble_clustering.py | 10 +- .../regression/gp/fingerprint/cartesian.py | 34 +- .../regression/gp/fingerprint/distances.py | 12 +- .../regression/gp/fingerprint/fingerprint.py | 8 +- .../gp/fingerprint/fingerprintobject.py | 10 +- .../regression/gp/fingerprint/fpwrapper.py | 26 +- .../regression/gp/fingerprint/invdistances.py | 12 +- .../gp/fingerprint/invdistances2.py | 98 +---- .../gp/fingerprint/meandistances.py | 117 +---- .../gp/fingerprint/meandistancespower.py | 124 +----- .../gp/fingerprint/sorteddistances.py | 12 +- .../regression/gp/fingerprint/sumdistances.py | 12 +- .../gp/fingerprint/sumdistancespower.py | 14 +- catlearn/regression/gp/hpboundary/boundary.py | 14 +- catlearn/regression/gp/hpboundary/educated.py | 12 +- catlearn/regression/gp/hpboundary/hptrans.py | 16 +- catlearn/regression/gp/hpboundary/length.py | 12 +- .../regression/gp/hpboundary/restricted.py | 58 +-- catlearn/regression/gp/hpboundary/strict.py | 68 +-- .../regression/gp/hpboundary/updatebounds.py | 16 +- catlearn/regression/gp/hpfitter/fbpmgp.py | 10 +- catlearn/regression/gp/hpfitter/hpfitter.py | 8 +- .../regression/gp/hpfitter/redhpfitter.py | 12 +- catlearn/regression/gp/kernel/kernel.py | 7 +- catlearn/regression/gp/kernel/se.py | 36 +- catlearn/regression/gp/means/constant.py | 14 +- catlearn/regression/gp/means/first.py | 22 +- catlearn/regression/gp/means/max.py | 22 +- catlearn/regression/gp/means/mean.py | 22 +- catlearn/regression/gp/means/median.py | 22 +- catlearn/regression/gp/means/min.py | 24 +- catlearn/regression/gp/means/prior.py | 8 +- catlearn/regression/gp/models/gp.py | 12 +- catlearn/regression/gp/models/model.py | 12 +- catlearn/regression/gp/models/tp.py | 12 +- .../regression/gp/objectivefunctions/batch.py | 18 +- .../gp/objectivefunctions/best_batch.py | 59 +-- .../objectivefunctions/gp/factorized_gpp.py | 63 +-- .../gp/factorized_likelihood.py | 18 +- .../gp/factorized_likelihood_svd.py | 63 +-- .../gp/objectivefunctions/gp/gpe.py | 8 +- .../gp/objectivefunctions/gp/gpp.py | 12 +- .../gp/objectivefunctions/gp/likelihood.py | 18 +- .../gp/objectivefunctions/gp/loo.py | 8 +- .../gp/objectivefunctions/gp/mle.py | 12 +- .../objectivefunctions/objectivefunction.py | 6 +- .../tp/factorized_likelihood.py | 14 +- .../tp/factorized_likelihood_svd.py | 53 +-- .../gp/objectivefunctions/tp/likelihood.py | 18 +- .../gp/optimizers/globaloptimizer.py | 160 ++++--- .../regression/gp/optimizers/linesearcher.py | 142 ++---- .../gp/optimizers/localoptimizer.py | 178 ++------ .../regression/gp/optimizers/noisesearcher.py | 120 ++--- .../regression/gp/optimizers/optimizer.py | 16 +- .../regression/gp/pdistributions/gamma.py | 20 +- .../gp/pdistributions/gen_normal.py | 16 +- .../regression/gp/pdistributions/invgamma.py | 20 +- .../regression/gp/pdistributions/normal.py | 16 +- .../gp/pdistributions/pdistributions.py | 16 +- .../regression/gp/pdistributions/uniform.py | 16 +- 80 files changed, 896 insertions(+), 1716 deletions(-) diff --git a/catlearn/regression/gp/baseline/baseline.py b/catlearn/regression/gp/baseline/baseline.py index d149ff05..facce1a3 100644 --- a/catlearn/regression/gp/baseline/baseline.py +++ b/catlearn/regression/gp/baseline/baseline.py @@ -3,6 +3,11 @@ class BaselineCalculator(Calculator): + """ + A baseline calculator for ASE Atoms instance. + It uses a flat baseline with zero energy and forces. + """ + implemented_properties = ["energy", "forces"] nolabel = True @@ -14,8 +19,7 @@ def __init__( **kwargs, ): """ - A baseline calculator for ASE atoms object. - It uses a flat baseline with zero energy and forces. + Initialize the baseline calculator. Parameters: reduce_dimensions: bool diff --git a/catlearn/regression/gp/baseline/bornrepulsive.py b/catlearn/regression/gp/baseline/bornrepulsive.py index c3ff084d..45635166 100644 --- a/catlearn/regression/gp/baseline/bornrepulsive.py +++ b/catlearn/regression/gp/baseline/bornrepulsive.py @@ -3,6 +3,13 @@ class BornRepulsionCalculator(RepulsionCalculator): + """ + A baseline calculator for ASE Atoms instance. + It uses the Born repulsion potential. + A cutoff distance is used to remove the repulsion + at larger distances. + """ + implemented_properties = ["energy", "forces"] nolabel = True @@ -23,10 +30,7 @@ def __init__( **kwargs, ): """ - A baseline calculator for ASE atoms object. - It uses a repulsive Lennard-Jones potential baseline. - The power and the scaling of the repulsive Lennard-Jones potential - can be selected. + Initialize the baseline calculator. Parameters: reduce_dimensions: bool diff --git a/catlearn/regression/gp/baseline/idpp.py b/catlearn/regression/gp/baseline/idpp.py index ff389e10..258f8d32 100644 --- a/catlearn/regression/gp/baseline/idpp.py +++ b/catlearn/regression/gp/baseline/idpp.py @@ -3,6 +3,11 @@ class IDPP(BaselineCalculator): + """ + A baseline calculator for ASE Atoms instance. + It uses image dependent pair potential. + (https://doi.org/10.1063/1.4878664) + """ def __init__( self, @@ -14,8 +19,7 @@ def __init__( **kwargs, ): """ - A baseline calculator for ASE atoms object. - It uses image dependent pair potential. + Initialize the baseline calculator. Parameters: target: array @@ -30,11 +34,6 @@ def __init__( dtype: type (optional) The data type of the arrays. If None, the default data type is used. - - See: - Improved initial guess for minimum energy path calculations. - Søren Smidstrup, Andreas Pedersen, Kurt Stokbro and Hannes Jónsson - Chem. Phys. 140, 214106 (2014) """ super().__init__( reduce_dimensions=False, diff --git a/catlearn/regression/gp/baseline/mie.py b/catlearn/regression/gp/baseline/mie.py index 1201d8e6..fb5b4df9 100644 --- a/catlearn/regression/gp/baseline/mie.py +++ b/catlearn/regression/gp/baseline/mie.py @@ -3,6 +3,12 @@ class MieCalculator(RepulsionCalculator): + """ + A baseline calculator for ASE Atoms instance. + It uses the Mie potential baseline. + The power and the scaling of the Mie potential can be selected. + """ + implemented_properties = ["energy", "forces"] nolabel = True @@ -26,9 +32,7 @@ def __init__( **kwargs, ): """ - A baseline calculator for ASE atoms object. - It uses the Mie potential baseline. - The power and the scaling of the Mie potential can be selected. + Initialize the baseline calculator. Parameters: reduce_dimensions: bool diff --git a/catlearn/regression/gp/baseline/repulsive.py b/catlearn/regression/gp/baseline/repulsive.py index 94dc669a..e224a5ea 100644 --- a/catlearn/regression/gp/baseline/repulsive.py +++ b/catlearn/regression/gp/baseline/repulsive.py @@ -9,6 +9,13 @@ class RepulsionCalculator(BaselineCalculator): + """ + A baseline calculator for ASE Atoms instance. + It uses a repulsive Lennard-Jones potential baseline. + The power and the scaling of the repulsive Lennard-Jones potential + can be selected. + """ + implemented_properties = ["energy", "forces"] nolabel = True @@ -30,10 +37,7 @@ def __init__( **kwargs, ): """ - A baseline calculator for ASE atoms object. - It uses a repulsive Lennard-Jones potential baseline. - The power and the scaling of the repulsive Lennard-Jones potential - can be selected. + Initialize the baseline calculator. Parameters: reduce_dimensions: bool diff --git a/catlearn/regression/gp/calculator/bocalc.py b/catlearn/regression/gp/calculator/bocalc.py index 945d9dd4..0986f464 100644 --- a/catlearn/regression/gp/calculator/bocalc.py +++ b/catlearn/regression/gp/calculator/bocalc.py @@ -3,6 +3,13 @@ class BOCalculator(MLCalculator): + """ + The machine learning calculator object applicable as an ASE calculator for + ASE Atoms instance. + This uses an acquisition function as the energy and forces. + E = E_pred + kappa * sigma + Therefore, it is Bayesian optimization calculator object. + """ # Define the properties available in this calculator implemented_properties = [ @@ -29,8 +36,7 @@ def __init__( **kwargs, ): """ - Bayesian optimization calculator object - applicable as an ASE calculator. + Initialize the ML calculator. Parameters: mlmodel: MLModel class object diff --git a/catlearn/regression/gp/calculator/copy_atoms.py b/catlearn/regression/gp/calculator/copy_atoms.py index c1deaf56..493d4e8f 100644 --- a/catlearn/regression/gp/calculator/copy_atoms.py +++ b/catlearn/regression/gp/calculator/copy_atoms.py @@ -120,7 +120,18 @@ def __init__( dtype=float, **results, ): - """Save the properties for the given configuration.""" + """ + Save the properties for the given configuration. + + Parameters: + atoms: ASE Atoms instance + The ASE Atoms instance which is used. + dtype: data type + The data type of the properties. + results: dict + The properties to be saved in the calculator. + If not given, the properties are taken from the calculator. + """ super().__init__() self.results = {} # Save the properties diff --git a/catlearn/regression/gp/calculator/database.py b/catlearn/regression/gp/calculator/database.py index 440e50c6..e9f4ecbb 100644 --- a/catlearn/regression/gp/calculator/database.py +++ b/catlearn/regression/gp/calculator/database.py @@ -8,6 +8,11 @@ class Database: + """ + Database of ASE Atoms instances that are converted + into stored fingerprints and targets. + """ + def __init__( self, fingerprint=None, @@ -20,8 +25,7 @@ def __init__( **kwargs, ): """ - Database of ASE atoms objects that are converted - into fingerprints and targets. + Initialize the database. Parameters: fingerprint: Fingerprint object diff --git a/catlearn/regression/gp/calculator/database_reduction.py b/catlearn/regression/gp/calculator/database_reduction.py index daf996a9..1cf56079 100644 --- a/catlearn/regression/gp/calculator/database_reduction.py +++ b/catlearn/regression/gp/calculator/database_reduction.py @@ -1,4 +1,3 @@ -# import numpy as np from numpy import ( append, arange, @@ -17,6 +16,13 @@ class DatabaseReduction(Database): + """ + Database of ASE Atoms instances that are converted + into stored fingerprints and targets. + The used Database is a reduced set of the full Database. + The reduction is done with a method that is defined in the class. + """ + def __init__( self, fingerprint=None, @@ -32,10 +38,7 @@ def __init__( **kwargs, ): """ - Database of ASE atoms objects that are converted - into fingerprints and targets. - The used Database is a reduced set of the full Database. - The reduced data set is selected from a method. + Initialize the database. Parameters: fingerprint: Fingerprint object @@ -330,67 +333,13 @@ def get_arguments(self): class DatabaseDistance(DatabaseReduction): - def __init__( - self, - fingerprint=None, - reduce_dimensions=True, - use_derivatives=True, - use_fingerprint=True, - round_targets=None, - seed=None, - dtype=float, - npoints=25, - initial_indicies=[0], - include_last=1, - **kwargs, - ): - """ - Database of ASE atoms objects that are converted - into fingerprints and targets. - The used Database is a reduced set of the full Database. - The reduced data set is selected from the distances. - - Parameters: - fingerprint: Fingerprint object - An object as a fingerprint class - that convert atoms to fingerprint. - reduce_dimensions: bool - Whether to reduce the fingerprint space if constrains are used. - use_derivatives: bool - Whether to use derivatives/forces in the targets. - use_fingerprint: bool - Whether the kernel uses fingerprint objects (True) - or arrays (False). - round_targets: int (optional) - The number of decimals to round the targets to. - If None, the targets are not rounded. - seed: int (optional) - The random seed. - The seed an also be a RandomState or Generator instance. - If not given, the default random number generator is used. - dtype: type - The data type of the arrays. - npoints: int - Number of points that are used from the database. - initial_indicies: list - The indicies of the data points that must be included - in the used data base. - include_last: int - Number of last data point to include in the used data base. - """ - super().__init__( - fingerprint=fingerprint, - reduce_dimensions=reduce_dimensions, - use_derivatives=use_derivatives, - use_fingerprint=use_fingerprint, - round_targets=round_targets, - seed=seed, - dtype=dtype, - npoints=npoints, - initial_indicies=initial_indicies, - include_last=include_last, - **kwargs, - ) + """ + Database of ASE Atoms instances that are converted + into stored fingerprints and targets. + The used Database is a reduced set of the full Database. + The reduction is done by selecting the points with the + largest distances from each other. + """ def make_reduction(self, all_indicies, **kwargs): "Reduce the training set with the points farthest from each other." @@ -421,67 +370,12 @@ def make_reduction(self, all_indicies, **kwargs): class DatabaseRandom(DatabaseReduction): - def __init__( - self, - fingerprint=None, - reduce_dimensions=True, - use_derivatives=True, - use_fingerprint=True, - round_targets=None, - seed=None, - dtype=float, - npoints=25, - initial_indicies=[0], - include_last=1, - **kwargs, - ): - """ - Database of ASE atoms objects that are converted - into fingerprints and targets. - The used Database is a reduced set of the full Database. - The reduced data set is selected from random. - - Parameters: - fingerprint: Fingerprint object - An object as a fingerprint class - that convert atoms to fingerprint. - reduce_dimensions: bool - Whether to reduce the fingerprint space if constrains are used. - use_derivatives: bool - Whether to use derivatives/forces in the targets. - use_fingerprint: bool - Whether the kernel uses fingerprint objects (True) - or arrays (False). - round_targets: int (optional) - The number of decimals to round the targets to. - If None, the targets are not rounded. - seed: int (optional) - The random seed. - The seed an also be a RandomState or Generator instance. - If not given, the default random number generator is used. - dtype: type - The data type of the arrays. - npoints: int - Number of points that are used from the database. - initial_indicies: list - The indicies of the data points that must be included - in the used data base. - include_last: int - Number of last data point to include in the used data base. - """ - super().__init__( - fingerprint=fingerprint, - reduce_dimensions=reduce_dimensions, - use_derivatives=use_derivatives, - use_fingerprint=use_fingerprint, - round_targets=round_targets, - seed=seed, - dtype=dtype, - npoints=npoints, - initial_indicies=initial_indicies, - include_last=include_last, - **kwargs, - ) + """ + Database of ASE Atoms instances that are converted + into stored fingerprints and targets. + The used Database is a reduced set of the full Database. + The reduction is done by selecting the points randomly. + """ def make_reduction(self, all_indicies, **kwargs): "Random select the training points." @@ -504,6 +398,15 @@ def make_reduction(self, all_indicies, **kwargs): class DatabaseHybrid(DatabaseReduction): + """ + Database of ASE Atoms instances that are converted + into stored fingerprints and targets. + The used Database is a reduced set of the full Database. + The reduction is done by selecting the points with the + largest distances from each other and randomly. + The random points are selected at every random_fraction step. + """ + def __init__( self, fingerprint=None, @@ -520,11 +423,7 @@ def __init__( **kwargs, ): """ - Database of ASE atoms objects that are converted - into fingerprints and targets. - The used Database is a reduced set of the full Database. - The reduced data set is selected from a mix of - the distances and random. + Initialize the database. Parameters: fingerprint: Fingerprint object @@ -706,6 +605,14 @@ def get_arguments(self): class DatabaseMin(DatabaseReduction): + """ + Database of ASE Atoms instances that are converted + into stored fingerprints and targets. + The used Database is a reduced set of the full Database. + The reduction is done by selecting the points with the + smallest targets. + """ + def __init__( self, fingerprint=None, @@ -722,10 +629,7 @@ def __init__( **kwargs, ): """ - Database of ASE atoms objects that are converted - into fingerprints and targets. - The used Database is a reduced set of the full Database. - The reduced data set is selected from the smallest targets. + Initialize the database. Parameters: fingerprint: Fingerprint object @@ -764,6 +668,7 @@ def __init__( use_derivatives=use_derivatives, use_fingerprint=use_fingerprint, round_targets=round_targets, + seed=seed, dtype=dtype, npoints=npoints, initial_indicies=initial_indicies, @@ -899,67 +804,12 @@ def get_arguments(self): class DatabaseLast(DatabaseReduction): - def __init__( - self, - fingerprint=None, - reduce_dimensions=True, - use_derivatives=True, - use_fingerprint=True, - round_targets=None, - seed=None, - dtype=float, - npoints=25, - initial_indicies=[0], - include_last=1, - **kwargs, - ): - """ - Database of ASE atoms objects that are converted - into fingerprints and targets. - The used Database is a reduced set of the full Database. - The reduced data set is selected from the last data points. - - Parameters: - fingerprint: Fingerprint object - An object as a fingerprint class - that convert atoms to fingerprint. - reduce_dimensions: bool - Whether to reduce the fingerprint space if constrains are used. - use_derivatives: bool - Whether to use derivatives/forces in the targets. - use_fingerprint: bool - Whether the kernel uses fingerprint objects (True) - or arrays (False). - round_targets: int (optional) - The number of decimals to round the targets to. - If None, the targets are not rounded. - seed: int (optional) - The random seed. - The seed an also be a RandomState or Generator instance. - If not given, the default random number generator is used. - dtype: type - The data type of the arrays. - npoints: int - Number of points that are used from the database. - initial_indicies: list - The indicies of the data points that must be included - in the used data base. - include_last: int - Number of last data point to include in the used data base. - """ - super().__init__( - fingerprint=fingerprint, - reduce_dimensions=reduce_dimensions, - use_derivatives=use_derivatives, - use_fingerprint=use_fingerprint, - round_targets=round_targets, - seed=seed, - dtype=dtype, - npoints=npoints, - initial_indicies=initial_indicies, - include_last=include_last, - **kwargs, - ) + """ + Database of ASE Atoms instances that are converted + into stored fingerprints and targets. + The used Database is a reduced set of the full Database. + The reduction is done by selecting the last points in the database. + """ def make_reduction(self, all_indicies, **kwargs): "Use the last data points." @@ -976,68 +826,13 @@ def make_reduction(self, all_indicies, **kwargs): class DatabaseRestart(DatabaseReduction): - def __init__( - self, - fingerprint=None, - reduce_dimensions=True, - use_derivatives=True, - use_fingerprint=True, - round_targets=None, - seed=None, - dtype=float, - npoints=25, - initial_indicies=[0], - include_last=1, - **kwargs, - ): - """ - Database of ASE atoms objects that are converted - into fingerprints and targets. - The used Database is a reduced set of the full Database. - The reduced data set is selected from restarts after npoints are used. - The initial indicies and the last data point is used at each restart. - - Parameters: - fingerprint: Fingerprint object - An object as a fingerprint class - that convert atoms to fingerprint. - reduce_dimensions: bool - Whether to reduce the fingerprint space if constrains are used. - use_derivatives: bool - Whether to use derivatives/forces in the targets. - use_fingerprint: bool - Whether the kernel uses fingerprint objects (True) - or arrays (False). - round_targets: int (optional) - The number of decimals to round the targets to. - If None, the targets are not rounded. - seed: int (optional) - The random seed. - The seed an also be a RandomState or Generator instance. - If not given, the default random number generator is used. - dtype: type - The data type of the arrays. - npoints: int - Number of points that are used from the database. - initial_indicies: list - The indicies of the data points that must be included - in the used data base. - include_last: int - Number of last data point to include in the used data base. - """ - super().__init__( - fingerprint=fingerprint, - reduce_dimensions=reduce_dimensions, - use_derivatives=use_derivatives, - use_fingerprint=use_fingerprint, - round_targets=round_targets, - seed=seed, - dtype=dtype, - npoints=npoints, - initial_indicies=initial_indicies, - include_last=include_last, - **kwargs, - ) + """ + Database of ASE Atoms instances that are converted + into stored fingerprints and targets. + The used Database is a reduced set of the full Database. + The reduced data set is selected from restarts after npoints are used. + The initial indicies and the last data point is used at each restart. + """ def make_reduction(self, all_indicies, **kwargs): "Make restart of used data set." @@ -1068,6 +863,16 @@ def make_reduction(self, all_indicies, **kwargs): class DatabasePointsInterest(DatabaseLast): + """ + Database of ASE Atoms instances that are converted + into stored fingerprints and targets. + The used Database is a reduced set of the full Database. + The reduced data set is selected from the distances + to the points of interest. + The distance metric is the shortest distance + to any of the points of interest. + """ + def __init__( self, fingerprint=None, @@ -1085,13 +890,7 @@ def __init__( **kwargs, ): """ - Database of ASE atoms objects that are converted - into fingerprints and targets. - The used Database is a reduced set of the full Database. - The reduced data set is selected from the distances - to the points of interest. - The distance metric is the shortest distance - to any of the points of interest. + Initialize the database. Parameters: fingerprint: Fingerprint object @@ -1354,79 +1153,15 @@ def get_arguments(self): class DatabasePointsInterestEach(DatabasePointsInterest): - def __init__( - self, - fingerprint=None, - reduce_dimensions=True, - use_derivatives=True, - use_fingerprint=True, - round_targets=None, - seed=None, - dtype=float, - npoints=25, - initial_indicies=[0], - include_last=1, - feature_distance=True, - point_interest=[], - **kwargs, - ): - """ - Database of ASE atoms objects that are converted - into fingerprints and targets. - The used Database is a reduced set of the full Database. - The reduced data set is selected from the distances - to each point of interest. - The distance metric is the shortest distance to the point of interest - and it is performed iteratively. - - Parameters: - fingerprint: Fingerprint object - An object as a fingerprint class - that convert atoms to fingerprint. - reduce_dimensions: bool - Whether to reduce the fingerprint space if constrains are used. - use_derivatives: bool - Whether to use derivatives/forces in the targets. - use_fingerprint: bool - Whether the kernel uses fingerprint objects (True) - or arrays (False). - round_targets: int (optional) - The number of decimals to round the targets to. - If None, the targets are not rounded. - seed: int (optional) - The random seed. - The seed an also be a RandomState or Generator instance. - If not given, the default random number generator is used. - dtype: type - The data type of the arrays. - npoints: int - Number of points that are used from the database. - initial_indicies: list - The indicies of the data points that must be included - in the used data base. - include_last: int - Number of last data point to include in the used data base. - feature_distance: bool - Whether to calculate the distance in feature space (True) - or Cartesian coordinate space (False). - point_interest: list - A list of the points of interest as ASE Atoms instances. - """ - super().__init__( - fingerprint=fingerprint, - reduce_dimensions=reduce_dimensions, - use_derivatives=use_derivatives, - use_fingerprint=use_fingerprint, - round_targets=round_targets, - seed=seed, - dtype=dtype, - npoints=npoints, - initial_indicies=initial_indicies, - include_last=include_last, - feature_distance=feature_distance, - point_interest=point_interest, - **kwargs, - ) + """ + Database of ASE Atoms instances that are converted + into stored fingerprints and targets. + The used Database is a reduced set of the full Database. + The reduced data set is selected from the distances + to each point of interest. + The distance metric is the shortest distance to the point of interest + and it is performed iteratively. + """ def make_reduction(self, all_indicies, **kwargs): """ diff --git a/catlearn/regression/gp/calculator/hiermodel.py b/catlearn/regression/gp/calculator/hiermodel.py index 8218cddc..e5e77f2b 100644 --- a/catlearn/regression/gp/calculator/hiermodel.py +++ b/catlearn/regression/gp/calculator/hiermodel.py @@ -4,6 +4,15 @@ class HierarchicalMLModel(MLModel): + """ + Machine Learning model used for the ASE Atoms instances and + in the machine learning calculators. + It is a hierarchy of ML models where the first model is used + for the first npoints data points. A new model is made when the + number of data points exceed the number of points. + The old models are used as a baseline. + """ + def __init__( self, model=None, @@ -20,11 +29,7 @@ def __init__( **kwargs, ): """ - A hierarchy of Machine Learning model used for - ASE Atoms and calculator. - A new model is made when the number of data points - exceed the number of points. - The old models are used as a baseline. + Initialize the ML model for Atoms. Parameters: model: Model diff --git a/catlearn/regression/gp/calculator/mlcalc.py b/catlearn/regression/gp/calculator/mlcalc.py index a3e5d71b..044cb360 100644 --- a/catlearn/regression/gp/calculator/mlcalc.py +++ b/catlearn/regression/gp/calculator/mlcalc.py @@ -4,6 +4,10 @@ class MLCalculator(Calculator): + """ + The machine learning calculator object applicable as an ASE calculator for + ASE Atoms instance. + """ # Define the properties available in this calculator implemented_properties = [ @@ -27,7 +31,7 @@ def __init__( **kwargs, ): """ - ML calculator object applicable as an ASE calculator. + Initialize the ML calculator. Parameters: mlmodel: MLModel class object diff --git a/catlearn/regression/gp/calculator/mlmodel.py b/catlearn/regression/gp/calculator/mlmodel.py index c2dffc61..2626ad9f 100644 --- a/catlearn/regression/gp/calculator/mlmodel.py +++ b/catlearn/regression/gp/calculator/mlmodel.py @@ -4,6 +4,11 @@ class MLModel: + """ + Machine Learning model used for the ASE Atoms instances and + in the machine learning calculators. + """ + def __init__( self, model=None, @@ -18,7 +23,7 @@ def __init__( **kwargs, ): """ - Machine Learning model used for ASE Atoms and calculator. + Initialize the ML model for Atoms. Parameters: model: Model diff --git a/catlearn/regression/gp/ensemble/clustering/clustering.py b/catlearn/regression/gp/ensemble/clustering/clustering.py index ac4caae0..78fcc962 100644 --- a/catlearn/regression/gp/ensemble/clustering/clustering.py +++ b/catlearn/regression/gp/ensemble/clustering/clustering.py @@ -2,6 +2,10 @@ class Clustering: + """ + Clustering algorithn class for data sets. + """ + def __init__( self, seed=None, @@ -9,7 +13,7 @@ def __init__( **kwargs, ): """ - Clustering class object for data sets. + Initialize the clustering algorithm. Parameters: seed: int (optional) diff --git a/catlearn/regression/gp/ensemble/clustering/fixed.py b/catlearn/regression/gp/ensemble/clustering/fixed.py index bd01b9e8..72a22b7f 100644 --- a/catlearn/regression/gp/ensemble/clustering/fixed.py +++ b/catlearn/regression/gp/ensemble/clustering/fixed.py @@ -3,6 +3,11 @@ class FixedClustering(K_means): + """ + Clustering algorithn class for data sets. + It uses the distances to pre-defined fixed centroids for clustering. + """ + def __init__( self, metric="euclidean", @@ -12,8 +17,7 @@ def __init__( **kwargs, ): """ - Clustering class object for data sets. - Use distances to pre-defined fixed centroids for clustering. + Initialize the clustering algorithm. Parameters: metric: str diff --git a/catlearn/regression/gp/ensemble/clustering/k_means.py b/catlearn/regression/gp/ensemble/clustering/k_means.py index 56ec751a..f1efde68 100644 --- a/catlearn/regression/gp/ensemble/clustering/k_means.py +++ b/catlearn/regression/gp/ensemble/clustering/k_means.py @@ -5,6 +5,11 @@ class K_means(Clustering): + """ + Clustering algorithn class for data sets. + It uses the K-means++ algorithm for clustering. + """ + def __init__( self, metric="euclidean", @@ -16,8 +21,7 @@ def __init__( **kwargs, ): """ - Clustering class object for data sets. - The K-means++ algorithm for clustering. + Initialize the clustering algorithm. Parameters: metric: str diff --git a/catlearn/regression/gp/ensemble/clustering/k_means_auto.py b/catlearn/regression/gp/ensemble/clustering/k_means_auto.py index a14418fb..ed5bb439 100644 --- a/catlearn/regression/gp/ensemble/clustering/k_means_auto.py +++ b/catlearn/regression/gp/ensemble/clustering/k_means_auto.py @@ -4,6 +4,12 @@ class K_means_auto(K_means): + """ + Clustering algorithn class for data sets. + It uses the K-means++ algorithm for clustering. + It uses a interval of number of data points in each cluster. + """ + def __init__( self, metric="euclidean", @@ -16,9 +22,7 @@ def __init__( **kwargs, ): """ - Clustering class object for data sets. - The K-means++ algorithm for clustering, but where the number - of clusters are updated. + Initialize the clustering algorithm. Parameters: metric: str diff --git a/catlearn/regression/gp/ensemble/clustering/k_means_number.py b/catlearn/regression/gp/ensemble/clustering/k_means_number.py index a6c6ea53..eeed21b6 100644 --- a/catlearn/regression/gp/ensemble/clustering/k_means_number.py +++ b/catlearn/regression/gp/ensemble/clustering/k_means_number.py @@ -4,6 +4,12 @@ class K_means_number(K_means): + """ + Clustering algorithn class for data sets. + It uses the K-means++ algorithm for clustering. + It uses a fixed number of data points in each cluster. + """ + def __init__( self, metric="euclidean", @@ -15,9 +21,7 @@ def __init__( **kwargs, ): """ - Clustering class object for data sets. - The K-means++ algorithm for clustering, but where the number - of clusters are updated from a fixed number data point in each cluster. + Initialize the clustering algorithm. Parameters: metric: str diff --git a/catlearn/regression/gp/ensemble/clustering/random.py b/catlearn/regression/gp/ensemble/clustering/random.py index 701bdaca..b99f9bcd 100644 --- a/catlearn/regression/gp/ensemble/clustering/random.py +++ b/catlearn/regression/gp/ensemble/clustering/random.py @@ -3,6 +3,11 @@ class RandomClustering(Clustering): + """ + Clustering algorithn class for data sets. + It uses randomized clusters for clustering. + """ + def __init__( self, n_clusters=4, @@ -12,8 +17,7 @@ def __init__( **kwargs, ): """ - Clustering class object for data sets. - The K-means++ algorithm for clustering. + Initialize the clustering algorithm. Parameters: n_clusters: int diff --git a/catlearn/regression/gp/ensemble/clustering/random_number.py b/catlearn/regression/gp/ensemble/clustering/random_number.py index cb04987d..eb08893b 100644 --- a/catlearn/regression/gp/ensemble/clustering/random_number.py +++ b/catlearn/regression/gp/ensemble/clustering/random_number.py @@ -3,10 +3,15 @@ class RandomClustering_number(RandomClustering): + """ + Clustering algorithn class for data sets. + It uses randomized clusters for clustering. + It uses a fixed number of data points in each cluster. + """ + def __init__(self, data_number=25, seed=None, dtype=float, **kwargs): """ - Clustering class object for data sets. - The K-means++ algorithm for clustering. + Initialize the clustering algorithm. Parameters: data_number: int diff --git a/catlearn/regression/gp/ensemble/ensemble.py b/catlearn/regression/gp/ensemble/ensemble.py index 1b653211..bc4a9326 100644 --- a/catlearn/regression/gp/ensemble/ensemble.py +++ b/catlearn/regression/gp/ensemble/ensemble.py @@ -6,6 +6,12 @@ class EnsembleModel: + """ + Ensemble model of machine learning models. + The ensemble model is used to combine the predictions + of multiple machine learning models. + """ + def __init__( self, model=None, @@ -16,7 +22,7 @@ def __init__( **kwargs, ): """ - Ensemble model of machine learning models. + Initialize the ensemble model. Parameters: model: Model diff --git a/catlearn/regression/gp/ensemble/ensemble_clustering.py b/catlearn/regression/gp/ensemble/ensemble_clustering.py index 066401fc..f2b51b53 100644 --- a/catlearn/regression/gp/ensemble/ensemble_clustering.py +++ b/catlearn/regression/gp/ensemble/ensemble_clustering.py @@ -4,6 +4,13 @@ class EnsembleClustering(EnsembleModel): + """ + Ensemble model of machine learning models. + The ensemble model is used to combine the predictions + of multiple machine learning models. + The ensemle models are chosen by a clustering algorithm. + """ + def __init__( self, model=None, @@ -15,8 +22,7 @@ def __init__( **kwargs, ): """ - Ensemble model of machine learning models with ensembles - from a clustering algorithm.. + Initialize the ensemble model. Parameters: model: Model diff --git a/catlearn/regression/gp/fingerprint/cartesian.py b/catlearn/regression/gp/fingerprint/cartesian.py index 8e7eabe1..ff37f441 100644 --- a/catlearn/regression/gp/fingerprint/cartesian.py +++ b/catlearn/regression/gp/fingerprint/cartesian.py @@ -4,35 +4,11 @@ class Cartesian(Fingerprint): - def __init__( - self, - reduce_dimensions=True, - use_derivatives=True, - dtype=float, - **kwargs, - ): - """ - Fingerprint constructer class that convert atoms object into - a fingerprint object with vector and derivatives. - The cartesian coordinate fingerprint is generated. - - Parameters: - reduce_dimensions : bool - Whether to reduce the fingerprint space if constrains are used. - use_derivatives : bool - Calculate and store derivatives of the fingerprint wrt. - the cartesian coordinates. - dtype: type (optional) - The data type of the arrays. - If None, the default data type is used. - """ - # Set the arguments - super().__init__( - reduce_dimensions=reduce_dimensions, - use_derivatives=use_derivatives, - dtype=dtype, - **kwargs, - ) + """ + Fingerprint constructor class that convert an atoms instance into + a fingerprint instance with vector and derivatives. + The cartesian coordinate fingerprint is generated. + """ def make_fingerprint(self, atoms, **kwargs): "The calculation of the cartesian coordinates fingerprint" diff --git a/catlearn/regression/gp/fingerprint/distances.py b/catlearn/regression/gp/fingerprint/distances.py index 45937890..43319dab 100644 --- a/catlearn/regression/gp/fingerprint/distances.py +++ b/catlearn/regression/gp/fingerprint/distances.py @@ -12,6 +12,13 @@ class Distances(Fingerprint): + """ + Fingerprint constructor class that convert an atoms instance into + a fingerprint instance with vector and derivatives. + The distances fingerprint is generated. + The distances are scaled with covalent radii. + """ + def __init__( self, reduce_dimensions=True, @@ -27,10 +34,7 @@ def __init__( **kwargs, ): """ - Fingerprint constructer class that convert atoms object into - a fingerprint object with vector and derivatives. - The distance fingerprint constructer class. - The distances are scaled with covalent radii. + Initialize the fingerprint constructor. Parameters: reduce_dimensions: bool diff --git a/catlearn/regression/gp/fingerprint/fingerprint.py b/catlearn/regression/gp/fingerprint/fingerprint.py index a696a92e..4115c187 100644 --- a/catlearn/regression/gp/fingerprint/fingerprint.py +++ b/catlearn/regression/gp/fingerprint/fingerprint.py @@ -4,6 +4,11 @@ class Fingerprint: + """ + Fingerprint constructor class that convert an atoms instance into + a fingerprint instance with vector and derivatives. + """ + def __init__( self, reduce_dimensions=True, @@ -12,8 +17,7 @@ def __init__( **kwargs, ): """ - Fingerprint constructer class that convert atoms object into - a fingerprint object with vector and derivatives. + Initialize the fingerprint constructor. Parameters: reduce_dimensions: bool diff --git a/catlearn/regression/gp/fingerprint/fingerprintobject.py b/catlearn/regression/gp/fingerprint/fingerprintobject.py index 00ef9df5..42dda8d0 100644 --- a/catlearn/regression/gp/fingerprint/fingerprintobject.py +++ b/catlearn/regression/gp/fingerprint/fingerprintobject.py @@ -2,11 +2,15 @@ class FingerprintObject: + """ + Fingerprint object class that has the fingerprint vector for an + ASE Atoms instance. + The derivatives wrt. to the cartesian coordinates can also be saved. + """ + def __init__(self, vector, derivative=None, **kwargs): """ - Fingerprint object class that has the fingerprint vector - for an Atoms object. - The derivatives wrt. to the cartesian coordinates can also be saved. + Initialize the fingerprint object. Parameters: vector: (N) array diff --git a/catlearn/regression/gp/fingerprint/fpwrapper.py b/catlearn/regression/gp/fingerprint/fpwrapper.py index 208c370e..468bd6b6 100644 --- a/catlearn/regression/gp/fingerprint/fpwrapper.py +++ b/catlearn/regression/gp/fingerprint/fpwrapper.py @@ -4,6 +4,14 @@ class FingerprintWrapperGPAtom(Fingerprint): + """ + Fingerprint constructor class that convert an atoms instance into + a fingerprint instance with vector and derivatives. + The fingerprint is generated by wrapping the fingerprint class + from gpatom. + (https://gitlab.com/gpatom/ase-gpatom) + """ + def __init__( self, fingerprint, @@ -13,10 +21,7 @@ def __init__( **kwargs, ): """ - Fingerprint constructer class that convert atoms object into - a fingerprint object with vector and derivatives. - The fingerprint is generated by wrapping the fingerprint class - from gpatom. + Initialize the fingerprint constructor. Parameters: fingerprint: gpatom class. @@ -116,6 +121,14 @@ def get_arguments(self): class FingerprintWrapperDScribe(Fingerprint): + """ + Fingerprint constructor class that convert an atoms instance into + a fingerprint instance with vector and derivatives. + The fingerprint is generated by wrapping the fingerprint class + from dscribe (>=2.1). + (https://github.com/SINGROUP/dscribe) + """ + def __init__( self, fingerprint, @@ -126,10 +139,7 @@ def __init__( **kwargs, ): """ - Fingerprint constructer class that convert atoms object into - a fingerprint object with vector and derivatives. - The fingerprint is generated by wrapping the fingerprint class - from dscribe (>=2.1). + Initialize the fingerprint constructor. Parameters: fingerprint: dscribe class instance (>=2.1). diff --git a/catlearn/regression/gp/fingerprint/invdistances.py b/catlearn/regression/gp/fingerprint/invdistances.py index 7c03fae0..59829ab9 100644 --- a/catlearn/regression/gp/fingerprint/invdistances.py +++ b/catlearn/regression/gp/fingerprint/invdistances.py @@ -8,6 +8,13 @@ class InvDistances(Distances): + """ + Fingerprint constructor class that convert an atoms instance into + a fingerprint instance with vector and derivatives. + The inverse distances are constructed as the fingerprint. + The inverse distances are scaled with covalent radii. + """ + def __init__( self, reduce_dimensions=True, @@ -26,10 +33,7 @@ def __init__( **kwargs, ): """ - Fingerprint constructer class that convert atoms object into - a fingerprint object with vector and derivatives. - The inverse distance fingerprint constructer class. - The inverse distances are scaled with covalent radii. + Initialize the fingerprint constructor. Parameters: reduce_dimensions: bool diff --git a/catlearn/regression/gp/fingerprint/invdistances2.py b/catlearn/regression/gp/fingerprint/invdistances2.py index 58d08852..b5eed15d 100644 --- a/catlearn/regression/gp/fingerprint/invdistances2.py +++ b/catlearn/regression/gp/fingerprint/invdistances2.py @@ -2,98 +2,12 @@ class InvDistances2(InvDistances): - def __init__( - self, - reduce_dimensions=True, - use_derivatives=True, - wrap=True, - include_ncells=False, - periodic_sum=False, - periodic_softmax=True, - mic=False, - all_ncells=True, - cell_cutoff=4.0, - use_cutoff=False, - rs_cutoff=3.0, - re_cutoff=4.0, - dtype=float, - **kwargs, - ): - """ - Fingerprint constructer class that convert atoms object into - a fingerprint object with vector and derivatives. - The inverse squared distance fingerprint constructer class. - The inverse squared distances are scaled with covalent radii. - - Parameters: - reduce_dimensions: bool - Whether to reduce the fingerprint space if constrains are used. - use_derivatives: bool - Calculate and store derivatives of the fingerprint wrt. - the cartesian coordinates. - wrap: bool - Whether to wrap the atoms to the unit cell or not. - include_ncells: bool - Include the neighboring cells when calculating the distances. - The fingerprint will include the neighboring cells. - include_ncells will replace periodic_softmax and mic. - Either use mic, periodic_sum, periodic_softmax, or - include_ncells. - periodic_sum: bool - Use a sum of the distances to neighboring cells - when periodic boundary conditions are used. - Either use mic, periodic_sum, periodic_softmax, or - include_ncells. - periodic_softmax: bool - Use a softmax weighting on the distances to neighboring cells - from the squared distances when periodic boundary conditions - are used. - Either use mic, periodic_sum, periodic_softmax, or - include_ncells. - mic: bool - Minimum Image Convention (Shortest distances when - periodic boundary conditions are used). - Either use mic, periodic_sum, periodic_softmax, or - include_ncells. - mic is faster than periodic_softmax, - but the derivatives are discontinuous. - all_ncells: bool - Use all neighboring cells when calculating the distances. - cell_cutoff is used to check how many neighboring cells are - needed. - cell_cutoff: float - The cutoff distance for the neighboring cells. - It is the scaling of the maximum covalent distance. - use_cutoff: bool - Whether to use a cutoff function for the inverse distance - fingerprint. - The cutoff function is a cosine cutoff function. - rs_cutoff: float - The starting distance for the cutoff function being 1. - re_cutoff: float - The ending distance for the cutoff function being 0. - re_cutoff must be larger than rs_cutoff. - dtype: type (optional) - The data type of the arrays. - If None, the default data type is used. - """ - # Set the arguments - super().__init__( - reduce_dimensions=reduce_dimensions, - use_derivatives=use_derivatives, - wrap=wrap, - include_ncells=include_ncells, - periodic_sum=periodic_sum, - periodic_softmax=periodic_softmax, - mic=mic, - all_ncells=all_ncells, - cell_cutoff=cell_cutoff, - use_cutoff=use_cutoff, - rs_cutoff=rs_cutoff, - re_cutoff=re_cutoff, - dtype=dtype, - **kwargs, - ) + """ + Fingerprint constructor class that convert an atoms instance into + a fingerprint instance with vector and derivatives. + The squared inverse distances are constructed as the fingerprint. + The squared inverse distances are scaled with covalent radii. + """ def modify_fp( self, diff --git a/catlearn/regression/gp/fingerprint/meandistances.py b/catlearn/regression/gp/fingerprint/meandistances.py index a3fc3be6..0184a951 100644 --- a/catlearn/regression/gp/fingerprint/meandistances.py +++ b/catlearn/regression/gp/fingerprint/meandistances.py @@ -3,117 +3,12 @@ class MeanDistances(SumDistances): - def __init__( - self, - reduce_dimensions=True, - use_derivatives=True, - wrap=True, - include_ncells=False, - periodic_sum=False, - periodic_softmax=True, - mic=False, - all_ncells=True, - cell_cutoff=4.0, - use_cutoff=False, - rs_cutoff=3.0, - re_cutoff=4.0, - dtype=float, - use_tags=False, - use_pairs=True, - reuse_combinations=True, - **kwargs, - ): - """ - Fingerprint constructer class that convert atoms object into - a fingerprint object with vector and derivatives. - The mean of inverse distance fingerprint constructer class. - The inverse distances are scaled with covalent radii. - - Parameters: - reduce_dimensions: bool - Whether to reduce the fingerprint space if constrains are used. - use_derivatives: bool - Calculate and store derivatives of the fingerprint wrt. - the cartesian coordinates. - wrap: bool - Whether to wrap the atoms to the unit cell or not. - include_ncells: bool - Include the neighboring cells when calculating the distances. - The fingerprint will include the neighboring cells. - include_ncells will replace periodic_softmax and mic. - Either use mic, periodic_sum, periodic_softmax, or - include_ncells. - periodic_sum: bool - Use a sum of the distances to neighboring cells - when periodic boundary conditions are used. - Either use mic, periodic_sum, periodic_softmax, or - include_ncells. - periodic_softmax: bool - Use a softmax weighting on the distances to neighboring cells - from the squared distances when periodic boundary conditions - are used. - Either use mic, periodic_sum, periodic_softmax, or - include_ncells. - mic: bool - Minimum Image Convention (Shortest distances when - periodic boundary conditions are used). - Either use mic, periodic_sum, periodic_softmax, or - include_ncells. - mic is faster than periodic_softmax, - but the derivatives are discontinuous. - all_ncells: bool - Use all neighboring cells when calculating the distances. - cell_cutoff is used to check how many neighboring cells are - needed. - cell_cutoff: float - The cutoff distance for the neighboring cells. - It is the scaling of the maximum covalent distance. - use_cutoff: bool - Whether to use a cutoff function for the inverse distance - fingerprint. - The cutoff function is a cosine cutoff function. - rs_cutoff: float - The starting distance for the cutoff function being 1. - re_cutoff: float - The ending distance for the cutoff function being 0. - re_cutoff must be larger than rs_cutoff. - dtype: type (optional) - The data type of the arrays. - If None, the default data type is used. - use_tags: bool - Use the tags of the atoms to identify the atoms as - another type. - use_pairs: bool - Use the pairs of the atoms to identify the atoms as - another type. - use_pairs: bool - Whether to use pairs of elements or use all elements. - reuse_combinations: bool - Whether to reuse the combinations of the elements. - The change in the atomic numbers and tags will be checked - to see if they are unchanged. - If False, the combinations are calculated each time. - """ - # Set the arguments - super().__init__( - reduce_dimensions=reduce_dimensions, - use_derivatives=use_derivatives, - wrap=wrap, - include_ncells=include_ncells, - periodic_sum=periodic_sum, - periodic_softmax=periodic_softmax, - mic=mic, - all_ncells=all_ncells, - cell_cutoff=cell_cutoff, - use_cutoff=use_cutoff, - rs_cutoff=rs_cutoff, - re_cutoff=re_cutoff, - dtype=dtype, - use_tags=use_tags, - use_pairs=use_pairs, - reuse_combinations=reuse_combinations, - **kwargs, - ) + """ + Fingerprint constructor class that convert an atoms instance into + a fingerprint instance with vector and derivatives. + The mean of inverse distance fingerprint constructer class. + The inverse distances are scaled with covalent radii. + """ def modify_fp_pairs( self, diff --git a/catlearn/regression/gp/fingerprint/meandistancespower.py b/catlearn/regression/gp/fingerprint/meandistancespower.py index b43de53c..e8f521c7 100644 --- a/catlearn/regression/gp/fingerprint/meandistancespower.py +++ b/catlearn/regression/gp/fingerprint/meandistancespower.py @@ -3,123 +3,13 @@ class MeanDistancesPower(SumDistancesPower): - def __init__( - self, - reduce_dimensions=True, - use_derivatives=True, - wrap=True, - include_ncells=False, - periodic_sum=False, - periodic_softmax=True, - mic=False, - all_ncells=True, - cell_cutoff=4.0, - use_cutoff=False, - rs_cutoff=3.0, - re_cutoff=4.0, - dtype=float, - use_tags=False, - use_pairs=True, - reuse_combinations=True, - power=4, - use_roots=True, - **kwargs, - ): - """ - Fingerprint constructer class that convert atoms object into - a fingerprint object with vector and derivatives. - The mean of dfferent powers of - the inverse distances fingerprint constructer class. - The inverse distances are scaled with covalent radii. - - Parameters: - reduce_dimensions: bool - Whether to reduce the fingerprint space if constrains are used. - use_derivatives: bool - Calculate and store derivatives of the fingerprint wrt. - the cartesian coordinates. - wrap: bool - Whether to wrap the atoms to the unit cell or not. - include_ncells: bool - Include the neighboring cells when calculating the distances. - The fingerprint will include the neighboring cells. - include_ncells will replace periodic_softmax and mic. - Either use mic, periodic_sum, periodic_softmax, or - include_ncells. - periodic_sum: bool - Use a sum of the distances to neighboring cells - when periodic boundary conditions are used. - Either use mic, periodic_sum, periodic_softmax, or - include_ncells. - periodic_softmax: bool - Use a softmax weighting on the distances to neighboring cells - from the squared distances when periodic boundary conditions - are used. - Either use mic, periodic_sum, periodic_softmax, or - include_ncells. - mic: bool - Minimum Image Convention (Shortest distances when - periodic boundary conditions are used). - Either use mic, periodic_sum, periodic_softmax, or - include_ncells. - mic is faster than periodic_softmax, - but the derivatives are discontinuous. - all_ncells: bool - Use all neighboring cells when calculating the distances. - cell_cutoff is used to check how many neighboring cells are - needed. - cell_cutoff: float - The cutoff distance for the neighboring cells. - It is the scaling of the maximum covalent distance. - use_cutoff: bool - Whether to use a cutoff function for the inverse distance - fingerprint. - The cutoff function is a cosine cutoff function. - rs_cutoff: float - The starting distance for the cutoff function being 1. - re_cutoff: float - The ending distance for the cutoff function being 0. - re_cutoff must be larger than rs_cutoff. - dtype: type (optional) - The data type of the arrays. - If None, the default data type is used. - use_tags: bool - Use the tags of the atoms to identify the atoms as - another type. - use_pairs: bool - Whether to use pairs of elements or use all elements. - reuse_combinations: bool - Whether to reuse the combinations of the elements. - The change in the atomic numbers and tags will be checked - to see if they are unchanged. - If False, the combinations are calculated each time. - power: int - The power of the inverse distances. - use_roots: bool - Whether to use roots of the power elements. - """ - # Set the arguments - super().__init__( - reduce_dimensions=reduce_dimensions, - use_derivatives=use_derivatives, - wrap=wrap, - include_ncells=include_ncells, - periodic_sum=periodic_sum, - periodic_softmax=periodic_softmax, - mic=mic, - all_ncells=all_ncells, - cell_cutoff=cell_cutoff, - use_cutoff=use_cutoff, - rs_cutoff=rs_cutoff, - re_cutoff=re_cutoff, - dtype=dtype, - use_tags=use_tags, - use_pairs=use_pairs, - reuse_combinations=reuse_combinations, - power=power, - use_roots=use_roots, - **kwargs, - ) + """ + Fingerprint constructor class that convert an atoms instance into + a fingerprint instance with vector and derivatives. + The mean of multiple powers of the inverse distance fingerprint + constructer class. + The inverse distances are scaled with covalent radii. + """ def modify_fp_pairs( self, diff --git a/catlearn/regression/gp/fingerprint/sorteddistances.py b/catlearn/regression/gp/fingerprint/sorteddistances.py index 25387ed9..37f14eae 100644 --- a/catlearn/regression/gp/fingerprint/sorteddistances.py +++ b/catlearn/regression/gp/fingerprint/sorteddistances.py @@ -3,6 +3,13 @@ class SortedInvDistances(InvDistances): + """ + Fingerprint constructor class that convert an atoms instance into + a fingerprint instance with vector and derivatives. + The sorted inverse distance fingerprint constructer class. + The inverse distances are scaled with covalent radii. + """ + def __init__( self, reduce_dimensions=True, @@ -24,10 +31,7 @@ def __init__( **kwargs, ): """ - Fingerprint constructer class that convert atoms object into - a fingerprint object with vector and derivatives. - The sorted inverse distance fingerprint constructer class. - The inverse distances are scaled with covalent radii. + Initialize the fingerprint class. Parameters: reduce_dimensions: bool diff --git a/catlearn/regression/gp/fingerprint/sumdistances.py b/catlearn/regression/gp/fingerprint/sumdistances.py index e7de44dd..ccd8b3b3 100644 --- a/catlearn/regression/gp/fingerprint/sumdistances.py +++ b/catlearn/regression/gp/fingerprint/sumdistances.py @@ -10,6 +10,13 @@ class SumDistances(InvDistances): + """ + Fingerprint constructor class that convert an atoms instance into + a fingerprint instance with vector and derivatives. + The sum of inverse distance fingerprint constructer class. + The inverse distances are scaled with covalent radii. + """ + def __init__( self, reduce_dimensions=True, @@ -31,10 +38,7 @@ def __init__( **kwargs, ): """ - Fingerprint constructer class that convert atoms object into - a fingerprint object with vector and derivatives. - The sum of inverse distance fingerprint constructer class. - The inverse distances are scaled with covalent radii. + Initialize the fingerprint constructor. Parameters: reduce_dimensions: bool diff --git a/catlearn/regression/gp/fingerprint/sumdistancespower.py b/catlearn/regression/gp/fingerprint/sumdistancespower.py index 6923de6b..2f6cd8c3 100644 --- a/catlearn/regression/gp/fingerprint/sumdistancespower.py +++ b/catlearn/regression/gp/fingerprint/sumdistancespower.py @@ -3,6 +3,14 @@ class SumDistancesPower(SumDistances): + """ + Fingerprint constructor class that convert an atoms instance into + a fingerprint instance with vector and derivatives. + The sum of multiple powers of the inverse distance fingerprint + constructer class. + The inverse distances are scaled with covalent radii. + """ + def __init__( self, reduce_dimensions=True, @@ -26,11 +34,7 @@ def __init__( **kwargs, ): """ - Fingerprint constructer class that convert atoms object into - a fingerprint object with vector and derivatives. - The sum of dfferent powers of - the inverse distances fingerprint constructer class. - The inverse distances are scaled with covalent radii. + Initialize the fingerprint constructor. Parameters: reduce_dimensions: bool diff --git a/catlearn/regression/gp/hpboundary/boundary.py b/catlearn/regression/gp/hpboundary/boundary.py index 7f2e24fd..e4fc9981 100644 --- a/catlearn/regression/gp/hpboundary/boundary.py +++ b/catlearn/regression/gp/hpboundary/boundary.py @@ -12,6 +12,14 @@ class HPBoundaries: + """ + Boundary conditions for the hyperparameters. + A dictionary with boundary conditions of the hyperparameters + can be given as an argument. + Machine precisions are used as boundary conditions for + the hyperparameters not given in the dictionary. + """ + def __init__( self, bounds_dict={}, @@ -22,11 +30,7 @@ def __init__( **kwargs, ): """ - Boundary conditions for the hyperparameters. - A dictionary with boundary conditions of the hyperparameters - can be given as an argument. - Machine precisions are used as boundary conditions for - the hyperparameters not given in the dictionary. + Initialize the boundary conditions for the hyperparameters. Parameters: bounds_dict: dict diff --git a/catlearn/regression/gp/hpboundary/educated.py b/catlearn/regression/gp/hpboundary/educated.py index e7a4ef63..e43834f9 100644 --- a/catlearn/regression/gp/hpboundary/educated.py +++ b/catlearn/regression/gp/hpboundary/educated.py @@ -4,6 +4,13 @@ class EducatedBoundaries(RestrictedBoundaries): + """ + Boundary conditions for the hyperparameters with educated guess for + the length-scale, relative-noise, and prefactor hyperparameters. + Machine precisions are used as boundary conditions for + other hyperparameters not given in the dictionary. + """ + def __init__( self, bounds_dict={}, @@ -17,10 +24,7 @@ def __init__( **kwargs, ): """ - Boundary conditions for the hyperparameters with educated guess for - the length-scale, relative-noise, and prefactor hyperparameters. - Machine precisions are used as boundary conditions for - other hyperparameters not given in the dictionary. + Initialize the boundary conditions for the hyperparameters. Parameters: bounds_dict: dict diff --git a/catlearn/regression/gp/hpboundary/hptrans.py b/catlearn/regression/gp/hpboundary/hptrans.py index 20e303af..e85500d9 100644 --- a/catlearn/regression/gp/hpboundary/hptrans.py +++ b/catlearn/regression/gp/hpboundary/hptrans.py @@ -14,6 +14,15 @@ class VariableTransformation(HPBoundaries): + """ + Make variable transformation of hyperparameters into + an interval of (0,1). + A dictionary of mean and standard deviation values are used + to make Logistic transformations. + Boundary conditions can be used to calculate + the variable transformation parameters. + """ + def __init__( self, var_dict={}, @@ -24,12 +33,7 @@ def __init__( **kwargs, ): """ - Make variable transformation of hyperparameters into - an interval of (0,1). - A dictionary of mean and standard deviation values are used - to make Logistic transformations. - Boundary conditions can be used to calculate - the variable transformation parameters. + Initialize the variable transformation of hyperparameters. Parameters: var_dict: dict diff --git a/catlearn/regression/gp/hpboundary/length.py b/catlearn/regression/gp/hpboundary/length.py index aebb2aef..558fa42d 100644 --- a/catlearn/regression/gp/hpboundary/length.py +++ b/catlearn/regression/gp/hpboundary/length.py @@ -15,6 +15,13 @@ class LengthBoundaries(HPBoundaries): + """ + Boundary conditions for the hyperparameters with educated guess for + the length-scale hyperparameter. + Machine precisions are used as default boundary conditions for + the rest of the hyperparameters not given in the dictionary. + """ + def __init__( self, bounds_dict={}, @@ -27,10 +34,7 @@ def __init__( **kwargs, ): """ - Boundary conditions for the hyperparameters with educated guess for - the length-scale hyperparameter. - Machine precisions are used as default boundary conditions for - the rest of the hyperparameters not given in the dictionary. + Initialize the boundary conditions for the hyperparameters. Parameters: bounds_dict: dict diff --git a/catlearn/regression/gp/hpboundary/restricted.py b/catlearn/regression/gp/hpboundary/restricted.py index d003dd89..742f6e3f 100644 --- a/catlearn/regression/gp/hpboundary/restricted.py +++ b/catlearn/regression/gp/hpboundary/restricted.py @@ -3,58 +3,12 @@ class RestrictedBoundaries(LengthBoundaries): - def __init__( - self, - bounds_dict={}, - scale=1.0, - use_log=True, - max_length=True, - use_derivatives=False, - seed=None, - dtype=float, - **kwargs, - ): - """ - Boundary conditions for the hyperparameters with educated guess for - the length-scale and relative-noise hyperparameters. - Machine precisions are used as default boundary conditions for - the rest of the hyperparameters not given in the dictionary. - - Parameters: - bounds_dict: dict - A dictionary with boundary conditions as numpy (H,2) arrays - with two columns for each type of hyperparameter. - scale: float - Scale the boundary conditions. - use_log: bool - Whether to use hyperparameters in log-scale or not. - max_length: bool - Whether to use the maximum scaling for the length-scale or - use a more reasonable scaling. - use_derivatives: bool - Whether the derivatives of the target are used in the model. - The boundary conditions of the length-scale hyperparameter(s) - will change with the use_derivatives. - The use_derivatives will be updated when - update_bounds is called. - seed: int (optional) - The random seed. - The seed can be an integer, RandomState, or Generator instance. - If not given, the default random number generator is used. - dtype: type (optional) - The data type of the arrays. - If None, the default data type is used. - """ - super().__init__( - bounds_dict=bounds_dict, - scale=scale, - use_log=use_log, - max_length=max_length, - use_derivatives=use_derivatives, - seed=seed, - dtype=dtype, - **kwargs, - ) + """ + Boundary conditions for the hyperparameters with educated guess for + the length-scale and relative-noise hyperparameters. + Machine precisions are used as default boundary conditions for + the rest of the hyperparameters not given in the dictionary. + """ def make_bounds(self, model, X, Y, parameters, parameters_set, **kwargs): eps_lower, eps_upper = self.get_boundary_limits() diff --git a/catlearn/regression/gp/hpboundary/strict.py b/catlearn/regression/gp/hpboundary/strict.py index a1307cfd..555099b8 100644 --- a/catlearn/regression/gp/hpboundary/strict.py +++ b/catlearn/regression/gp/hpboundary/strict.py @@ -4,67 +4,13 @@ class StrictBoundaries(EducatedBoundaries): - def __init__( - self, - bounds_dict={}, - scale=1.0, - use_log=True, - max_length=True, - use_derivatives=False, - use_prior_mean=True, - seed=None, - dtype=float, - **kwargs - ): - """ - Boundary conditions for the hyperparameters with educated guess for - the length-scale, relative-noise, and prefactor hyperparameters. - Stricter boundary conditions are used for - the length-scale hyperparameter. - Machine precisions are used as boundary conditions for - other hyperparameters not given in the dictionary. - - Parameters: - bounds_dict: dict - A dictionary with boundary conditions as numpy (H,2) arrays - with two columns for each type of hyperparameter. - scale: float - Scale the boundary conditions. - use_log: bool - Whether to use hyperparameters in log-scale or not. - max_length: bool - Whether to use the maximum scaling for the length-scale or - use a more reasonable scaling. - use_derivatives: bool - Whether the derivatives of the target are used in the model. - The boundary conditions of the length-scale hyperparameter(s) - will change with the use_derivatives. - The use_derivatives will be updated when - update_bounds is called. - use_prior_mean: bool - Whether to use the prior mean to calculate the boundary of - the prefactor hyperparameter. - If use_prior_mean=False, the minimum and maximum target - differences are used as the boundary conditions. - seed: int (optional) - The random seed. - The seed can be an integer, RandomState, or Generator instance. - If not given, the default random number generator is used. - dtype: type (optional) - The data type of the arrays. - If None, the default data type is used. - """ - super().__init__( - bounds_dict=bounds_dict, - scale=scale, - use_log=use_log, - max_length=max_length, - use_derivatives=use_derivatives, - use_prior_mean=use_prior_mean, - seed=seed, - dtype=dtype, - **kwargs, - ) + """ + Boundary conditions for the hyperparameters with educated guess for + the length-scale, relative-noise, and prefactor hyperparameters. + Stricter boundary conditions are used for the length-scale hyperparameter. + Machine precisions are used as boundary conditions for + other hyperparameters not given in the dictionary. + """ def length_bound(self, X, l_dim, **kwargs): """ diff --git a/catlearn/regression/gp/hpboundary/updatebounds.py b/catlearn/regression/gp/hpboundary/updatebounds.py index 4637f179..68b06a98 100644 --- a/catlearn/regression/gp/hpboundary/updatebounds.py +++ b/catlearn/regression/gp/hpboundary/updatebounds.py @@ -3,6 +3,15 @@ class UpdatingBoundaries(HPBoundaries): + """ + An updating boundary conditions for the hyperparameters. + Previous solutions to the hyperparameters can be used to + updating the boundary conditions. + The bounds and the solutions are treated as Normal distributions. + A Normal distribution of a mixture model is then treated as + the updated boundary conditions. + """ + def __init__( self, bounds=None, @@ -15,12 +24,7 @@ def __init__( **kwargs, ): """ - An updating boundary conditions for the hyperparameters. - Previous solutions to the hyperparameters can be used to - updating the boundary conditions. - The bounds and the solutions are treated as Normal distributions. - A Normal distribution of a mixture model is then treated as - the updated boundary conditions. + Initialize the boundary conditions for the hyperparameters. Parameters: bounds: Boundary condition class diff --git a/catlearn/regression/gp/hpfitter/fbpmgp.py b/catlearn/regression/gp/hpfitter/fbpmgp.py index 182d1551..f19b69ce 100644 --- a/catlearn/regression/gp/hpfitter/fbpmgp.py +++ b/catlearn/regression/gp/hpfitter/fbpmgp.py @@ -29,6 +29,12 @@ class FBPMGP(HyperparameterFitter): + """ + This class is used to find the best Gaussian Process + that mimics the Full-Bayesian predictive distribution. + It only works with a Gaussian Process. + """ + def __init__( self, Q=None, @@ -42,9 +48,7 @@ def __init__( **kwargs, ): """ - Get the best Gaussian Process that mimics - the Full-Bayesian predictive distribution. - It only works with a Gaussian Process. + Initialize the class with its arguments. Parameters: Q: (M,D) array diff --git a/catlearn/regression/gp/hpfitter/hpfitter.py b/catlearn/regression/gp/hpfitter/hpfitter.py index 0d68a00d..6d273ad8 100644 --- a/catlearn/regression/gp/hpfitter/hpfitter.py +++ b/catlearn/regression/gp/hpfitter/hpfitter.py @@ -5,6 +5,11 @@ class HyperparameterFitter: + """ + Hyperparameter fitter class for optimizing the hyperparameters + of a given objective function with a given optimizer. + """ + def __init__( self, func, @@ -18,8 +23,7 @@ def __init__( **kwargs, ): """ - Hyperparameter fitter object with an optimizer for optimizing - the hyperparameters on different given objective functions. + Initialize the hyperparameter fitter class. Parameters: func: ObjectiveFunction class diff --git a/catlearn/regression/gp/hpfitter/redhpfitter.py b/catlearn/regression/gp/hpfitter/redhpfitter.py index f8bc6028..9141b362 100644 --- a/catlearn/regression/gp/hpfitter/redhpfitter.py +++ b/catlearn/regression/gp/hpfitter/redhpfitter.py @@ -8,6 +8,13 @@ class ReducedHyperparameterFitter(HyperparameterFitter): + """ + Hyperparameter fitter class for optimizing the hyperparameters + of a given objective function with a given optimizer. + The hyperparameters are only optimized when the training set size + is below a given number. + """ + def __init__( self, func, @@ -22,10 +29,7 @@ def __init__( **kwargs, ): """ - Hyperparameter fitter object with an optimizer for optimizing - the hyperparameters on different given objective functions. - The optimization of the hyperparameters are only performed when - the training set size is below a number. + Initialize the hyperparameter fitter class. Parameters: func: ObjectiveFunction class diff --git a/catlearn/regression/gp/kernel/kernel.py b/catlearn/regression/gp/kernel/kernel.py index 454cc940..4904bf78 100644 --- a/catlearn/regression/gp/kernel/kernel.py +++ b/catlearn/regression/gp/kernel/kernel.py @@ -3,6 +3,10 @@ class Kernel: + """ + The kernel class with hyperparameters. + """ + def __init__( self, use_derivatives=False, @@ -12,7 +16,7 @@ def __init__( **kwargs, ): """ - The Kernel class with hyperparameters. + Initialize the kernel class. Parameters: use_derivatives: bool @@ -25,7 +29,6 @@ def __init__( like hp=dict(length=np.array([-0.7])). dtype: type The data type of the arrays. - """ # Set the default hyperparameters self.hp = dict(length=asarray([-0.7], dtype=dtype)) diff --git a/catlearn/regression/gp/kernel/se.py b/catlearn/regression/gp/kernel/se.py index d85d10b1..714083cb 100644 --- a/catlearn/regression/gp/kernel/se.py +++ b/catlearn/regression/gp/kernel/se.py @@ -16,38 +16,10 @@ class SE(Kernel): - def __init__( - self, - use_derivatives=False, - use_fingerprint=False, - hp={}, - dtype=float, - **kwargs, - ): - """ - The Kernel class with hyperparameters. - Squared exponential or radial basis kernel class. - - Parameters: - use_derivatives: bool - Whether to use the derivatives of the targets. - use_fingerprint: bool - Whether fingerprint objects is given or arrays. - hp: dict - A dictionary of the hyperparameters in the log-space. - The hyperparameters should be given as flatten arrays, - like hp=dict(length=np.array([-0.7])). - dtype: type - The data type of the arrays. - - """ - super().__init__( - use_derivatives=use_derivatives, - use_fingerprint=use_fingerprint, - hp=hp, - dtype=dtype, - **kwargs, - ) + """ + The Squared exponential or radial basis function kernel class + with hyperparameters. + """ def get_KXX(self, features, **kwargs): # Scale features or fingerprints with their length-scales diff --git a/catlearn/regression/gp/means/constant.py b/catlearn/regression/gp/means/constant.py index dcc55e94..f36bd3ad 100644 --- a/catlearn/regression/gp/means/constant.py +++ b/catlearn/regression/gp/means/constant.py @@ -3,13 +3,17 @@ class Prior_constant(Prior): + """ + The prior mean of the targets. + The prior mean is used as a baseline of the target values. + The prior mean is a constant from the target values + if given else it is 0. + A value can be added to the constant. + """ + def __init__(self, yp=0.0, add=0.0, dtype=float, **kwargs): """ - The prior mean of the targets. - The prior mean is used as a baseline of the target values. - The prior mean is a constant from the target values - if given else it is 0. - A value can be added to the constant. + Initialize the prior mean. Parameters: yp: float diff --git a/catlearn/regression/gp/means/first.py b/catlearn/regression/gp/means/first.py index 851f162e..486ac5db 100644 --- a/catlearn/regression/gp/means/first.py +++ b/catlearn/regression/gp/means/first.py @@ -2,22 +2,12 @@ class Prior_first(Prior_constant): - def __init__(self, yp=0.0, add=0.0, dtype=float, **kwargs): - """ - The prior mean of the targets. - The prior mean is used as a baseline of the target values. - The prior mean is the first target value if given else it is 0. - A value can be added to the constant. - - Parameters: - yp: float - The prior mean constant - add: float - A value added to the found prior mean from data. - dtype: type - The data type of the arrays. - """ - self.update_arguments(yp=yp, add=add, dtype=dtype, **kwargs) + """ + The prior mean of the targets. + The prior mean is used as a baseline of the target values. + The prior mean is the first target value if given else it is 0. + A value can be added to the constant. + """ def update(self, features, targets, **kwargs): self.update_arguments(yp=targets.item(0)) diff --git a/catlearn/regression/gp/means/max.py b/catlearn/regression/gp/means/max.py index 97ee368f..eaf71c9c 100644 --- a/catlearn/regression/gp/means/max.py +++ b/catlearn/regression/gp/means/max.py @@ -2,22 +2,12 @@ class Prior_max(Prior_constant): - def __init__(self, yp=0.0, add=0.0, dtype=float, **kwargs): - """ - The prior mean of the targets. - The prior mean is used as a baseline of the target values. - The prior mean is the maximum target value if given else it is 0. - A value can be added to the constant. - - Parameters: - yp: float - The prior mean constant - add: float - A value added to the found prior mean from data. - dtype: type - The data type of the arrays. - """ - self.update_arguments(yp=yp, add=add, dtype=dtype, **kwargs) + """ + The prior mean of the targets. + The prior mean is used as a baseline of the target values. + The prior mean is the maximum target value if given else it is 0. + A value can be added to the constant. + """ def update(self, features, targets, **kwargs): self.update_arguments(yp=targets[:, 0].max()) diff --git a/catlearn/regression/gp/means/mean.py b/catlearn/regression/gp/means/mean.py index 9f29aad0..6474658d 100644 --- a/catlearn/regression/gp/means/mean.py +++ b/catlearn/regression/gp/means/mean.py @@ -2,22 +2,12 @@ class Prior_mean(Prior_constant): - def __init__(self, yp=0.0, add=0.0, dtype=float, **kwargs): - """ - The prior mean of the targets. - The prior mean is used as a baseline of the target values. - The prior mean is the mean of the target values if given else it is 0. - A value can be added to the constant. - - Parameters: - yp: float - The prior mean constant - add: float - A value added to the found prior mean from data. - dtype: type - The data type of the arrays. - """ - self.update_arguments(yp=yp, add=add, dtype=dtype, **kwargs) + """ + The prior mean of the targets. + The prior mean is used as a baseline of the target values. + The prior mean is the mean of the target values if given else it is 0. + A value can be added to the constant. + """ def update(self, features, targets, **kwargs): self.update_arguments(yp=targets[:, 0].mean()) diff --git a/catlearn/regression/gp/means/median.py b/catlearn/regression/gp/means/median.py index c280bd6d..a0048ee4 100644 --- a/catlearn/regression/gp/means/median.py +++ b/catlearn/regression/gp/means/median.py @@ -3,21 +3,13 @@ class Prior_median(Prior_constant): - def __init__(self, yp=0.0, add=0.0, dtype=float, **kwargs): - """ - The prior mean of the targets. - The prior mean is used as a baseline of the target values. - The prior mean is the median of the target values - if given else it is 0. - A value can be added to the constant. - - Parameters: - yp: float - The prior mean constant - add: float - A value added to the found prior mean from data. - """ - self.update_arguments(yp=yp, add=add, dtype=dtype, **kwargs) + """ + The prior mean of the targets. + The prior mean is used as a baseline of the target values. + The prior mean is the median of the target values + if given else it is 0. + A value can be added to the constant. + """ def update(self, features, targets, **kwargs): self.update_arguments(yp=median(targets[:, 0])) diff --git a/catlearn/regression/gp/means/min.py b/catlearn/regression/gp/means/min.py index f77052f4..f577d5bb 100644 --- a/catlearn/regression/gp/means/min.py +++ b/catlearn/regression/gp/means/min.py @@ -2,23 +2,13 @@ class Prior_min(Prior_constant): - def __init__(self, yp=0.0, add=0.0, dtype=float, **kwargs): - """ - The prior mean of the targets. - The prior mean is used as a baseline of the target values. - The prior mean is the minimum of the target value - if given else it is 0. - A value can be added to the constant. - - Parameters: - yp: float - The prior mean constant - add: float - A value added to the found prior mean from data. - dtype: type - The data type of the arrays. - """ - self.update_arguments(yp=yp, add=add, dtype=dtype, **kwargs) + """ + The prior mean of the targets. + The prior mean is used as a baseline of the target values. + The prior mean is the minimum of the target value + if given else it is 0. + A value can be added to the constant. + """ def update(self, features, targets, **kwargs): self.update_arguments(yp=targets[:, 0].min()) diff --git a/catlearn/regression/gp/means/prior.py b/catlearn/regression/gp/means/prior.py index 6c9307fb..7cb1614d 100644 --- a/catlearn/regression/gp/means/prior.py +++ b/catlearn/regression/gp/means/prior.py @@ -1,8 +1,12 @@ class Prior: + """ + The prior mean of the targets. + The prior mean is used as a baseline of the target values. + """ + def __init__(self, dtype=float, **kwargs): """ - The prior mean of the targets. - The prior mean is used as a baseline of the target values. + Initialize the prior mean. Parameters: dtype: type diff --git a/catlearn/regression/gp/models/gp.py b/catlearn/regression/gp/models/gp.py index 62bc4432..90ca52ea 100644 --- a/catlearn/regression/gp/models/gp.py +++ b/catlearn/regression/gp/models/gp.py @@ -9,6 +9,13 @@ class GaussianProcess(ModelProcess): + """ + The Gaussian Process Regressor. + The Gaussian process uses Cholesky decomposition for + inverting the kernel matrix. + The hyperparameters can be optimized. + """ + def __init__( self, prior=Prior_mean(), @@ -21,10 +28,7 @@ def __init__( **kwargs ): """ - The Gaussian Process Regressor. - The Gaussian process uses Cholesky decomposition for - inverting the kernel matrix. - The hyperparameters can be optimized. + Initialize the Gaussian Process Regressor. Parameters: prior: Prior class diff --git a/catlearn/regression/gp/models/model.py b/catlearn/regression/gp/models/model.py index 088a937d..e5f558bc 100644 --- a/catlearn/regression/gp/models/model.py +++ b/catlearn/regression/gp/models/model.py @@ -19,6 +19,13 @@ class ModelProcess: + """ + The Model Process Regressor. + The Model process uses Cholesky decomposition for + inverting the kernel matrix. + The hyperparameters can be optimized. + """ + def __init__( self, prior=Prior_mean(), @@ -31,10 +38,7 @@ def __init__( **kwargs, ): """ - The Model Process Regressor. - The Model process uses Cholesky decomposition for - inverting the kernel matrix. - The hyperparameters can be optimized. + Initialize the Model Process Regressor. Parameters: prior: Prior class diff --git a/catlearn/regression/gp/models/tp.py b/catlearn/regression/gp/models/tp.py index a85f8164..aa0db610 100644 --- a/catlearn/regression/gp/models/tp.py +++ b/catlearn/regression/gp/models/tp.py @@ -9,6 +9,13 @@ class TProcess(ModelProcess): + """ + The Student's T Process Regressor. + The Student's T process uses Cholesky decomposition for + inverting the kernel matrix. + The hyperparameters can be optimized. + """ + def __init__( self, prior=Prior_mean(), @@ -23,10 +30,7 @@ def __init__( **kwargs, ): """ - The Student's T Process Regressor. - The Student's T process uses Cholesky decomposition for - inverting the kernel matrix. - The hyperparameters can be optimized. + Initialize the Student's T Process Regressor. Parameters: prior: Prior class diff --git a/catlearn/regression/gp/objectivefunctions/batch.py b/catlearn/regression/gp/objectivefunctions/batch.py index 104148ca..ab61eefc 100644 --- a/catlearn/regression/gp/objectivefunctions/batch.py +++ b/catlearn/regression/gp/objectivefunctions/batch.py @@ -11,6 +11,16 @@ class BatchFuction(ObjectiveFuction): + """ + The objective function that is used to optimize the hyperparameters. + The instance splits the training data into batches. + A given objective function is then used as + an objective function for the batches. + The function values from each batch are summed. + BatchFuction is not recommended for analytic prefactor or + noise optimized objective functions! + """ + def __init__( self, func, @@ -23,13 +33,7 @@ def __init__( **kwargs, ): """ - The objective function that is used to optimize the hyperparameters. - The instance splits the training data into batches. - A given objective function is then used as - an objective function for the batches. - The function values from each batch are summed. - BatchFuction is not recommended for analytic prefactor or - noise optimized objective functions! + Initialize the objective function. Parameters: func: ObjectiveFunction class diff --git a/catlearn/regression/gp/objectivefunctions/best_batch.py b/catlearn/regression/gp/objectivefunctions/best_batch.py index d7426a7c..f7ed5f4b 100644 --- a/catlearn/regression/gp/objectivefunctions/best_batch.py +++ b/catlearn/regression/gp/objectivefunctions/best_batch.py @@ -3,56 +3,15 @@ class BestBatchFuction(BatchFuction): - def __init__( - self, - func, - get_prior_mean=False, - batch_size=25, - equal_size=False, - use_same_prior_mean=True, - seed=None, - dtype=float, - **kwargs, - ): - """ - The objective function that is used to optimize the hyperparameters. - The instance splits the training data into batches. - A given objective function is then used as - an objective function for the batches. - The lowest function value and it corresponding hyperparameters - from a single batch are used. - BestBatchFuction is not recommended for gradient-based optimization! - - Parameters: - func: ObjectiveFunction class - A class with the objective function used - to optimize the hyperparameters. - get_prior_mean: bool - Whether to get the parameters of the prior mean - in the solution. - equal_size: bool - Whether the clusters are forced to have the same size. - use_same_prior_mean: bool - Whether to use the same prior mean for all models. - seed: int (optional) - The random seed. - The seed can be an integer, RandomState, or Generator instance. - If not given, the default random number generator is used. - dtype: type (optional) - The data type of the arrays. - If None, the default data type is used. - """ - # Set the arguments - super().__init__( - func=func, - get_prior_mean=get_prior_mean, - batch_size=batch_size, - equal_size=equal_size, - use_same_prior_mean=use_same_prior_mean, - seed=seed, - dtype=dtype, - **kwargs, - ) + """ + The objective function that is used to optimize the hyperparameters. + The instance splits the training data into batches. + A given objective function is then used as + an objective function for the batches. + The lowest function value and it corresponding hyperparameters + from a single batch are used. + BestBatchFuction is not recommended for gradient-based optimization! + """ def function( self, diff --git a/catlearn/regression/gp/objectivefunctions/gp/factorized_gpp.py b/catlearn/regression/gp/objectivefunctions/gp/factorized_gpp.py index 6adafd24..f18d7bb3 100644 --- a/catlearn/regression/gp/objectivefunctions/gp/factorized_gpp.py +++ b/catlearn/regression/gp/objectivefunctions/gp/factorized_gpp.py @@ -11,62 +11,19 @@ pi, zeros, ) -from .factorized_likelihood import ( - FactorizedLogLikelihood, - VariableTransformation, -) +from .factorized_likelihood import FactorizedLogLikelihood class FactorizedGPP(FactorizedLogLikelihood): - def __init__( - self, - get_prior_mean=False, - modification=False, - ngrid=80, - bounds=VariableTransformation(), - noise_optimizer=None, - dtype=float, - **kwargs, - ): - """ - The factorized Geissers surrogate predictive probability - objective function that is used to optimize the hyperparameters. - The prefactor hyperparameter is determined from - an analytical expression. - An eigendecomposition is performed to get the eigenvalues. - The relative-noise hyperparameter can be searched from - a single eigendecomposition for each length-scale hyperparameter. - - Parameters: - get_prior_mean: bool - Whether to save the parameters of the prior mean - in the solution. - modification: bool - Whether to modify the analytical prefactor value in the end. - The prefactor hyperparameter becomes larger - if modification=True. - ngrid: int - Number of grid points that are searched in - the relative-noise hyperparameter. - bounds: Boundary_conditions class - A class of the boundary conditions of - the relative-noise hyperparameter. - noise_optimizer: Noise line search optimizer class - A line search optimization method for - the relative-noise hyperparameter. - dtype: type (optional) - The data type of the arrays. - If None, the default data type is used. - """ - super().__init__( - get_prior_mean=get_prior_mean, - modification=modification, - ngrid=ngrid, - bounds=bounds, - noise_optimizer=noise_optimizer, - dtype=dtype, - **kwargs, - ) + """ + The factorized Geissers surrogate predictive probability + objective function that is used to optimize the hyperparameters. + The prefactor hyperparameter is determined from + an analytical expression. + An eigendecomposition is performed to get the eigenvalues. + The relative-noise hyperparameter can be searched from + a single eigendecomposition for each length-scale hyperparameter. + """ def function( self, diff --git a/catlearn/regression/gp/objectivefunctions/gp/factorized_likelihood.py b/catlearn/regression/gp/objectivefunctions/gp/factorized_likelihood.py index d8cdd70d..f36737e5 100644 --- a/catlearn/regression/gp/objectivefunctions/gp/factorized_likelihood.py +++ b/catlearn/regression/gp/objectivefunctions/gp/factorized_likelihood.py @@ -15,6 +15,16 @@ class FactorizedLogLikelihood(ObjectiveFuction): + """ + The factorized log-likelihood objective function that is used + to optimize the hyperparameters. + The prefactor hyperparameter is determined from + an analytical expression. + An eigendecomposition is performed to get the eigenvalues. + The relative-noise hyperparameter can be searched from + a single eigendecomposition for each length-scale hyperparameter. + """ + def __init__( self, get_prior_mean=False, @@ -26,13 +36,7 @@ def __init__( **kwargs, ): """ - The factorized log-likelihood objective function that is used - to optimize the hyperparameters. - The prefactor hyperparameter is determined from - an analytical expression. - An eigendecomposition is performed to get the eigenvalues. - The relative-noise hyperparameter can be searched from - a single eigendecomposition for each length-scale hyperparameter. + Initialize the objective function. Parameters: get_prior_mean: bool diff --git a/catlearn/regression/gp/objectivefunctions/gp/factorized_likelihood_svd.py b/catlearn/regression/gp/objectivefunctions/gp/factorized_likelihood_svd.py index 46411422..73415f97 100644 --- a/catlearn/regression/gp/objectivefunctions/gp/factorized_likelihood_svd.py +++ b/catlearn/regression/gp/objectivefunctions/gp/factorized_likelihood_svd.py @@ -1,61 +1,18 @@ from numpy import matmul from numpy.linalg import svd -from .factorized_likelihood import ( - FactorizedLogLikelihood, - VariableTransformation, -) +from .factorized_likelihood import FactorizedLogLikelihood class FactorizedLogLikelihoodSVD(FactorizedLogLikelihood): - def __init__( - self, - get_prior_mean=False, - modification=False, - ngrid=80, - bounds=VariableTransformation(), - noise_optimizer=None, - dtype=float, - **kwargs, - ): - """ - The factorized log-likelihood objective function that is used - to optimize the hyperparameters. - The prefactor hyperparameter is determined from - an analytical expression. - A SVD is performed to get the eigenvalues. - The relative-noise hyperparameter can be searched from - a single eigendecomposition for each length-scale hyperparameter. - - Parameters: - get_prior_mean: bool - Whether to save the parameters of the prior mean - in the solution. - modification: bool - Whether to modify the analytical prefactor value in the end. - The prefactor hyperparameter becomes larger - if modification=True. - ngrid: int - Number of grid points that are searched in - the relative-noise hyperparameter. - bounds: Boundary_conditions class - A class of the boundary conditions of - the relative-noise hyperparameter. - noise_optimizer: Noise line search optimizer class - A line search optimization method for - the relative-noise hyperparameter. - dtype: type (optional) - The data type of the arrays. - If None, the default data type is used. - """ - super().__init__( - get_prior_mean=get_prior_mean, - modification=modification, - ngrid=ngrid, - bounds=bounds, - noise_optimizer=noise_optimizer, - dtype=dtype, - **kwargs, - ) + """ + The factorized log-likelihood objective function that is used + to optimize the hyperparameters. + The prefactor hyperparameter is determined from + an analytical expression. + A SVD is performed to get the eigenvalues. + The relative-noise hyperparameter can be searched from + a single eigendecomposition for each length-scale hyperparameter. + """ def get_eig(self, model, X, Y): "Calculate the eigenvalues" diff --git a/catlearn/regression/gp/objectivefunctions/gp/gpe.py b/catlearn/regression/gp/objectivefunctions/gp/gpe.py index 0a5c03b2..040a8746 100644 --- a/catlearn/regression/gp/objectivefunctions/gp/gpe.py +++ b/catlearn/regression/gp/objectivefunctions/gp/gpe.py @@ -3,10 +3,14 @@ class GPE(LOO): + """ + The Geissers predictive mean square error objective function as + a function of the hyperparameters. + """ + def __init__(self, get_prior_mean=False, dtype=float, **kwargs): """ - The Geissers predictive mean square error objective function as - a function of the hyperparameters. + Initialize the objective function. Parameters: get_prior_mean: bool diff --git a/catlearn/regression/gp/objectivefunctions/gp/gpp.py b/catlearn/regression/gp/objectivefunctions/gp/gpp.py index ddf6c3b6..04c41af0 100644 --- a/catlearn/regression/gp/objectivefunctions/gp/gpp.py +++ b/catlearn/regression/gp/objectivefunctions/gp/gpp.py @@ -3,12 +3,16 @@ class GPP(LOO): + """ + The Geissers surrogate predictive probability objective function as + a function of the hyperparameters. + The prefactor hyperparameter is calculated from + an analytical expression. + """ + def __init__(self, get_prior_mean=False, dtype=float, **kwargs): """ - The Geissers surrogate predictive probability objective function as - a function of the hyperparameters. - The prefactor hyperparameter is calculated from - an analytical expression. + Initialize the objective function. Parameters: get_prior_mean: bool diff --git a/catlearn/regression/gp/objectivefunctions/gp/likelihood.py b/catlearn/regression/gp/objectivefunctions/gp/likelihood.py index c264ddb6..52a08132 100644 --- a/catlearn/regression/gp/objectivefunctions/gp/likelihood.py +++ b/catlearn/regression/gp/objectivefunctions/gp/likelihood.py @@ -3,20 +3,10 @@ class LogLikelihood(ObjectiveFuction): - def __init__(self, get_prior_mean=False, dtype=float, **kwargs): - """ - The log-likelihood objective function that is used to - optimize the hyperparameters. - - Parameters: - get_prior_mean: bool - Whether to save the parameters of the prior mean - in the solution. - dtype: type (optional) - The data type of the arrays. - If None, the default data type is used. - """ - super().__init__(get_prior_mean=get_prior_mean, dtype=dtype, **kwargs) + """ + The log-likelihood objective function that is used to + optimize the hyperparameters. + """ def function( self, diff --git a/catlearn/regression/gp/objectivefunctions/gp/loo.py b/catlearn/regression/gp/objectivefunctions/gp/loo.py index c71fd2ef..85106ab9 100644 --- a/catlearn/regression/gp/objectivefunctions/gp/loo.py +++ b/catlearn/regression/gp/objectivefunctions/gp/loo.py @@ -14,6 +14,11 @@ class LOO(ObjectiveFuction): + """ + The leave-one-out objective function that is used to + optimize the hyperparameters. + """ + def __init__( self, get_prior_mean=False, @@ -22,8 +27,7 @@ def __init__( **kwargs, ): """ - The leave-one-out objective function that is used to - optimize the hyperparameters. + Initialize the objective function. Parameters: get_prior_mean: bool diff --git a/catlearn/regression/gp/objectivefunctions/gp/mle.py b/catlearn/regression/gp/objectivefunctions/gp/mle.py index a499f7e6..c675da8a 100644 --- a/catlearn/regression/gp/objectivefunctions/gp/mle.py +++ b/catlearn/regression/gp/objectivefunctions/gp/mle.py @@ -14,6 +14,13 @@ class MaximumLogLikelihood(ObjectiveFuction): + """ + The Maximum log-likelihood objective function as + a function of the hyperparameters. + The prefactor hyperparameter is calculated from + an analytical expression. + """ + def __init__( self, get_prior_mean=False, @@ -22,10 +29,7 @@ def __init__( **kwargs, ): """ - The Maximum log-likelihood objective function as - a function of the hyperparameters. - The prefactor hyperparameter is calculated from - an analytical expression. + Initialize the objective function. Parameters: get_prior_mean: bool diff --git a/catlearn/regression/gp/objectivefunctions/objectivefunction.py b/catlearn/regression/gp/objectivefunctions/objectivefunction.py index f44eee20..ee7a0e98 100644 --- a/catlearn/regression/gp/objectivefunctions/objectivefunction.py +++ b/catlearn/regression/gp/objectivefunctions/objectivefunction.py @@ -19,9 +19,13 @@ class ObjectiveFuction: + """ + The objective function that is used to optimize the hyperparameters. + """ + def __init__(self, get_prior_mean=False, dtype=float, **kwargs): """ - The objective function that is used to optimize the hyperparameters. + Initialize the objective function. Parameters: get_prior_mean: bool diff --git a/catlearn/regression/gp/objectivefunctions/tp/factorized_likelihood.py b/catlearn/regression/gp/objectivefunctions/tp/factorized_likelihood.py index 80b67ec2..eb1d0c50 100644 --- a/catlearn/regression/gp/objectivefunctions/tp/factorized_likelihood.py +++ b/catlearn/regression/gp/objectivefunctions/tp/factorized_likelihood.py @@ -15,6 +15,14 @@ class FactorizedLogLikelihood(FactorizedLogLikelihood): + """ + The factorized log-likelihood objective function that is used + to optimize the hyperparameters. + An eigendecomposition is performed to get the eigenvalues. + The relative-noise hyperparameter can be searched from + a single eigendecomposition for each length-scale hyperparameter. + """ + def __init__( self, get_prior_mean=False, @@ -25,11 +33,7 @@ def __init__( **kwargs, ): """ - The factorized log-likelihood objective function that is used - to optimize the hyperparameters. - An eigendecomposition is performed to get the eigenvalues. - The relative-noise hyperparameter can be searched from - a single eigendecomposition for each length-scale hyperparameter. + Initialize the objective function. Parameters: get_prior_mean: bool diff --git a/catlearn/regression/gp/objectivefunctions/tp/factorized_likelihood_svd.py b/catlearn/regression/gp/objectivefunctions/tp/factorized_likelihood_svd.py index 760b5120..fe5d269c 100644 --- a/catlearn/regression/gp/objectivefunctions/tp/factorized_likelihood_svd.py +++ b/catlearn/regression/gp/objectivefunctions/tp/factorized_likelihood_svd.py @@ -1,53 +1,16 @@ from numpy import matmul from numpy.linalg import svd -from .factorized_likelihood import ( - FactorizedLogLikelihood, - VariableTransformation, -) +from .factorized_likelihood import FactorizedLogLikelihood class FactorizedLogLikelihoodSVD(FactorizedLogLikelihood): - def __init__( - self, - get_prior_mean=False, - ngrid=80, - bounds=VariableTransformation(), - noise_optimizer=None, - dtype=float, - **kwargs - ): - """ - The factorized log-likelihood objective function that is used - to optimize the hyperparameters. - A SVD is performed to get the eigenvalues. - The relative-noise hyperparameter can be searched from - a single eigendecomposition for each length-scale hyperparameter. - - Parameters: - get_prior_mean: bool - Whether to save the parameters of the prior mean - in the solution. - ngrid: int - Number of grid points that are searched in - the relative-noise hyperparameter. - bounds: Boundary_conditions class - A class of the boundary conditions of - the relative-noise hyperparameter. - noise_optimizer: Noise line search optimizer class - A line search optimization method for - the relative-noise hyperparameter. - dtype: type (optional) - The data type of the arrays. - If None, the default data type is used. - """ - super().__init__( - get_prior_mean=get_prior_mean, - ngrid=ngrid, - bounds=bounds, - noise_optimizer=noise_optimizer, - dtype=dtype, - **kwargs - ) + """ + The factorized log-likelihood objective function that is used + to optimize the hyperparameters. + A SVD is performed to get the eigenvalues. + The relative-noise hyperparameter can be searched from + a single eigendecomposition for each length-scale hyperparameter. + """ def get_eig(self, model, X, Y): "Calculate the eigenvalues" diff --git a/catlearn/regression/gp/objectivefunctions/tp/likelihood.py b/catlearn/regression/gp/objectivefunctions/tp/likelihood.py index 8f04afa3..d91aca6d 100644 --- a/catlearn/regression/gp/objectivefunctions/tp/likelihood.py +++ b/catlearn/regression/gp/objectivefunctions/tp/likelihood.py @@ -9,20 +9,10 @@ class LogLikelihood(ObjectiveFuction): - def __init__(self, get_prior_mean=False, dtype=float, **kwargs): - """ - The log-likelihood objective function that is used to - optimize the hyperparameters. - - Parameters: - get_prior_mean: bool - Whether to save the parameters of the prior mean - in the solution. - dtype: type (optional) - The data type of the arrays. - If None, the default data type is used. - """ - super().__init__(get_prior_mean=get_prior_mean, dtype=dtype, **kwargs) + """ + The log-likelihood objective function that is used to + optimize the hyperparameters. + """ def function( self, diff --git a/catlearn/regression/gp/optimizers/globaloptimizer.py b/catlearn/regression/gp/optimizers/globaloptimizer.py index 9238c9b0..53c40b8a 100644 --- a/catlearn/regression/gp/optimizers/globaloptimizer.py +++ b/catlearn/regression/gp/optimizers/globaloptimizer.py @@ -21,6 +21,13 @@ class GlobalOptimizer(Optimizer): + """ + The global optimizer used for optimzing the objective function + wrt. the hyperparameters. + The global optimizer requires a local optimization method and + boundary conditions of the hyperparameters. + """ + def __init__( self, local_optimizer=None, @@ -33,10 +40,7 @@ def __init__( **kwargs, ): """ - The global optimizer used for optimzing the objective function - wrt. the hyperparameters. - The global optimizer requires a local optimization method and - boundary conditions of the hyperparameters. + Initialize the global optimizer. Parameters: local_optimizer: Local optimizer class @@ -263,6 +267,14 @@ def get_arguments(self): class RandomSamplingOptimizer(GlobalOptimizer): + """ + The random sampling optimizer used for optimzing the objective function + wrt. the hyperparameters. + The random sampling optimizer samples the hyperparameters randomly + from the boundary conditions + and optimize all samples with the local optimizer. + """ + def __init__( self, local_optimizer=None, @@ -276,11 +288,7 @@ def __init__( **kwargs, ): """ - The random sampling optimizer used for optimzing the objective function - wrt. the hyperparameters. - The random sampling optimizer samples the hyperparameters randomly - from the boundary conditions - and optimize all samples with the local optimizer. + Initialize the global optimizer. Parameters: local_optimizer: Local optimizer class @@ -542,6 +550,15 @@ def get_arguments(self): class GridOptimizer(GlobalOptimizer): + """ + The grid optimizer used for optimzing the objective function + wrt. the hyperparameters. + The grid optimizer makes a grid in the hyperparameter space from + the boundary conditions and evaluate them. + The grid point with the lowest function value can be optimized + with the local optimizer. + """ + def __init__( self, local_optimizer=None, @@ -556,12 +573,7 @@ def __init__( **kwargs, ): """ - The grid optimizer used for optimzing the objective function - wrt. the hyperparameters. - The grid optimizer makes a grid in the hyperparameter space from - the boundary conditions and evaluate them. - The grid point with the lowest function value can be optimized - with the local optimizer. + Initialize the global optimizer. Parameters: local_optimizer: Local optimizer class @@ -875,6 +887,18 @@ def get_arguments(self): class IterativeLineOptimizer(GridOptimizer): + """ + The iteratively line optimizer used for optimzing + the objective function wrt. the hyperparameters. + The iteratively line optimizer makes a 1D grid in each dimension + of the hyperparameter space from the boundary conditions. + The grid points are then evaluated and the best value + updates the hyperparameter in the specific dimension. + This process is done iteratively over all dimensions and in loops. + The grid point with the lowest function value can be optimized + with the local optimizer. + """ + def __init__( self, local_optimizer=None, @@ -891,15 +915,7 @@ def __init__( **kwargs, ): """ - The iteratively line optimizer used for optimzing - the objective function wrt. the hyperparameters. - The iteratively line optimizer makes a 1D grid in each dimension - of the hyperparameter space from the boundary conditions. - The grid points are then evaluated and the best value - updates the hyperparameter in the specific dimension. - This process is done iteratively over all dimensions and in loops. - The grid point with the lowest function value can be optimized - with the local optimizer. + Initialize the global optimizer. Parameters: local_optimizer: Local optimizer class @@ -1140,6 +1156,17 @@ def get_arguments(self): class FactorizedOptimizer(GlobalOptimizer): + """ + The factorized optimizer used for optimzing + the objective function wrt. the hyperparameters. + The factorized optimizer makes a 1D grid for each + hyperparameter from the boundary conditions. + The hyperparameters are then optimized with a line search optimizer. + The line search optimizer optimizes only one of the hyperparameters and + it therefore relies on a factorization method as + the objective function. + """ + def __init__( self, line_optimizer=None, @@ -1154,14 +1181,7 @@ def __init__( **kwargs, ): """ - The factorized optimizer used for optimzing - the objective function wrt. the hyperparameters. - The factorized optimizer makes a 1D grid for each - hyperparameter from the boundary conditions. - The hyperparameters are then optimized with a line search optimizer. - The line search optimizer optimizes only one of the hyperparameters and - it therefore relies on a factorization method as - the objective function. + Initialize the global optimizer. Parameters: line_optimizer: Line search optimizer class @@ -1377,6 +1397,14 @@ def get_arguments(self): class ScipyGlobalOptimizer(Optimizer): + """ + The global optimizer used for optimzing the objective function + wrt. the hyperparameters. + The global optimizer requires a local optimization method and + boundary conditions of the hyperparameters. + This global optimizer is a wrapper to SciPy's global optimizers. + """ + def __init__( self, maxiter=5000, @@ -1389,10 +1417,7 @@ def __init__( **kwargs, ): """ - The global optimizer used for optimzing the objective function - wrt. the hyperparameters. - The global optimizer requires a local optimization method and - boundary conditions of the hyperparameters. + Initialize the global optimizer. Parameters: maxiter: int @@ -1539,6 +1564,16 @@ def get_arguments(self): class BasinOptimizer(ScipyGlobalOptimizer): + """ + The basin-hopping optimizer used for optimzing the objective function + wrt. the hyperparameters. + The basin-hopping optimizer is a wrapper to SciPy's basinhopping. + (https://docs.scipy.org/doc/scipy/reference/generated/scipy.optimize.basinhopping.html) + No local optimizer and boundary conditions are given to this optimizer. + The local optimizer is set by keywords in the local_kwargs and + it uses SciPy's minimizer. + """ + def __init__( self, maxiter=5000, @@ -1551,13 +1586,7 @@ def __init__( **kwargs, ): """ - The basin-hopping optimizer used for optimzing the objective function - wrt. the hyperparameters. - The basin-hopping optimizer is a wrapper to SciPy's basinhopping. - (https://docs.scipy.org/doc/scipy/reference/generated/scipy.optimize.basinhopping.html) - No local optimizer and boundary conditions are given to this optimizer. - The local optimizer is set by keywords in the local_kwargs and - it uses SciPy's minimizer. + Initialize the global optimizer. Parameters: maxiter: int @@ -1723,6 +1752,17 @@ def get_arguments(self): class AnneallingOptimizer(ScipyGlobalOptimizer): + """ + The simulated annealing optimizer used for optimzing + the objective function wrt. the hyperparameters. + The simulated annealing optimizer is a wrapper to + SciPy's dual_annealing. + (https://docs.scipy.org/doc/scipy/reference/generated/scipy.optimize.dual_annealing.html) + No local optimizer is given to this optimizer. + The local optimizer is set by keywords in the local_kwargs and + it uses SciPy's minimizer. + """ + def __init__( self, bounds=EducatedBoundaries(use_log=True), @@ -1736,15 +1776,7 @@ def __init__( **kwargs, ): """ - The simulated annealing optimizer used for optimzing - the objective function wrt. the hyperparameters. - The simulated annealing optimizer is a wrapper to - SciPy's dual_annealing. - (https://docs.scipy.org/doc/scipy/reference/generated/scipy.optimize.dual_annealing.html) - No local optimizer is given to this optimizer. - The local optimizer is set by keywords in the local_kwargs and - it uses SciPy's minimizer. - + Initialize the global optimizer. Parameters: bounds: HPBoundaries class @@ -1939,6 +1971,19 @@ def get_arguments(self): class AnneallingTransOptimizer(AnneallingOptimizer): + """ + The simulated annealing optimizer used for optimzing + the objective functionwrt. the hyperparameters. + The simulated annealing optimizer is a wrapper to + SciPy's dual_annealing. + (https://docs.scipy.org/doc/scipy/reference/generated/scipy.optimize.dual_annealing.html) + No local optimizer is given to this optimizer. + The local optimizer is set by keywords in the local_kwargs and + it uses SciPy's minimizer. + This simulated annealing optimizer uses variable transformation of + the hyperparameters to search the space. + """ + def __init__( self, bounds=VariableTransformation(), @@ -1952,16 +1997,7 @@ def __init__( **kwargs, ): """ - The simulated annealing optimizer used for optimzing - the objective functionwrt. the hyperparameters. - The simulated annealing optimizer is a wrapper to - SciPy's dual_annealing. - (https://docs.scipy.org/doc/scipy/reference/generated/scipy.optimize.dual_annealing.html) - No local optimizer is given to this optimizer. - The local optimizer is set by keywords in the local_kwargs and - it uses SciPy's minimizer. - This simulated annealing optimizer uses variable transformation of - the hyperparameters to search the space. + Initialize the global optimizer. Parameters: bounds: VariableTransformation class diff --git a/catlearn/regression/gp/optimizers/linesearcher.py b/catlearn/regression/gp/optimizers/linesearcher.py index 47138e75..fb45a7ab 100644 --- a/catlearn/regression/gp/optimizers/linesearcher.py +++ b/catlearn/regression/gp/optimizers/linesearcher.py @@ -21,6 +21,14 @@ class LineSearchOptimizer(LocalOptimizer): + """ + The line search optimizer is used for optimzing + the objective function wrt. a single hyperparameter. + The LineSearchOptimizer does only work together with a GlobalOptimizer + that uses line searches (e.g. FactorizedOptimizer). + A line of the hyperparameter is required to run the line search. + """ + def __init__( self, maxiter=5000, @@ -37,11 +45,7 @@ def __init__( **kwargs, ): """ - The line search optimizer is used for optimzing - the objective function wrt. a single hyperparameter. - The LineSearchOptimizer does only work together with a GlobalOptimizer - that uses line searches (e.g. FactorizedOptimizer). - A line of the hyperparameter is required to run the line search. + Initialize the line search optimizer. Parameters: maxiter: int @@ -374,82 +378,14 @@ def get_arguments(self): class GoldenSearch(LineSearchOptimizer): - def __init__( - self, - maxiter=5000, - jac=False, - parallel=False, - seed=None, - dtype=float, - tol=1e-5, - optimize=True, - multiple_min=True, - theta_index=None, - xtol=None, - ftol=None, - **kwargs, - ): - """ - The golden section search method is used as the line search optimizer. - The line search optimizer is used for - optimzing the objective function wrt. a single the hyperparameter. - The GoldenSearch does only work together with a GlobalOptimizer - that uses line searches (e.g. FactorizedOptimizer). - A line of the hyperparameter is required to run the line search. - - Parameters: - maxiter: int - The maximum number of evaluations or iterations - the optimizer can use. - jac: bool - Whether to use the gradient of the objective function - wrt. the hyperparameters. - The line search optimizers cannot use gradients - of the objective function. - parallel: bool - Whether to calculate the grid points in parallel - over multiple CPUs. - seed: int (optional) - The random seed. - The seed can be an integer, RandomState, or Generator instance. - If not given, the default random number generator is used. - dtype: type (optional) - The data type of the arrays. - If None, the default data type is used. - tol: float - A tolerance criterion for convergence. - optimize: bool - Whether to optimize the line given by split it - into smaller intervals. - multiple_min: bool - Whether to optimize multiple minimums or just - optimize the lowest minimum. - theta_index: int or None - The index of the hyperparameter that is - optimized with the line search. - If theta_index=None, then it will use the index of - the length-scale. - If theta_index=None and no length-scale, then theta_index=0. - xtol: float - A tolerance criterion of the hyperparameter for convergence. - ftol: float - A tolerance criterion of the objective function - for convergence. - """ - super().__init__( - maxiter=maxiter, - jac=jac, - parallel=parallel, - seed=seed, - dtype=dtype, - tol=tol, - optimize=optimize, - multiple_min=multiple_min, - theta_index=theta_index, - xtol=xtol, - ftol=ftol, - **kwargs, - ) + """ + The golden section search method is used as the line search optimizer. + The line search optimizer is used for optimzing the objective function + wrt. a single the hyperparameter. + The GoldenSearch does only work together with a GlobalOptimizer + that uses line searches (e.g. FactorizedOptimizer). + A line of the hyperparameter is required to run the line search. + """ def run(self, func, line, parameters, model, X, Y, pdis, **kwargs): # Get the function arguments @@ -659,6 +595,16 @@ def golden_search( class FineGridSearch(LineSearchOptimizer): + """ + The fine grid search method is used as the line search optimizer. + The line search optimizer is used for optimzing the objective function + wrt. a single the hyperparameter. + Finer grids are made for all minimums of the objective function. + The FineGridSearch does only work together with a GlobalOptimizer + that uses line searches (e.g. FactorizedOptimizer). + A line of the hyperparameter is required to run the line search. + """ + def __init__( self, maxiter=5000, @@ -677,13 +623,7 @@ def __init__( **kwargs, ): """ - The fine grid search method is used as the line search optimizer. - The line search optimizer is used for optimzing the objective function - wrt. a single the hyperparameter. - Finer grids are made for all minimums of the objective function. - The FineGridSearch does only work together with a GlobalOptimizer - that uses line searches (e.g. FactorizedOptimizer). - A line of the hyperparameter is required to run the line search. + Initialize the line search optimizer. Parameters: maxiter: int @@ -1034,6 +974,18 @@ def get_arguments(self): class TransGridSearch(FineGridSearch): + """ + The variable transformed grid search method is used + as the line search optimizer. + The line search optimizer is used for optimzing + the objective function wrt. a single the hyperparameter. + Grids are made by updating the variable transformation from + the objective function values. + The TransGridSearch does only work together with a GlobalOptimizer + that uses line searches (e.g. FactorizedOptimizer). + A line of the hyperparameter is required to run the line search. + """ + def __init__( self, maxiter=5000, @@ -1053,15 +1005,7 @@ def __init__( **kwargs, ): """ - The variable transformed grid search method is used - as the line search optimizer. - The line search optimizer is used for optimzing - the objective function wrt. a single the hyperparameter. - Grids are made by updating the variable transformation from - the objective function values. - The TransGridSearch does only work together with a GlobalOptimizer - that uses line searches (e.g. FactorizedOptimizer). - A line of the hyperparameter is required to run the line search. + Initialize the line search optimizer. Parameters: maxiter: int @@ -1072,7 +1016,7 @@ def __init__( wrt. the hyperparameters. The line search optimizers cannot use gradients of the objective function. - parallel : bool + parallel: bool Whether to calculate the grid points in parallel over multiple CPUs. seed: int (optional) @@ -1166,7 +1110,7 @@ def update_arguments( wrt. the hyperparameters. The line search optimizers cannot use gradients of the objective function. - parallel : bool + parallel: bool Whether to calculate the grid points in parallel over multiple CPUs. seed: int (optional) diff --git a/catlearn/regression/gp/optimizers/localoptimizer.py b/catlearn/regression/gp/optimizers/localoptimizer.py index a52cbcf4..dad369a2 100644 --- a/catlearn/regression/gp/optimizers/localoptimizer.py +++ b/catlearn/regression/gp/optimizers/localoptimizer.py @@ -3,6 +3,11 @@ class LocalOptimizer(Optimizer): + """ + The local optimizer used for optimzing the objective function + wrt. the hyperparameters. + """ + def __init__( self, maxiter=5000, @@ -14,8 +19,7 @@ def __init__( **kwargs, ): """ - The local optimizer used for optimzing the objective function - wrt. the hyperparameters. + Initialize the local optimizer. Parameters: maxiter: int @@ -118,6 +122,13 @@ def get_arguments(self): class ScipyOptimizer(LocalOptimizer): + """ + The local optimizer used for optimzing the objective function + wrt. the hyperparameters. + This method uses the SciPy minimizers. + (https://docs.scipy.org/doc/scipy/reference/generated/scipy.optimize.minimize.html) + """ + def __init__( self, maxiter=5000, @@ -134,10 +145,7 @@ def __init__( **kwargs, ): """ - The local optimizer used for optimzing the objective function - wrt. the hyperparameters. - This method uses the SciPy minimizers. - (https://docs.scipy.org/doc/scipy/reference/generated/scipy.optimize.minimize.html) + Initialize the local optimizer. Parameters: maxiter: int @@ -376,77 +384,16 @@ def get_arguments(self): class ScipyPriorOptimizer(ScipyOptimizer): - def __init__( - self, - maxiter=5000, - jac=True, - parallel=False, - seed=None, - dtype=float, - tol=1e-8, - method="l-bfgs-b", - bounds=None, - use_bounds=False, - options={}, - opt_kwargs={}, - **kwargs, - ): - """ - The local optimizer used for optimzing the objective function - wrt.the hyperparameters. - This method uses the SciPy minimizers. - (https://docs.scipy.org/doc/scipy/reference/generated/scipy.optimize.minimize.html) - If prior distributions of the hyperparameters are used, - it will start by include - the prior distributions and then restart with - excluded prior distributions. - - Parameters: - maxiter: int - The maximum number of evaluations or iterations - the optimizer can use. - jac: bool - Whether to use the gradient of the objective function - wrt. the hyperparameters. - parallel: bool - Whether to use parallelization. - This is not implemented for this method. - seed: int (optional) - The random seed. - The seed can be an integer, RandomState, or Generator instance. - If not given, the default random number generator is used. - dtype: type (optional) - The data type of the arrays. - If None, the default data type is used. - tol: float - A tolerance criterion for convergence. - method: str - The minimizer method used in SciPy. - bounds: HPBoundaries class - A class of the boundary conditions of the hyperparameters. - All global optimization methods are using boundary conditions. - use_bounds: bool - Whether to use the boundary conditions or not. - Only some methods can use boundary conditions. - options: dict - Solver options used in the SciPy minimizer. - opt_kwargs: dict - Extra arguments used in the SciPy minimizer. - """ - super().__init__( - maxiter=maxiter, - jac=jac, - parallel=parallel, - seed=seed, - dtype=dtype, - tol=tol, - method=method, - bounds=bounds, - use_bounds=use_bounds, - options=options, - opt_kwargs=opt_kwargs, - **kwargs, - ) + """ + The local optimizer used for optimzing the objective function + wrt.the hyperparameters. + This method uses the SciPy minimizers. + (https://docs.scipy.org/doc/scipy/reference/generated/scipy.optimize.minimize.html) + If prior distributions of the hyperparameters are used, + it will start by include + the prior distributions and then restart with + excluded prior distributions. + """ def run(self, func, theta, parameters, model, X, Y, pdis, **kwargs): # Get solution with the prior distributions @@ -475,75 +422,14 @@ def run(self, func, theta, parameters, model, X, Y, pdis, **kwargs): class ScipyGuessOptimizer(ScipyOptimizer): - def __init__( - self, - maxiter=5000, - jac=True, - parallel=False, - seed=None, - dtype=float, - tol=1e-8, - method="l-bfgs-b", - bounds=None, - use_bounds=False, - options={}, - opt_kwargs={}, - **kwargs, - ): - """ - The local optimizer used for optimzing the objective function - wrt. the hyperparameters. - This method uses the SciPy minimizers. - (https://docs.scipy.org/doc/scipy/reference/generated/scipy.optimize.minimize.html) - Use boundary conditions to give an extra guess of the hyperparameters - that also are optimized. - - Parameters: - maxiter: int - The maximum number of evaluations or iterations - the optimizer can use. - jac: bool - Whether to use the gradient of the objective function - wrt. the hyperparameters. - parallel: bool - Whether to use parallelization. - This is not implemented for this method. - seed: int (optional) - The random seed. - The seed can be an integer, RandomState, or Generator instance. - If not given, the default random number generator is used. - dtype: type (optional) - The data type of the arrays. - If None, the default data type is used. - tol: float - A tolerance criterion for convergence. - method: str - The minimizer method used in SciPy. - bounds: HPBoundaries class - A class of the boundary conditions of the hyperparameters. - All global optimization methods are using boundary conditions. - use_bounds: bool - Whether to use the boundary conditions or not. - Only some methods can use boundary conditions. - options: dict - Solver options used in the SciPy minimizer. - opt_kwargs: dict - Extra arguments used in the SciPy minimizer. - """ - super().__init__( - maxiter=maxiter, - jac=jac, - parallel=parallel, - seed=seed, - dtype=dtype, - tol=tol, - method=method, - bounds=bounds, - use_bounds=use_bounds, - options=options, - opt_kwargs=opt_kwargs, - **kwargs, - ) + """ + The local optimizer used for optimzing the objective function + wrt. the hyperparameters. + This method uses the SciPy minimizers. + (https://docs.scipy.org/doc/scipy/reference/generated/scipy.optimize.minimize.html) + Use boundary conditions to give an extra guess of the hyperparameters + that also are optimized. + """ def run(self, func, theta, parameters, model, X, Y, pdis, **kwargs): # Optimize the initial hyperparameters diff --git a/catlearn/regression/gp/optimizers/noisesearcher.py b/catlearn/regression/gp/optimizers/noisesearcher.py index be833a39..a1369f88 100644 --- a/catlearn/regression/gp/optimizers/noisesearcher.py +++ b/catlearn/regression/gp/optimizers/noisesearcher.py @@ -9,6 +9,16 @@ class NoiseGrid(LineSearchOptimizer): + """ + The grid method is used as the line search optimizer. + The grid of relative-noise hyperparameter values is calculated + with the objective function. + The lowest of objective function values of the single grid + is used as the optimum. + A line of the relative-noise hyperparameter is required to + run the line search. + """ + def __init__( self, maxiter=5000, @@ -19,16 +29,10 @@ def __init__( **kwargs, ): """ - The grid method is used as the line search optimizer. - The grid of relative-noise hyperparameter values is calculated - with the objective function. - The lowest of objective function values of the single grid - is used as the optimum. - A line of the relative-noise hyperparameter is required to - run the line search. + Initialize the relative-noise search optimizer. Parameters: - maxiter : int + maxiter: int The maximum number of evaluations or iterations the optimizer can use. jac: bool @@ -103,7 +107,7 @@ def update_arguments( The existing arguments are used if they are not given. Parameters: - maxiter : int + maxiter: int The maximum number of evaluations or iterations the optimizer can use. jac: bool @@ -175,6 +179,14 @@ def get_arguments(self): class NoiseGoldenSearch(GoldenSearch): + """ + The golden section search method is used as the line search optimizer. + The line search optimizer is used for optimzing the objective function + wrt. the relative-noise hyperparameter. + A line of the relative-noise hyperparameter is required to + run the line search. + """ + def __init__( self, maxiter=5000, @@ -191,14 +203,10 @@ def __init__( **kwargs, ): """ - The golden section search method is used as the line search optimizer. - The line search optimizer is used for optimzing the objective function - wrt. the relative-noise hyperparameter. - A line of the relative-noise hyperparameter is required to - run the line search. + Initialize the relative-noise search optimizer. Parameters: - maxiter : int + maxiter: int The maximum number of evaluations or iterations the optimizer can use. jac: bool @@ -217,23 +225,23 @@ def __init__( dtype: type (optional) The data type of the arrays. If None, the default data type is used. - tol : float + tol: float A tolerance criterion for convergence. - optimize : bool + optimize: bool Whether to optimize the line given by split it into smaller intervals. - multiple_min : bool + multiple_min: bool Whether to optimize multiple minimums or just optimize the lowest minimum. - theta_index : int or None + theta_index: int or None The index of the relative-noise hyperparameter that is optimized with the line search. If theta_index=None, then it will use the index of the relative-noise. If theta_index=None and no relative-noise, then theta_index=0. - xtol : float + xtol: float A tolerance criterion of the hyperparameter for convergence. - ftol : float + ftol: float A tolerance criterion of the objective function for convergence. """ @@ -286,6 +294,15 @@ def set_parallel(self, parallel=False, **kwargs): class NoiseFineGridSearch(FineGridSearch): + """ + The fine grid search method is used as the line search optimizer. + The line search optimizer is used for optimzing the objective function + wrt. the relative-noise hyperparameter. + Finer grids are made for all minimums of the objective function. + A line of the relative-noise hyperparameter is required to + run the line search. + """ + def __init__( self, maxiter=5000, @@ -304,15 +321,10 @@ def __init__( **kwargs, ): """ - The fine grid search method is used as the line search optimizer. - The line search optimizer is used for optimzing the objective function - wrt. the relative-noise hyperparameter. - Finer grids are made for all minimums of the objective function. - A line of the relative-noise hyperparameter is required to - run the line search. + Initialize the relative-noise search optimizer. Parameters: - maxiter : int + maxiter: int The maximum number of evaluations or iterations the optimizer can use. jac: bool @@ -331,23 +343,23 @@ def __init__( dtype: type (optional) The data type of the arrays. If None, the default data type is used. - tol : float + tol: float A tolerance criterion for convergence. - optimize : bool + optimize: bool Whether to optimize the line given by split it into smaller intervals. - multiple_min : bool + multiple_min: bool Whether to optimize multiple minimums or just optimize the lowest minimum. - theta_index : int or None + theta_index: int or None The index of the relative-noise hyperparameter that is optimized with the line search. If theta_index=None, then it will use the index of the relative-noise. If theta_index=None and no relative-noise, then theta_index=0. - xtol : float + xtol: float A tolerance criterion of the hyperparameter for convergence. - ftol : float + ftol: float A tolerance criterion of the objective function for convergence. """ @@ -402,6 +414,17 @@ def set_parallel(self, parallel=False, **kwargs): class NoiseTransGridSearch(TransGridSearch): + """ + The variable transformed grid search method is used as + the line search optimizer. + The line search optimizer is used for optimzing the objective function + wrt. the relative-noise hyperparameter. + Grids are made by updating the variable transformation from + the objective function values. + A line of the relative-noise hyperparameter is required to + run the line search. + """ + def __init__( self, maxiter=5000, @@ -421,17 +444,10 @@ def __init__( **kwargs, ): """ - The variable transformed grid search method is used as - the line search optimizer. - The line search optimizer is used for optimzing the objective function - wrt. the relative-noise hyperparameter. - Grids are made by updating the variable transformation from - the objective function values. - A line of the relative-noise hyperparameter is required to - run the line search. + Initialize the relative-noise search optimizer. Parameters: - maxiter : int + maxiter: int The maximum number of evaluations or iterations the optimizer can use. jac: bool @@ -450,33 +466,33 @@ def __init__( dtype: type (optional) The data type of the arrays. If None, the default data type is used. - tol : float + tol: float A tolerance criterion for convergence. - optimize : bool + optimize: bool Whether to optimize the line given by split it into smaller intervals. - multiple_min : bool + multiple_min: bool Whether to optimize multiple minimums or just optimize the lowest minimum. - ngrid : int + ngrid: int The number of grid points of the hyperparameter that is optimized. - loops : int + loops: int The number of loops where the grid points are made. - use_likelihood : bool + use_likelihood: bool Whether to use the objective function as a log-likelihood or not. If the use_likelihood=False, the objective function is scaled and shifted with the maximum value. - theta_index : int or None + theta_index: int or None The index of the relative-noise hyperparameter that is optimized with the line search. If theta_index=None, then it will use the index of the relative-noise. If theta_index=None and no relative-noise, then theta_index=0. - xtol : float + xtol: float A tolerance criterion of the hyperparameter for convergence. - ftol : float + ftol: float A tolerance criterion of the objective function for convergence. """ diff --git a/catlearn/regression/gp/optimizers/optimizer.py b/catlearn/regression/gp/optimizers/optimizer.py index a72c06b2..a55deb75 100644 --- a/catlearn/regression/gp/optimizers/optimizer.py +++ b/catlearn/regression/gp/optimizers/optimizer.py @@ -5,6 +5,11 @@ class Optimizer: + """ + The optimizer used for optimzing the objective function wrt. + the hyperparameters. + """ + def __init__( self, maxiter=5000, @@ -15,8 +20,7 @@ def __init__( **kwargs, ): """ - The optimizer used for optimzing the objective function - wrt. the hyperparameters. + Initialize the optimizer. Parameters: maxiter: int @@ -396,10 +400,14 @@ def __repr__(self): class FunctionEvaluation(Optimizer): + """ + A method used for evaluating the objective function for + the given hyperparameters. + """ + def __init__(self, jac=True, parallel=False, dtype=float, **kwargs): """ - A method used for evaluating the objective function for - the given hyperparameters. + Initialize the function evaluation method. Parameters: jac: bool diff --git a/catlearn/regression/gp/pdistributions/gamma.py b/catlearn/regression/gp/pdistributions/gamma.py index 593903a1..2bd86ed1 100644 --- a/catlearn/regression/gp/pdistributions/gamma.py +++ b/catlearn/regression/gp/pdistributions/gamma.py @@ -4,16 +4,20 @@ class Gamma_prior(Prior_distribution): + """ + Gamma prior distribution used for each type + of hyperparameters in log-space. + The Gamma distribution is variable transformed from + linear- to log-space. + If the type of the hyperparameter is multi dimensional (H), + it is given in the axis=-1. + If multiple values (M) of the hyperparameter(/s) + are calculated simultaneously, it has to be in a (M,H) array. + """ + def __init__(self, a=1e-20, b=1e-20, dtype=float, **kwargs): """ - Gamma prior distribution used for each type - of hyperparameters in log-space. - The Gamma distribution is variable transformed from - linear- to log-space. - If the type of the hyperparameter is multi dimensional (H), - it is given in the axis=-1. - If multiple values (M) of the hyperparameter(/s) - are calculated simultaneously, it has to be in a (M,H) array. + Initialization of the prior distribution. Parameters: a: float or (H) array diff --git a/catlearn/regression/gp/pdistributions/gen_normal.py b/catlearn/regression/gp/pdistributions/gen_normal.py index 321d589e..1c2cc77b 100644 --- a/catlearn/regression/gp/pdistributions/gen_normal.py +++ b/catlearn/regression/gp/pdistributions/gen_normal.py @@ -3,14 +3,18 @@ class Gen_normal_prior(Prior_distribution): + """ + Independent Generalized Normal prior distribution used for each type + of hyperparameters in log-space. + If the type of the hyperparameter is multi dimensional (H), + it is given in the axis=-1. + If multiple values (M) of the hyperparameter(/s) + are calculated simultaneously, it has to be in a (M,H) array. + """ + def __init__(self, mu=0.0, s=10.0, v=2, dtype=float, **kwargs): """ - Independent Generalized Normal prior distribution used for each type - of hyperparameters in log-space. - If the type of the hyperparameter is multi dimensional (H), - it is given in the axis=-1. - If multiple values (M) of the hyperparameter(/s) - are calculated simultaneously, it has to be in a (M,H) array. + Initialization of the prior distribution. Parameters: mu: float or (H) array diff --git a/catlearn/regression/gp/pdistributions/invgamma.py b/catlearn/regression/gp/pdistributions/invgamma.py index 4460defd..1a10b61e 100644 --- a/catlearn/regression/gp/pdistributions/invgamma.py +++ b/catlearn/regression/gp/pdistributions/invgamma.py @@ -4,16 +4,20 @@ class Invgamma_prior(Prior_distribution): + """ + Inverse-Gamma prior distribution used for each type + of hyperparameters in log-space. + The inverse-gamma distribution is variable transformed from + linear- to log-space. + If the type of the hyperparameter is multi dimensional (H), + it is given in the axis=-1. + If multiple values (M) of the hyperparameter(/s) + are calculated simultaneously, it has to be in a (M,H) array. + """ + def __init__(self, a=1e-20, b=1e-20, dtype=float, **kwargs): """ - Inverse-Gamma prior distribution used for each type - of hyperparameters in log-space. - The inverse-gamma distribution is variable transformed from - linear- to log-space. - If the type of the hyperparameter is multi dimensional (H), - it is given in the axis=-1. - If multiple values (M) of the hyperparameter(/s) - are calculated simultaneously, it has to be in a (M,H) array. + Initialization of the prior distribution. Parameters: a: float or (H) array diff --git a/catlearn/regression/gp/pdistributions/normal.py b/catlearn/regression/gp/pdistributions/normal.py index 375b90a8..631e5536 100644 --- a/catlearn/regression/gp/pdistributions/normal.py +++ b/catlearn/regression/gp/pdistributions/normal.py @@ -3,14 +3,18 @@ class Normal_prior(Prior_distribution): + """ + Independent Normal or Gaussian prior distribution used for each type + of hyperparameters in log-space. + If the type of the hyperparameter is multi dimensional (H), + it is given in the axis=-1. + If multiple values (M) of the hyperparameter(/s) + are calculated simultaneously, it has to be in a (M,H) array. + """ + def __init__(self, mu=0.0, std=10.0, dtype=float, **kwargs): """ - Independent Normal prior distribution used for each type - of hyperparameters in log-space. - If the type of the hyperparameter is multi dimensional (H), - it is given in the axis=-1. - If multiple values (M) of the hyperparameter(/s) - are calculated simultaneously, it has to be in a (M,H) array. + Initialization of the prior distribution. Parameters: mu: float or (H) array diff --git a/catlearn/regression/gp/pdistributions/pdistributions.py b/catlearn/regression/gp/pdistributions/pdistributions.py index 9a02b796..915562a8 100644 --- a/catlearn/regression/gp/pdistributions/pdistributions.py +++ b/catlearn/regression/gp/pdistributions/pdistributions.py @@ -2,14 +2,18 @@ class Prior_distribution: + """ + Prior probability distribution used for each type + of hyperparameters in log-space. + If the type of the hyperparameter is multi dimensional (H), + it is given in the axis=-1. + If multiple values (M) of the hyperparameter(/s) + are calculated simultaneously, it has to be in a (M,H) array. + """ + def __init__(self, dtype=float, **kwargs): """ - Prior probability distribution used for each type - of hyperparameters in log-space. - If the type of the hyperparameter is multi dimensional (H), - it is given in the axis=-1. - If multiple values (M) of the hyperparameter(/s) - are calculated simultaneously, it has to be in a (M,H) array. + Initialization of the prior distribution. Parameters: dtype: type diff --git a/catlearn/regression/gp/pdistributions/uniform.py b/catlearn/regression/gp/pdistributions/uniform.py index 388877ff..303184ec 100644 --- a/catlearn/regression/gp/pdistributions/uniform.py +++ b/catlearn/regression/gp/pdistributions/uniform.py @@ -13,14 +13,18 @@ class Uniform_prior(Prior_distribution): + """ + Uniform prior distribution used for each type + of hyperparameters in log-space. + If the type of the hyperparameter is multi dimensional (H), + it is given in the axis=-1. + If multiple values (M) of the hyperparameter(/s) + are calculated simultaneously, it has to be in a (M,H) array. + """ + def __init__(self, start=-18.0, end=18.0, prob=1.0, dtype=float, **kwargs): """ - Uniform prior distribution used for each type - of hyperparameters in log-space. - If the type of the hyperparameter is multi dimensional (H), - it is given in the axis=-1. - If multiple values (M) of the hyperparameter(/s) - are calculated simultaneously, it has to be in a (M,H) array. + Initialization of the prior distribution. Parameters: start: float or (H) array From a3cb1bbf5c4af4e71992a724a507e1e3123e31bc Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Wed, 21 May 2025 09:19:27 +0200 Subject: [PATCH 117/194] Use warnings --- catlearn/regression/gp/hpfitter/fbpmgp.py | 14 +++++++++----- .../gp/objectivefunctions/objectivefunction.py | 8 +++++--- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/catlearn/regression/gp/hpfitter/fbpmgp.py b/catlearn/regression/gp/hpfitter/fbpmgp.py index f19b69ce..090e92cb 100644 --- a/catlearn/regression/gp/hpfitter/fbpmgp.py +++ b/catlearn/regression/gp/hpfitter/fbpmgp.py @@ -24,7 +24,7 @@ from scipy.linalg import eigh as scipy_eigh from scipy.spatial.distance import pdist from scipy.optimize import OptimizeResult -import logging +import warnings from .hpfitter import HyperparameterFitter, VariableTransformation @@ -278,8 +278,10 @@ def get_eig(self, model, X, Y, **kwargs): # Eigendecomposition try: D, U = eigh(KXX) - except LinAlgError as e: - logging.error("An error occurred: %s", str(e)) + except LinAlgError: + warnings.warn( + "Eigendecomposition failed, using scipy.eigh instead." + ) # More robust but slower eigendecomposition D, U = scipy_eigh(KXX, driver="ev") # Subtract the prior mean to the training target @@ -294,8 +296,10 @@ def get_eig_without_Yp(self, model, X, Y_p, n_data, **kwargs): # Eigendecomposition try: D, U = eigh(KXX) - except LinAlgError as e: - logging.error("An error occurred: %s", str(e)) + except LinAlgError: + warnings.warn( + "Eigendecomposition failed, using scipy.eigh instead." + ) # More robust but slower eigendecomposition D, U = scipy_eigh(KXX, driver="ev") UTY = matmul(U.T, Y_p) diff --git a/catlearn/regression/gp/objectivefunctions/objectivefunction.py b/catlearn/regression/gp/objectivefunctions/objectivefunction.py index ee7a0e98..b476d726 100644 --- a/catlearn/regression/gp/objectivefunctions/objectivefunction.py +++ b/catlearn/regression/gp/objectivefunctions/objectivefunction.py @@ -15,7 +15,7 @@ ) from numpy.linalg import eigh, LinAlgError from scipy.linalg import cho_factor, cho_solve, eigh as scipy_eigh -import logging +import warnings class ObjectiveFuction: @@ -294,8 +294,10 @@ def get_eig(self, model, X, Y, **kwargs): # Eigendecomposition try: D, U = eigh(KXX) - except LinAlgError as e: - logging.error("An error occurred: %s", str(e)) + except LinAlgError: + warnings.warn( + "Eigendecomposition failed, using scipy.eigh instead." + ) # More robust but slower eigendecomposition D, U = scipy_eigh(KXX, driver="ev") # Subtract the prior mean to the training target From f25cc1c59b24cbae2bf2be3a378056fd9db33744 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Wed, 21 May 2025 09:42:07 +0200 Subject: [PATCH 118/194] Use the endpoints in the NEB interpolations and implement the born interpolation. --- catlearn/structures/neb/interpolate_band.py | 116 +++++++++++++++++--- 1 file changed, 102 insertions(+), 14 deletions(-) diff --git a/catlearn/structures/neb/interpolate_band.py b/catlearn/structures/neb/interpolate_band.py index 8b7c114a..db4c1d61 100644 --- a/catlearn/structures/neb/interpolate_band.py +++ b/catlearn/structures/neb/interpolate_band.py @@ -1,5 +1,7 @@ -import numpy as np +from numpy import ndarray +from numpy.linalg import norm from ase.io import read +from ase.geometry import find_mic from ase.optimize import FIRE from ase.build import minimize_rotation_and_translation from ...regression.gp.calculator.copy_atoms import copy_atoms @@ -16,8 +18,40 @@ def interpolate( **interpolation_kwargs, ): """ - Make an interpolation between the start and end structure. - A transition state structure can be given to guide the interpolation. + Make a NEB interpolation between the start and end structure. + A transition state structure can be given to guide the NEB interpolation. + + Parameters: + start: ASE Atoms instance + The starting structure for the NEB interpolation. + end: ASE Atoms instance + The ending structure for the NEB interpolation. + ts: ASE Atoms instance (optional) + An intermediate state the NEB interpolation should go through. + Then, the method should be one of the following: 'linear', 'idpp', + 'rep', 'born', or 'ends'. + n_images: int + The number of images in the NEB interpolation. + method: str or list of ASE Atoms instances + The method to use for the NEB interpolation. If a list of + ASE Atoms instances is given, then the interpolation will be + made between the start and end structure using the images in + the list. If a string is given, then it should be one of the + following: 'linear', 'idpp', 'rep', or 'ends'. The string can + also be the name of a trajectory file. In that case, the + interpolation will be made using the images in the trajectory + file. The trajectory file should contain the start and end + structure. + mic: bool + If True, then the minimum-image convention is used for the + interpolation. If False, then the images are not constrained + to the minimum-image convention. + remove_rotation_and_translation: bool + If True, then the rotation and translation of the end + structure is removed before the interpolation is made. + interpolation_kwargs: dict + Additional keyword arguments to pass to the interpolation + methods. """ # Copy the start and end structures start = copy_atoms(start) @@ -42,6 +76,15 @@ def interpolate( return images # Copy the transition state structure ts = copy_atoms(ts) + # Check if the method is compatible with the interpolation for the TS + if not ( + isinstance(method, str) + and method in ["linear", "idpp", "rep", "born", "ends"] + ): + raise ValueError( + "The method should be one of the following: " + "'linear', 'idpp', 'rep', 'born', or 'ends." + ) # Get the interpolated path from the start structure to the TS structure images = make_interpolation( start, @@ -104,19 +147,22 @@ def make_interpolation( ): "Make the NEB interpolation path." # Use a premade interpolation path - if isinstance(method, (list, np.ndarray)): - images = [copy_atoms(image) for image in method] + if isinstance(method, (list, ndarray)): + images = [copy_atoms(image) for image in method[1:-1]] + images = [copy_atoms(start)] + images + [copy_atoms(end)] elif isinstance(method, str) and method.lower() not in [ "linear", "idpp", "rep", + "born", "ends", ]: # Import interpolation from a trajectory file images = read(method, "-{}:".format(n_images)) + images = [copy_atoms(start)] + images[1:-1] + [copy_atoms(end)] else: # Make path by the NEB methods interpolation - images = [start.copy() for i in range(1, n_images - 1)] + images = [start.copy() for _ in range(1, n_images - 1)] images = [copy_atoms(start)] + images + [copy_atoms(end)] if method.lower() == "ends": images = make_end_interpolations( @@ -142,13 +188,17 @@ def make_interpolation( mic=mic, **interpolation_kwargs, ) + elif method.lower() == "born": + images = make_born_interpolation( + images, + mic=mic, + **interpolation_kwargs, + ) return images def make_linear_interpolation(images, mic=False, **kwargs): "Make the linear interpolation from initial to final state." - from ase.geometry import find_mic - # Get the position of initial state pos0 = images[0].get_positions() # Get the distance to the final state @@ -212,6 +262,9 @@ def make_rep_interpolation( steps=100, local_opt=FIRE, local_kwargs={}, + calc_kwargs={}, + trajectory="rep.traj", + logfile="rep.log", **kwargs, ): """ @@ -222,11 +275,48 @@ def make_rep_interpolation( # Use Repulsive potential as calculator for image in images[1:-1]: - image.calc = RepulsionCalculator(power=10, mic=mic) + image.calc = RepulsionCalculator(mic=mic, **calc_kwargs) + # Make default NEB + neb = ImprovedTangentNEB(images) + # Set local optimizer arguments + local_kwargs_default = dict(trajectory=trajectory, logfile=logfile) + if isinstance(local_opt, FIRE): + local_kwargs_default.update( + dict(dt=0.05, a=1.0, astart=1.0, fa=0.999, maxstep=0.2) + ) + local_kwargs_default.update(local_kwargs) + # Optimize NEB path with repulsive potential + with local_opt(neb, **local_kwargs_default) as opt: + opt.run(fmax=fmax, steps=steps) + return images + + +def make_born_interpolation( + images, + mic=False, + fmax=1.0, + steps=100, + local_opt=FIRE, + local_kwargs={}, + calc_kwargs={}, + trajectory="born.traj", + logfile="born.log", + **kwargs, +): + """ + Make a Born repulsive potential to get the interpolation from NEB + optimization. + """ + from .improvedneb import ImprovedTangentNEB + from ...regression.gp.baseline import BornRepulsionCalculator + + # Use Repulsive potential as calculator + for image in images[1:-1]: + image.calc = BornRepulsionCalculator(mic=mic, **calc_kwargs) # Make default NEB neb = ImprovedTangentNEB(images) # Set local optimizer arguments - local_kwargs_default = dict(trajectory="rep.traj", logfile="rep.log") + local_kwargs_default = dict(trajectory=trajectory, logfile=logfile) if isinstance(local_opt, FIRE): local_kwargs_default.update( dict(dt=0.05, a=1.0, astart=1.0, fa=0.999, maxstep=0.2) @@ -256,7 +346,7 @@ def make_end_interpolations(images, mic=False, trust_dist=0.2, **kwargs): if mic: dist = find_mic(dist, images[0].get_cell(), images[0].pbc)[0] # Calculate the scaled distance - scale_dist = 2.0 * trust_dist / np.linalg.norm(dist) + scale_dist = 2.0 * trust_dist / norm(dist) # Check if the distance is within the trust distance if scale_dist >= 1.0: # Calculate the distance moved for each image @@ -283,7 +373,5 @@ def get_images_distance(images): "Get the cumulative distacnce of the images." dis = 0.0 for i in range(len(images) - 1): - dis += np.linalg.norm( - images[i + 1].get_positions() - images[i].get_positions() - ) + dis += norm(images[i + 1].get_positions() - images[i].get_positions()) return dis From 9917347ad7ff503cd5db30dba9b7aef061920977 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Wed, 21 May 2025 09:57:16 +0200 Subject: [PATCH 119/194] Remove find_mic --- catlearn/structures/neb/interpolate_band.py | 43 ++++++++++++--------- 1 file changed, 25 insertions(+), 18 deletions(-) diff --git a/catlearn/structures/neb/interpolate_band.py b/catlearn/structures/neb/interpolate_band.py index db4c1d61..530db5dd 100644 --- a/catlearn/structures/neb/interpolate_band.py +++ b/catlearn/structures/neb/interpolate_band.py @@ -1,10 +1,11 @@ from numpy import ndarray from numpy.linalg import norm from ase.io import read -from ase.geometry import find_mic from ase.optimize import FIRE from ase.build import minimize_rotation_and_translation +from .improvedneb import ImprovedTangentNEB from ...regression.gp.calculator.copy_atoms import copy_atoms +from ...regression.gp.fingerprint.geometry import mic_distance def interpolate( @@ -202,15 +203,20 @@ def make_linear_interpolation(images, mic=False, **kwargs): # Get the position of initial state pos0 = images[0].get_positions() # Get the distance to the final state - dist = images[-1].get_positions() - pos0 + dist_vec = images[-1].get_positions() - pos0 # Calculate the minimum-image convention if mic=True if mic: - dist = find_mic(dist, images[0].get_cell(), images[0].pbc)[0] + _, dist_vec = mic_distance( + dist_vec, + cell=images[0].get_cell(), + pbc=images[0].pbc, + use_vector=True, + ) # Calculate the distance moved for each image - dist = dist / float(len(images) - 1) + dist_vec = dist_vec / float(len(images) - 1) # Set the positions for i in range(1, len(images) - 1): - images[i].set_positions(pos0 + (i * dist)) + images[i].set_positions(pos0 + (i * dist_vec)) return images @@ -227,7 +233,7 @@ def make_idpp_interpolation( Make the IDPP interpolation from initial to final state from NEB optimization. """ - from .improvedneb import ImprovedTangentNEB + from ...regression.gp.baseline import IDPP # Get all distances in the system @@ -270,7 +276,6 @@ def make_rep_interpolation( """ Make a repulsive potential to get the interpolation from NEB optimization. """ - from .improvedneb import ImprovedTangentNEB from ...regression.gp.baseline import RepulsionCalculator # Use Repulsive potential as calculator @@ -307,7 +312,6 @@ def make_born_interpolation( Make a Born repulsive potential to get the interpolation from NEB optimization. """ - from .improvedneb import ImprovedTangentNEB from ...regression.gp.baseline import BornRepulsionCalculator # Use Repulsive potential as calculator @@ -334,38 +338,41 @@ def make_end_interpolations(images, mic=False, trust_dist=0.2, **kwargs): but place the images at the initial and final states with the maximum distance as trust_dist. """ - from ase.geometry import find_mic - # Get the number of images n_images = len(images) # Get the position of initial state pos0 = images[0].get_positions() # Get the distance to the final state - dist = images[-1].get_positions() - pos0 + dist_vec = images[-1].get_positions() - pos0 # Calculate the minimum-image convention if mic=True if mic: - dist = find_mic(dist, images[0].get_cell(), images[0].pbc)[0] + _, dist_vec = mic_distance( + dist_vec, + cell=images[0].get_cell(), + pbc=images[0].pbc, + use_vector=True, + ) # Calculate the scaled distance - scale_dist = 2.0 * trust_dist / norm(dist) + scale_dist = 2.0 * trust_dist / norm(dist_vec) # Check if the distance is within the trust distance if scale_dist >= 1.0: # Calculate the distance moved for each image - dist = dist / float(n_images - 1) + dist_vec = dist_vec / float(n_images - 1) # Set the positions for i in range(1, n_images - 1): - images[i].set_positions(pos0 + (i * dist)) + images[i].set_positions(pos0 + (i * dist_vec)) return images # Calculate the distance moved for each image - dist = dist * (scale_dist / float(n_images - 1)) + dist_vec = dist_vec * (scale_dist / float(n_images - 1)) # Get the position of final state posn = images[-1].get_positions() # Set the positions nfirst = int(0.5 * (n_images - 1)) for i in range(1, n_images - 1): if i <= nfirst: - images[i].set_positions(pos0 + (i * dist)) + images[i].set_positions(pos0 + (i * dist_vec)) else: - images[i].set_positions(posn - ((n_images - 1 - i) * dist)) + images[i].set_positions(posn - ((n_images - 1 - i) * dist_vec)) return images From 554877f853ef5fb23317992d15ca53a29a9a7718 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Thu, 22 May 2025 14:26:14 +0200 Subject: [PATCH 120/194] Update to the structures and NEB --- catlearn/structures/neb/avgewneb.py | 17 ++- catlearn/structures/neb/ewneb.py | 57 ++++++++-- catlearn/structures/neb/improvedneb.py | 24 ++-- catlearn/structures/neb/maxewneb.py | 52 ++++++++- catlearn/structures/neb/orgneb.py | 146 ++++++++++++++++--------- catlearn/structures/structure.py | 4 +- 6 files changed, 222 insertions(+), 78 deletions(-) diff --git a/catlearn/structures/neb/avgewneb.py b/catlearn/structures/neb/avgewneb.py index a3faac11..18c3cd49 100644 --- a/catlearn/structures/neb/avgewneb.py +++ b/catlearn/structures/neb/avgewneb.py @@ -1,24 +1,31 @@ -import numpy as np +from numpy import where from .ewneb import EWNEB class AvgEWNEB(EWNEB): + """ + The average energy-weighted Nudged Elastic Band method implementation. + The energy-weighted method uses energy weighting to calculate the + spring constants. + The average weigting for both ends of the spring are used + instead of the forward energy weighting. + """ def get_spring_constants(self, **kwargs): # Get the spring constants energies = self.get_energies() # Get the reference energy if self.use_minimum: - e0 = np.min([energies[0], energies[-1]]) + e0 = min([energies[0], energies[-1]]) else: - e0 = np.max([energies[0], energies[-1]]) + e0 = max([energies[0], energies[-1]]) # Get the maximum energy - emax = np.max(energies) + emax = energies.max() # Calculate the weighted spring constants k_l = self.k * self.kl_scale if e0 < emax: a = (emax - energies) / (emax - e0) - a = np.where(a < 1.0, a, 1.0) + a = where(a < 1.0, a, 1.0) a = 0.5 * (a[1:] + a[:-1]) k = ((1.0 - a) * self.k) + (a * k_l) else: diff --git a/catlearn/structures/neb/ewneb.py b/catlearn/structures/neb/ewneb.py index 2cbe2041..a20878e8 100644 --- a/catlearn/structures/neb/ewneb.py +++ b/catlearn/structures/neb/ewneb.py @@ -1,8 +1,17 @@ -import numpy as np +from numpy import where +from ase.parallel import world from .improvedneb import ImprovedTangentNEB class EWNEB(ImprovedTangentNEB): + """ + The energy-weighted Nudged Elastic Band method implementation. + The energy-weighted method uses energy weighting to calculate the spring + constants. + See: + https://doi.org/10.1021/acs.jctc.1c00462 + """ + def __init__( self, images, @@ -14,9 +23,43 @@ def __init__( mic=True, save_properties=False, parallel=False, - world=None, + comm=world, **kwargs ): + """ + Initialize the NEB instance. + + Parameters: + images: List of ASE Atoms instances + The ASE Atoms instances used as the images of the initial path + that is optimized. + k: List of floats or float + The (Nimg-1) spring forces acting between each image. + In the energy-weighted Nudged Elastic Band method, this spring + constants are the upper spring constants. + kl_scale: float + The scaling factor for the lower spring constants. + use_minimum: bool + Whether to use the minimum energy as the reference energy + for the spring constants. + If False, the maximum energy is used. + climb: bool + Whether to use climbing image in the NEB. + See: + https://doi.org/10.1063/1.1329672 + remove_rotation_and_translation: bool + Whether to remove rotation and translation in interpolation + and when predicting forces. + mic: bool + Minimum Image Convention (Shortest distances when + periodic boundary conditions are used). + save_properties: bool + Whether to save the properties by making a copy of the images. + parallel: bool + Whether to run the calculations in parallel. + comm: ASE communicator instance + The communicator instance for parallelization. + """ super().__init__( images, k=k, @@ -25,7 +68,7 @@ def __init__( mic=mic, save_properties=save_properties, parallel=parallel, - world=world, + comm=comm, **kwargs ) self.kl_scale = kl_scale @@ -36,16 +79,16 @@ def get_spring_constants(self, **kwargs): energies = self.get_energies() # Get the reference energy if self.use_minimum: - e0 = np.min([energies[0], energies[-1]]) + e0 = min([energies[0], energies[-1]]) else: - e0 = np.max([energies[0], energies[-1]]) + e0 = max([energies[0], energies[-1]]) # Get the maximum energy - emax = np.max(energies) + emax = energies.max() # Calculate the weighted spring constants k_l = self.k * self.kl_scale if e0 < emax: a = (emax - energies[:-1]) / (emax - e0) - k = np.where(a < 1.0, (1.0 - a) * self.k + a * k_l, k_l) + k = where(a < 1.0, (1.0 - a) * self.k + a * k_l, k_l) else: k = k_l return k diff --git a/catlearn/structures/neb/improvedneb.py b/catlearn/structures/neb/improvedneb.py index c00f1f69..6df03014 100644 --- a/catlearn/structures/neb/improvedneb.py +++ b/catlearn/structures/neb/improvedneb.py @@ -1,20 +1,28 @@ -import numpy as np +from numpy import einsum, empty, sqrt +from numpy.linalg import norm from .orgneb import OriginalNEB class ImprovedTangentNEB(OriginalNEB): + """ + The improved tangent Nudged Elastic Band method implementation. + The improved tangent method uses energy weighting to calculate the tangent. + + See: + https://doi.org/10.1063/1.1323224 + """ def get_parallel_forces(self, tangent, pos_p, pos_m, **kwargs): # Get the spring constants k = self.get_spring_constants() # Calculate the parallel forces - forces_parallel = k[1:] * np.linalg.norm(pos_p, axis=(1, 2)) - forces_parallel -= k[:-1] * np.linalg.norm(pos_m, axis=(1, 2)) + forces_parallel = k[1:] * sqrt(einsum("ijk,ijk->i", pos_p, pos_p)) + forces_parallel -= k[:-1] * sqrt(einsum("ijk,ijk->i", pos_m, pos_m)) forces_parallel = forces_parallel.reshape(-1, 1, 1) * tangent return forces_parallel def get_tangent(self, pos_p, pos_m, **kwargs): - tangent = np.empty((int(self.nimages - 2), self.natoms, 3)) + tangent = empty((int(self.nimages - 2), self.natoms, 3)) energies = self.get_energies() for i in range(1, self.nimages - 1): if energies[i + 1] > energies[i] and energies[i] > energies[i - 1]: @@ -38,9 +46,11 @@ def get_tangent(self, pos_p, pos_m, **kwargs): tangent[i - 1] = pos_p[i - 1] * min(energy_dif) tangent[i - 1] += pos_m[i - 1] * max(energy_dif) else: - tangent[i - 1] = pos_p[i - 1] / np.linalg.norm(pos_p[i - 1]) - tangent[i - 1] += pos_m[i - 1] / np.linalg.norm(pos_m[i - 1]) + tangent[i - 1] = pos_p[i - 1] / norm(pos_p[i - 1]) + tangent[i - 1] += pos_m[i - 1] / norm(pos_m[i - 1]) # Normalization of tangent - tangent_norm = np.linalg.norm(tangent, axis=(1, 2)).reshape(-1, 1, 1) + tangent_norm = sqrt(einsum("ijk,ijk->i", tangent, tangent)).reshape( + -1, 1, 1 + ) tangent = tangent / tangent_norm return tangent diff --git a/catlearn/structures/neb/maxewneb.py b/catlearn/structures/neb/maxewneb.py index 2fbab244..216e075b 100644 --- a/catlearn/structures/neb/maxewneb.py +++ b/catlearn/structures/neb/maxewneb.py @@ -1,8 +1,17 @@ -import numpy as np +from numpy import where +from ase.parallel import world from .improvedneb import ImprovedTangentNEB class MaxEWNEB(ImprovedTangentNEB): + """ + The maximum energy-weighted Nudged Elastic Band method implementation. + The energy-weighted method uses energy weighting to calculate the + spring constants. + The maximum energy subtracted by the energy difference (dE) is used as + the reference energy for the spring constants. + """ + def __init__( self, images, @@ -14,9 +23,42 @@ def __init__( mic=True, save_properties=False, parallel=False, - world=None, + comm=world, **kwargs ): + """ + Initialize the NEB instance. + + Parameters: + images: List of ASE Atoms instances + The ASE Atoms instances used as the images of the initial path + that is optimized. + k: List of floats or float + The (Nimg-1) spring forces acting between each image. + In the energy-weighted Nudged Elastic Band method, this spring + constants are the upper spring constants. + kl_scale: float + The scaling factor for the lower spring constants. + dE: float + The energy difference between the maximum energy + and the used reference energy. + climb: bool + Whether to use climbing image in the NEB. + See: + https://doi.org/10.1063/1.1329672 + remove_rotation_and_translation: bool + Whether to remove rotation and translation in interpolation + and when predicting forces. + mic: bool + Minimum Image Convention (Shortest distances when + periodic boundary conditions are used). + save_properties: bool + Whether to save the properties by making a copy of the images. + parallel: bool + Whether to run the calculations in parallel. + comm: ASE communicator instance + The communicator instance for parallelization. + """ super().__init__( images, k=k, @@ -25,7 +67,7 @@ def __init__( mic=mic, save_properties=save_properties, parallel=parallel, - world=world, + comm=comm, **kwargs ) self.kl_scale = kl_scale @@ -35,14 +77,14 @@ def get_spring_constants(self, **kwargs): # Get the spring constants energies = self.get_energies() # Get the maximum energy - emax = np.max(energies) + emax = energies.max() # Calculate the reference energy e0 = emax - self.dE # Calculate the weighted spring constants k_l = self.k * self.kl_scale if e0 < emax: a = (emax - energies[:-1]) / (emax - e0) - k = np.where(a < 1.0, (1.0 - a) * self.k + a * k_l, k_l) + k = where(a < 1.0, (1.0 - a) * self.k + a * k_l, k_l) else: k = k_l return k diff --git a/catlearn/structures/neb/orgneb.py b/catlearn/structures/neb/orgneb.py index c8d76108..15caceda 100644 --- a/catlearn/structures/neb/orgneb.py +++ b/catlearn/structures/neb/orgneb.py @@ -1,12 +1,33 @@ -import numpy as np +from numpy import ( + argmax, + array, + asarray, + einsum, + full, + nanmax, + sqrt, + vdot, + zeros, +) from ase.calculators.singlepoint import SinglePointCalculator from ase.build import minimize_rotation_and_translation -from ase.parallel import broadcast +from ase.parallel import world, broadcast +import warnings +from .interpolate_band import interpolate from ..structure import Structure from ...regression.gp.fingerprint.geometry import mic_distance +from ...regression.gp.calculator.copy_atoms import compare_atoms class OriginalNEB: + """ + The orginal Nudged Elastic Band method implementation for the tangent + and parallel force. + + See: + https://doi.org/10.1142/9789812839664_0016 + """ + def __init__( self, images, @@ -16,12 +37,11 @@ def __init__( mic=True, save_properties=False, parallel=False, - world=None, + comm=world, **kwargs ): """ - The orginal Nudged Elastic Band method implementation for the tangent - and parallel force. + Initialize the NEB instance. Parameters: images: List of ASE Atoms instances @@ -31,6 +51,8 @@ def __init__( The (Nimg-1) spring forces acting between each image. climb: bool Whether to use climbing image in the NEB. + See: + https://doi.org/10.1063/1.1329672 remove_rotation_and_translation: bool Whether to remove rotation and translation in interpolation and when predicting forces. @@ -41,12 +63,12 @@ def __init__( Whether to save the properties by making a copy of the images. parallel: bool Whether to run the calculations in parallel. - world: ASE communicator instance + comm: ASE communicator instance The communicator instance for parallelization. """ # Check that the endpoints are the same - self.check_endpoints(images) + self.check_images(images) # Set images if save_properties: self.images = [Structure(image) for image in images] @@ -56,7 +78,7 @@ def __init__( self.natoms = len(images[0]) # Set the spring constant if isinstance(k, (int, float)): - self.k = np.full(self.nimages - 1, k) + self.k = full(self.nimages - 1, k) else: self.k = k.copy() # Set the parameters @@ -67,34 +89,37 @@ def __init__( # Set the parallelization self.parallel = parallel if parallel: - if world is None: - from ase.parallel import world - - self.world = world - if (self.nimages - 2) % self.world.size != 0: - if self.world.rank == 0: - print( - "Warning: The number of moving images are not chosen " + self.parallel_setup(comm) + if (self.nimages - 2) % self.size != 0: + if self.rank == 0: + warnings.warn( + "The number of moving images are not chosen " "optimal for the number of processors when running in " "parallel!" ) else: - self.world = None + self.remove_parallel_setup() # Set the properties self.reset() - def check_endpoints(self, images): - "Check that the endpoints of the images are the same structures." - initial_atomic_numbers = images[0].get_atomic_numbers() - final_atomic_numbers = images[-1].get_atomic_numbers() - if ( - len(initial_atomic_numbers) != len(final_atomic_numbers) - or (initial_atomic_numbers != final_atomic_numbers).any() - ): - raise ValueError( - "The atoms in the initial and final images " - "are not the same." - ) + def check_images( + self, + images, + properties_to_check=["atoms", "cell", "pbc"], + ): + "Check that the images are the same structures." + ends_equal = compare_atoms( + images[0], + images[-1], + properties_to_check=properties_to_check, + ) + ends_move_equal = compare_atoms( + images[0], + images[1], + properties_to_check=properties_to_check, + ) + if not (ends_equal and ends_move_equal): + raise ValueError("The images are not the same structures.") return self def interpolate(self, method="linear", mic=True, **kwargs): @@ -111,8 +136,6 @@ def interpolate(self, method="linear", mic=True, **kwargs): Returns: self: The instance itself. """ - from .interpolate_band import interpolate - self.images = interpolate( self.images[0], self.images[-1], @@ -132,7 +155,7 @@ def get_positions(self): ((Nimg-2)*Natoms,3) array: Coordinates of all atoms in all the moving images. """ - positions = np.array( + positions = array( [image.get_positions() for image in self.images[1:-1]] ) return positions.reshape(-1, 3) @@ -205,12 +228,12 @@ def get_image_positions(self): ((Nimg),Natoms,3) array: The positions for all atoms in all the images. """ - return np.array([image.get_positions() for image in self.images]) + return asarray([image.get_positions() for image in self.images]) def get_climb_forces(self, forces_new, forces, tangent, **kwargs): "Get the forces of the climbing image." - i_max = np.argmax(self.get_energies()[1:-1]) - forces_parallel = 2.0 * np.vdot(forces[i_max], tangent[i_max]) + i_max = argmax(self.get_energies()[1:-1]) + forces_parallel = 2.0 * vdot(forces[i_max], tangent[i_max]) forces_parallel = forces_parallel * tangent[i_max] forces_new[i_max] = forces[i_max] - forces_parallel return forces_new @@ -230,8 +253,8 @@ def get_energies(self, **kwargs): def calculate_properties(self, **kwargs): "Calculate the energy and forces for each image." # Initialize the arrays - self.real_forces = np.zeros((self.nimages, self.natoms, 3)) - self.energies = np.zeros((self.nimages)) + self.real_forces = zeros((self.nimages, self.natoms, 3)) + self.energies = zeros((self.nimages)) # Get the energy of the fixed images self.energies[0] = self.images[0].get_potential_energy() self.energies[-1] = self.images[-1].get_potential_energy() @@ -240,7 +263,7 @@ def calculate_properties(self, **kwargs): return self.calculate_properties_parallel(**kwargs) # Calculate the energy and forces for each image for i, image in enumerate(self.images[1:-1]): - self.real_forces[i + 1] = image.get_forces().copy() + self.real_forces[i + 1] = image.get_forces() self.energies[i + 1] = image.get_potential_energy() return self.energies, self.real_forces @@ -248,22 +271,22 @@ def calculate_properties_parallel(self, **kwargs): "Calculate the energy and forces for each image in parallel." # Calculate the energy and forces for each image for i, image in enumerate(self.images[1:-1]): - if self.world.rank == (i % self.world.size): - self.real_forces[i + 1] = image.get_forces().copy() + if self.rank == (i % self.size): + self.real_forces[i + 1] = image.get_forces() self.energies[i + 1] = image.get_potential_energy() # Broadcast the results for i in range(1, self.nimages - 1): - root = (i - 1) % self.world.size + root = (i - 1) % self.size self.energies[i], self.real_forces[i] = broadcast( (self.energies[i], self.real_forces[i]), root=root, - comm=self.world, + comm=self.comm, ) return self.energies, self.real_forces def emax(self, **kwargs): "Get maximum energy of the moving images." - return np.nanmax(self.get_energies(**kwargs)[1:-1]) + return nanmax(self.get_energies(**kwargs)[1:-1]) def get_parallel_forces(self, tangent, pos_p, pos_m, **kwargs): "Get the parallel forces between the images." @@ -289,29 +312,31 @@ def get_position_diff(self): """ positions = self.get_image_positions() position_diff = positions[1:] - positions[:-1] - pbc = np.array(self.images[0].get_pbc()) + pbc = asarray(self.images[0].get_pbc()) if self.mic and pbc.any(): - cell = np.array(self.images[0].get_cell()) - position_diff = mic_distance( + cell = asarray(self.images[0].get_cell()) + _, position_diff = mic_distance( position_diff, cell, pbc, vector=True, - )[1] + ) return position_diff[1:], position_diff[:-1] def get_tangent(self, pos_p, pos_m, **kwargs): "Calculate the tangent to the moving images." # Normalization factors - pos_m_norm = np.linalg.norm(pos_m, axis=(1, 2)).reshape(-1, 1, 1) - pos_p_norm = np.linalg.norm(pos_p, axis=(1, 2)).reshape(-1, 1, 1) + pos_m_norm = sqrt(einsum("ijk,ijk->i", pos_m, pos_m)).reshape(-1, 1, 1) + pos_p_norm = sqrt(einsum("ijk,ijk->i", pos_p, pos_p)).reshape(-1, 1, 1) # Normalization of tangent tangent_m = pos_m / pos_m_norm tangent_p = pos_p / pos_p_norm # Sum them tangent = tangent_m + tangent_p # Normalization of tangent - tangent_norm = np.linalg.norm(tangent, axis=(1, 2)).reshape(-1, 1, 1) + tangent_norm = sqrt(einsum("ijk,ijk->i", tangent, tangent)).reshape( + -1, 1, 1 + ) tangent = tangent / tangent_norm return tangent @@ -325,10 +350,27 @@ def reset(self): self.real_forces = None return self + def parallel_setup(self, comm, **kwargs): + "Setup the parallelization." + if comm is None: + self.comm = world + else: + self.comm = comm + self.rank = self.comm.rank + self.size = self.comm.size + return self + + def remove_parallel_setup(self): + "Remove the parallelization by removing the communicator." + self.comm = None + self.rank = 0 + self.size = 1 + return self + def get_residual(self, **kwargs): "Get the residual of the NEB." forces = self.get_forces() - return np.max(np.linalg.norm(forces, axis=-1)) + return sqrt(einsum("ij,ij->i", forces, forces)).max() def set_calculator(self, calculators, copy_calc=False, **kwargs): """ @@ -371,7 +413,7 @@ def calc(self, calculators): return self.set_calculator(calculators) def converged(self, forces, fmax): - return np.linalg.norm(forces, axis=1).max() < fmax + return sqrt(einsum("ij,ij->i", forces, forces)).max() < fmax def is_neb(self): return True diff --git a/catlearn/structures/structure.py b/catlearn/structures/structure.py index cf39fa68..0a3b09b2 100644 --- a/catlearn/structures/structure.py +++ b/catlearn/structures/structure.py @@ -1,4 +1,4 @@ -import numpy as np +from numpy import einsum, sqrt from ase import Atoms from ..regression.gp.calculator.copy_atoms import copy_atoms @@ -114,7 +114,7 @@ def get_uncertainty(self, *args, **kwargs): return unc def converged(self, forces, fmax): - return np.linalg.norm(forces, axis=1).max() < fmax + return sqrt(einsum("ij,ij->i", forces, forces)).max() < fmax def is_neb(self): return False From bdb7cdcf927a1486965d5c14aa2fa8eccd0535ed Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Fri, 23 May 2025 10:36:24 +0200 Subject: [PATCH 121/194] New version --- catlearn/_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/catlearn/_version.py b/catlearn/_version.py index e908fe0b..8be5e30b 100644 --- a/catlearn/_version.py +++ b/catlearn/_version.py @@ -1,3 +1,3 @@ -__version__ = "6.1.0" +__version__ = "7.0.0" __all__ = ["__version__"] From bc8c8894a43fde7a277e757dc2bef0ea49e1023e Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Tue, 3 Jun 2025 16:18:47 +0200 Subject: [PATCH 122/194] Set arguments --- catlearn/regression/gp/calculator/mlcalc.py | 28 +++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/catlearn/regression/gp/calculator/mlcalc.py b/catlearn/regression/gp/calculator/mlcalc.py index 044cb360..64d04233 100644 --- a/catlearn/regression/gp/calculator/mlcalc.py +++ b/catlearn/regression/gp/calculator/mlcalc.py @@ -446,6 +446,34 @@ def set_dtype(self, dtype, **kwargs): self.mlmodel.set_dtype(dtype, **kwargs) return self + def set_use_fingerprint(self, use_fingerprint, **kwargs): + """ + Set whether to use fingerprints in the model and database. + + Parameters: + use_fingerprint: bool + Whether to use fingerprints in the model and database. + + Returns: + self: The updated object itself. + """ + self.mlmodel.set_use_fingerprint(use_fingerprint=use_fingerprint) + return self + + def set_use_derivatives(self, use_derivatives, **kwargs): + """ + Set whether to use derivatives in the model and database. + + Parameters: + use_derivatives: bool + Whether to use derivatives in the model and database. + + Returns: + self: The updated object itself. + """ + self.mlmodel.set_use_derivatives(use_derivatives=use_derivatives) + return self + def get_property_arguments(self, properties=[], **kwargs): """ Get the arguments that ensure calculations of the properties requested. From 7abddbe1d52040ff44436676a7f8887f14abdbb0 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Tue, 3 Jun 2025 16:19:32 +0200 Subject: [PATCH 123/194] Flatten list --- catlearn/regression/gp/hpboundary/boundary.py | 9 +++++---- catlearn/regression/gp/hpboundary/hptrans.py | 8 +++++--- catlearn/regression/gp/hpboundary/updatebounds.py | 12 +++++------- catlearn/regression/gp/hpfitter/hpfitter.py | 8 ++++---- 4 files changed, 19 insertions(+), 18 deletions(-) diff --git a/catlearn/regression/gp/hpboundary/boundary.py b/catlearn/regression/gp/hpboundary/boundary.py index e4fc9981..41cd93d8 100644 --- a/catlearn/regression/gp/hpboundary/boundary.py +++ b/catlearn/regression/gp/hpboundary/boundary.py @@ -351,10 +351,11 @@ def initiate_bounds_dict(self, bounds_dict, **kwargs): self.bounds_dict.pop("correction") # Extract the hyperparameter names self.parameters_set = sorted(bounds_dict.keys()) - self.parameters = sum( - [[para] * len(bounds_dict[para]) for para in self.parameters_set], - [], - ) + self.parameters = [ + para + for para in self.parameters_set + for _ in range(len(bounds_dict[para])) + ] return self def make_parameters_set(self, parameters, **kwargs): diff --git a/catlearn/regression/gp/hpboundary/hptrans.py b/catlearn/regression/gp/hpboundary/hptrans.py index e85500d9..cf000410 100644 --- a/catlearn/regression/gp/hpboundary/hptrans.py +++ b/catlearn/regression/gp/hpboundary/hptrans.py @@ -578,9 +578,11 @@ def initiate_var_dict(self, var_dict, **kwargs): self.var_dict.pop("correction") # Extract the hyperparameters self.parameters_set = sorted(var_dict.keys()) - self.parameters = sum( - [[para] * len(var_dict[para]) for para in self.parameters_set], [] - ) + self.parameters = [ + para + for para in self.parameters_set + for _ in range(len(var_dict[para])) + ] return self def initiate_bounds_dict(self, bounds, **kwargs): diff --git a/catlearn/regression/gp/hpboundary/updatebounds.py b/catlearn/regression/gp/hpboundary/updatebounds.py index 68b06a98..02cbdca6 100644 --- a/catlearn/regression/gp/hpboundary/updatebounds.py +++ b/catlearn/regression/gp/hpboundary/updatebounds.py @@ -212,13 +212,11 @@ def initiate_bounds_dict(self, bounds, **kwargs): self.bounds_dict = self.bounds.get_bounds(use_array=False) # Extract the hyperparameter names self.parameters_set = sorted(self.bounds_dict.keys()) - self.parameters = sum( - [ - [para] * len(self.bounds_dict[para]) - for para in self.parameters_set - ], - [], - ) + self.parameters = [ + para + for para in self.parameters_set + for _ in range(len(self.bounds_dict[para])) + ] # Make sure log-scale of the hyperparameters are used if self.bounds.use_log is False: raise ValueError( diff --git a/catlearn/regression/gp/hpfitter/hpfitter.py b/catlearn/regression/gp/hpfitter/hpfitter.py index 6d273ad8..1e5c7bd7 100644 --- a/catlearn/regression/gp/hpfitter/hpfitter.py +++ b/catlearn/regression/gp/hpfitter/hpfitter.py @@ -262,10 +262,10 @@ def hp_to_theta(self, hp): a list of hyperparameter names. """ parameters_set = sorted(hp.keys()) - theta = sum([list(hp[para]) for para in parameters_set], []) - parameters = sum( - [[para] * len(hp[para]) for para in parameters_set], [] - ) + theta = [hp_v for para in parameters_set for hp_v in hp[para]] + parameters = [ + para for para in parameters_set for _ in range(len(hp[para])) + ] return asarray(theta), parameters def update_bounds(self, model, X, Y, parameters, **kwargs): From 0450e2dcae9082d78e097694c41e65d3a581cede Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Tue, 22 Jul 2025 09:35:33 +0200 Subject: [PATCH 124/194] Enable saving of model --- catlearn/regression/gp/calculator/mlcalc.py | 17 +++--- catlearn/regression/gp/calculator/mlmodel.py | 57 ++++++++++++++++++++ 2 files changed, 64 insertions(+), 10 deletions(-) diff --git a/catlearn/regression/gp/calculator/mlcalc.py b/catlearn/regression/gp/calculator/mlcalc.py index 64d04233..9d698f76 100644 --- a/catlearn/regression/gp/calculator/mlcalc.py +++ b/catlearn/regression/gp/calculator/mlcalc.py @@ -1,5 +1,6 @@ from numpy import round as round_ from ase.calculators.calculator import Calculator, all_changes +import pickle from .mlmodel import MLModel @@ -315,34 +316,30 @@ def calculate( def save_mlcalc(self, filename="mlcalc.pkl", **kwargs): """ - Save the ML calculator object to a file. + Save the ML calculator instance to a file. Parameters: filename: str - The name of the file where the object is saved. + The name of the file where the instance is saved. Returns: - self: The object itself. + self: The instance itself. """ - import pickle - with open(filename, "wb") as file: pickle.dump(self, file) return self def load_mlcalc(self, filename="mlcalc.pkl", **kwargs): """ - Load the ML calculator object from a file. + Load the ML calculator instance from a file. Parameters: filename: str - The name of the file where the object is saved. + The name of the file where the instance is saved. Returns: - mlcalc: The loaded ML calculator object. + mlcalc: The loaded ML calculator instance. """ - import pickle - with open(filename, "rb") as file: mlcalc = pickle.load(file) return mlcalc diff --git a/catlearn/regression/gp/calculator/mlmodel.py b/catlearn/regression/gp/calculator/mlmodel.py index 2626ad9f..abfa61e5 100644 --- a/catlearn/regression/gp/calculator/mlmodel.py +++ b/catlearn/regression/gp/calculator/mlmodel.py @@ -1,6 +1,7 @@ from numpy import asarray, ndarray, sqrt, zeros import warnings from ase.parallel import parprint +import pickle class MLModel: @@ -18,6 +19,8 @@ def __init__( hp=None, pdis=None, include_noise=False, + to_save_mlmodel=False, + save_mlmodel_kwargs={}, verbose=False, dtype=float, **kwargs, @@ -44,6 +47,10 @@ def __init__( A dict of prior distributions for each hyperparameter type. include_noise: bool Whether to include noise in the uncertainty from the model. + to_save_mlmodel: bool + Whether to save the ML model to a file after training. + save_mlmodel_kwargs: dict + Arguments for saving the ML model, like the filename. verbose: bool Whether to print statements in the optimization. dtype: type @@ -64,6 +71,8 @@ def __init__( hp=hp, pdis=pdis, include_noise=include_noise, + to_save_mlmodel=to_save_mlmodel, + save_mlmodel_kwargs=save_mlmodel_kwargs, verbose=verbose, dtype=dtype, **kwargs, @@ -105,6 +114,9 @@ def train_model(self, **kwargs): else: # Train the ML model self.model_training(features, targets, **kwargs) + # Save the ML model to a file if requested + if self.to_save_mlmodel: + self.save_mlmodel(**self.save_mlmodel_kwargs) return self def calculate( @@ -249,6 +261,36 @@ def update_database_arguments(self, point_interest=None, **kwargs): self.database.update_arguments(point_interest=point_interest, **kwargs) return self + def save_mlmodel(self, filename="mlmodel.pkl", **kwargs): + """ + Save the ML model instance to a file. + + Parameters: + filename: str + The name of the file where the instance is saved. + + Returns: + self: The instance itself. + """ + with open(filename, "wb") as file: + pickle.dump(self, file) + return self + + def load_mlmodel(self, filename="mlmodel.pkl", **kwargs): + """ + Load the ML model instance from a file. + + Parameters: + filename: str + The name of the file where the instance is saved. + + Returns: + mlcalc: The loaded ML model instance. + """ + with open(filename, "rb") as file: + mlmodel = pickle.load(file) + return mlmodel + def update_arguments( self, model=None, @@ -258,6 +300,8 @@ def update_arguments( hp=None, pdis=None, include_noise=None, + to_save_mlmodel=None, + save_mlmodel_kwargs=None, verbose=None, dtype=None, **kwargs, @@ -285,6 +329,10 @@ def update_arguments( A dict of prior distributions for each hyperparameter type. include_noise: bool Whether to include noise in the uncertainty from the model. + to_save_mlmodel: bool + Whether to save the ML model to a file after training. + save_mlmodel_kwargs: dict + Arguments for saving the ML model, like the filename. verbose: bool Whether to print statements in the optimization. dtype: type @@ -313,6 +361,10 @@ def update_arguments( self.pdis = None if include_noise is not None: self.include_noise = include_noise + if to_save_mlmodel is not None: + self.to_save_mlmodel = to_save_mlmodel + if save_mlmodel_kwargs is not None: + self.save_mlmodel_kwargs = save_mlmodel_kwargs if verbose is not None: self.verbose = verbose if dtype is not None or not hasattr(self, "dtype"): @@ -331,6 +383,7 @@ def update_arguments( def model_optimization(self, features, targets, **kwargs): "Optimize the ML model with the arguments set in optimize_kwargs." + # Optimize the hyperparameters and train the ML model sol = self.model.optimize( features, targets, @@ -340,6 +393,7 @@ def model_optimization(self, features, targets, **kwargs): verbose=False, **kwargs, ) + # Print the solution if verbose is True if self.verbose: parprint(sol) return self.model @@ -680,6 +734,8 @@ def get_arguments(self): hp=self.hp, pdis=self.pdis, include_noise=self.include_noise, + to_save_mlmodel=self.to_save_mlmodel, + save_mlmodel_kwargs=self.save_mlmodel_kwargs, verbose=self.verbose, dtype=self.dtype, ) @@ -1129,4 +1185,5 @@ def get_default_mlmodel( pdis=pdis, verbose=verbose, dtype=dtype, + **kwargs, ) From 556594dafb27b53d0a6146bdab63549dd5874bbe Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Tue, 22 Jul 2025 09:37:08 +0200 Subject: [PATCH 125/194] Write hyperparameters in solution --- catlearn/regression/gp/calculator/mlmodel.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/catlearn/regression/gp/calculator/mlmodel.py b/catlearn/regression/gp/calculator/mlmodel.py index abfa61e5..f5988de2 100644 --- a/catlearn/regression/gp/calculator/mlmodel.py +++ b/catlearn/regression/gp/calculator/mlmodel.py @@ -395,6 +395,16 @@ def model_optimization(self, features, targets, **kwargs): ) # Print the solution if verbose is True if self.verbose: + # Get the prefactor if it is available + if hasattr(self.model, "get_prefactor"): + sol["prefactor"] = float( + "{:.3e}".format(self.model.get_prefactor()) + ) + # Get the noise correction if it is available + if hasattr(self.model, "get_correction"): + sol["correction"] = float( + "{:.3e}".format(self.model.get_correction()) + ) parprint(sol) return self.model From 0d1d0fbca83339bd8c253896e852665e98a05523 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Tue, 22 Jul 2025 09:49:26 +0200 Subject: [PATCH 126/194] Change prior distribution of hp back --- catlearn/regression/gp/calculator/mlmodel.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/catlearn/regression/gp/calculator/mlmodel.py b/catlearn/regression/gp/calculator/mlmodel.py index f5988de2..30c99321 100644 --- a/catlearn/regression/gp/calculator/mlmodel.py +++ b/catlearn/regression/gp/calculator/mlmodel.py @@ -1181,8 +1181,8 @@ def get_default_mlmodel( from ..pdistributions.normal import Normal_prior pdis = dict( - length=Normal_prior(mu=[-1.0], std=[0.1], dtype=dtype), - noise=Normal_prior(mu=[-6.0], std=[0.1], dtype=dtype), + length=Normal_prior(mu=[-0.8], std=[0.2], dtype=dtype), + noise=Normal_prior(mu=[-9.0], std=[1.0], dtype=dtype), ) else: pdis = None From 59f77db2bb7f9bd2fe24273473539575ee586ca1 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Tue, 22 Jul 2025 16:51:40 +0200 Subject: [PATCH 127/194] Remove baseline_targets (bug fix), and more arguments for creating default mlmodels --- .../gp/calculator/database_reduction.py | 10 +- catlearn/regression/gp/calculator/mlmodel.py | 210 +++++++++++------- 2 files changed, 137 insertions(+), 83 deletions(-) diff --git a/catlearn/regression/gp/calculator/database_reduction.py b/catlearn/regression/gp/calculator/database_reduction.py index 1cf56079..a51854c8 100644 --- a/catlearn/regression/gp/calculator/database_reduction.py +++ b/catlearn/regression/gp/calculator/database_reduction.py @@ -170,16 +170,16 @@ def update_arguments( self.update_indicies = True return self - def get_all_atoms(self, **kwargs): + def get_all_data_atoms(self, **kwargs): """ Get the list of all atoms in the database. Returns: list: A list of the saved ASE Atoms objects. """ - return self.atoms_list.copy() + return super().get_data_atoms(**kwargs) - def get_atoms(self, **kwargs): + def get_data_atoms(self, **kwargs): """ Get the list of atoms in the reduced database. @@ -189,7 +189,7 @@ def get_atoms(self, **kwargs): indicies = self.get_reduction_indicies() return [ atoms - for i, atoms in enumerate(self.get_all_atoms(**kwargs)) + for i, atoms in enumerate(self.get_all_data_atoms(**kwargs)) if i in indicies ] @@ -986,7 +986,7 @@ def get_all_positions(self, **kwargs): list: A list of the positions of all the atoms in the database for each system. """ - return self.get_positions(self.get_all_atoms()) + return self.get_positions(self.get_all_data_atoms()) def get_distances(self, not_indicies, **kwargs): """ diff --git a/catlearn/regression/gp/calculator/mlmodel.py b/catlearn/regression/gp/calculator/mlmodel.py index 30c99321..f1d07a7c 100644 --- a/catlearn/regression/gp/calculator/mlmodel.py +++ b/catlearn/regression/gp/calculator/mlmodel.py @@ -93,7 +93,6 @@ def add_training(self, atoms_list, **kwargs): if not isinstance(atoms_list, (list, ndarray)): atoms_list = [atoms_list] self.database.add_set(atoms_list) - self.store_baseline_targets(atoms_list) return self def train_model(self, **kwargs): @@ -374,9 +373,6 @@ def update_arguments( self.use_baseline = False else: self.use_baseline = True - # Make a list of the baseline targets - if baseline is not None or database is not None: - self.baseline_targets = [] # Check that the model and database have the same attributes self.check_attributes() return self @@ -523,14 +519,14 @@ def add_baseline_correction( ): "Add the baseline correction to the targets if a baseline is used." if self.use_baseline: - # Calculate the baseline for the ASE atoms object + # Calculate the baseline for the ASE atoms instance y_base = self.calculate_baseline( [atoms], use_derivatives=use_derivatives, **kwargs, ) # Add baseline correction to the targets - return targets + asarray(y_base, dtype=self.dtype)[0] + targets += asarray(y_base, dtype=self.dtype)[0] return targets def get_baseline_corrected_targets(self, targets, **kwargs): @@ -539,20 +535,17 @@ def get_baseline_corrected_targets(self, targets, **kwargs): The baseline correction is subtracted from training targets. """ if self.use_baseline: - return targets - asarray(self.baseline_targets, dtype=self.dtype) - return targets - - def store_baseline_targets(self, atoms_list, **kwargs): - "Store the baseline correction on the targets." - # Calculate the baseline for each ASE atoms objects - if self.use_baseline: + # Get the ASE atoms list from the database + atoms_list = self.get_data_atoms() + # Calculate the baseline for each ASE atoms instance y_base = self.calculate_baseline( atoms_list, use_derivatives=self.database.use_derivatives, **kwargs, ) - self.baseline_targets.extend(y_base) - return self.baseline_targets + # Subtract baseline correction to the targets + targets -= asarray(y_base, dtype=self.dtype) + return targets def calculate_baseline(self, atoms_list, use_derivatives=True, **kwargs): "Calculate the baseline for each ASE atoms object." @@ -561,7 +554,11 @@ def calculate_baseline(self, atoms_list, use_derivatives=True, **kwargs): atoms_base = atoms.copy() atoms_base.calc = self.baseline y_base.append( - self.make_targets(atoms_base, use_derivatives=use_derivatives) + self.make_targets( + atoms_base, + use_derivatives=use_derivatives, + **kwargs, + ) ) return y_base @@ -597,7 +594,6 @@ def reset_database(self, **kwargs): self: The updated object itself. """ self.database.reset_database() - self.baseline_targets = [] return self def make_targets(self, atoms, use_derivatives=True, **kwargs): @@ -752,7 +748,7 @@ def get_arguments(self): # Get the constants made within the class constant_kwargs = dict() # Get the objects made within the class - object_kwargs = dict(baseline_targets=self.baseline_targets.copy()) + object_kwargs = dict() return arg_kwargs, constant_kwargs, object_kwargs def copy(self): @@ -789,6 +785,13 @@ def get_default_model( n_reduced=None, round_hp=3, dtype=float, + model_kwargs={}, + prior_kwargs={}, + kernel_kwargs={}, + hpfitter_kwargs={}, + optimizer_kwargs={}, + lineoptimizer_kwargs={}, + function_kwargs={}, **kwargs, ): """ @@ -813,13 +816,28 @@ def get_default_model( Whether to optimize the hyperparameters in parallel. n_reduced: int or None If n_reduced is an integer, the hyperparameters are only optimized - when the data set size is equal to or below the integer. + when the data set size is equal to or below the integer. If n_reduced is None, the hyperparameter is always optimized. round_hp: int (optional) The number of decimals to round the hyperparameters to. If None, the hyperparameters are not rounded. dtype: type The data type of the arrays. + model_kwargs: dict (optional) + The keyword arguments for the model. + The additional arguments are passed to the model. + prior_kwargs: dict (optional) + The keyword arguments for the prior mean. + kernel_kwargs: dict (optional) + The keyword arguments for the kernel. + hpfitter_kwargs: dict (optional) + The keyword arguments for the hyperparameter fitter. + optimizer_kwargs: dict (optional) + The keyword arguments for the optimizer. + lineoptimizer_kwargs: dict (optional) + The keyword arguments for the line optimizer. + function_kwargs: dict (optional) + The keyword arguments for the objective function. Returns: model: Model @@ -831,22 +849,16 @@ def get_default_model( return model # Make the prior mean from given string if isinstance(prior, str): - if prior.lower() == "median": - from ..means.median import Prior_median + from ..means import Prior_median, Prior_mean, Prior_min, Prior_max - prior = Prior_median() + if prior.lower() == "median": + prior = Prior_median(**prior_kwargs) elif prior.lower() == "mean": - from ..means.mean import Prior_mean - - prior = Prior_mean() + prior = Prior_mean(**prior_kwargs) elif prior.lower() == "min": - from ..means.min import Prior_min - - prior = Prior_min() + prior = Prior_min(**prior_kwargs) elif prior.lower() == "max": - from ..means.max import Prior_max - - prior = Prior_max() + prior = Prior_max(**prior_kwargs) # Construct the kernel class object from ..kernel.se import SE @@ -854,50 +866,69 @@ def get_default_model( use_fingerprint=use_fingerprint, use_derivatives=use_derivatives, dtype=dtype, + **kernel_kwargs, ) # Set the hyperparameter optimization method if global_optimization: # Set global optimization with or without parallelization from ..optimizers.globaloptimizer import FactorizedOptimizer + # Set the line searcher for the hyperparameter optimization if parallel: from ..optimizers.linesearcher import FineGridSearch - line_optimizer = FineGridSearch( + lineoptimizer_kwargs_default = dict( optimize=True, multiple_min=False, ngrid=80, loops=3, + ) + lineoptimizer_kwargs_default.update(lineoptimizer_kwargs) + line_optimizer = FineGridSearch( parallel=True, dtype=dtype, + **lineoptimizer_kwargs_default, ) else: from ..optimizers.linesearcher import GoldenSearch - line_optimizer = GoldenSearch( + lineoptimizer_kwargs_default = dict( optimize=True, multiple_min=False, + ) + lineoptimizer_kwargs_default.update(lineoptimizer_kwargs) + line_optimizer = GoldenSearch( parallel=False, dtype=dtype, + **lineoptimizer_kwargs_default, ) - optimizer = FactorizedOptimizer( - line_optimizer=line_optimizer, + # Set the optimizer for the hyperparameter optimization + optimizer_kwargs_default = dict( ngrid=80, calculate_init=False, + ) + optimizer_kwargs_default.update(optimizer_kwargs) + optimizer = FactorizedOptimizer( + line_optimizer=line_optimizer, parallel=parallel, dtype=dtype, + **optimizer_kwargs_default, ) else: from ..optimizers.localoptimizer import ScipyOptimizer - # Make the local optimizer - optimizer = ScipyOptimizer( + optimizer_kwargs_default = dict( maxiter=500, jac=True, method="l-bfgs-b", use_bounds=False, tol=1e-12, + ) + optimizer_kwargs_default.update(optimizer_kwargs) + # Make the local optimizer + optimizer = ScipyOptimizer( dtype=dtype, + **optimizer_kwargs_default, ) if parallel: warnings.warn( @@ -905,17 +936,22 @@ def get_default_model( "with local optimization!" ) # Use either the Student t process or the Gaussian process + model_kwargs.update(kwargs) if model.lower() == "tp": # Set model from ..models.tp import TProcess + model_kwargs_default = dict( + a=1e-4, + b=10.0, + ) + model_kwargs_default.update(model_kwargs) model = TProcess( prior=prior, kernel=kernel, use_derivatives=use_derivatives, - a=1e-4, - b=10.0, dtype=dtype, + **model_kwargs_default, ) # Set objective function if global_optimization: @@ -923,11 +959,11 @@ def get_default_model( FactorizedLogLikelihood, ) - func = FactorizedLogLikelihood() + func = FactorizedLogLikelihood(dtype=dtype, **function_kwargs) else: from ..objectivefunctions.tp.likelihood import LogLikelihood - func = LogLikelihood() + func = LogLikelihood(dtype=dtype, **function_kwargs) else: # Set model from ..models.gp import GaussianProcess @@ -937,6 +973,7 @@ def get_default_model( kernel=kernel, use_derivatives=use_derivatives, dtype=dtype, + **model_kwargs, ) # Set objective function if global_optimization: @@ -944,11 +981,11 @@ def get_default_model( FactorizedLogLikelihood, ) - func = FactorizedLogLikelihood(dtype=dtype) + func = FactorizedLogLikelihood(dtype=dtype, **function_kwargs) else: from ..objectivefunctions.gp.likelihood import LogLikelihood - func = LogLikelihood(dtype=dtype) + func = LogLikelihood(dtype=dtype, **function_kwargs) # Set hpfitter and whether a maximum data set size is applied if n_reduced is None: from ..hpfitter import HyperparameterFitter @@ -958,6 +995,7 @@ def get_default_model( optimizer=optimizer, round_hp=round_hp, dtype=dtype, + **hpfitter_kwargs, ) else: from ..hpfitter.redhpfitter import ReducedHyperparameterFitter @@ -968,6 +1006,7 @@ def get_default_model( opt_tr_size=n_reduced, round_hp=round_hp, dtype=dtype, + **hpfitter_kwargs, ) model.update_arguments(hpfitter=hpfitter) return model @@ -977,10 +1016,9 @@ def get_default_database( fp=None, use_derivatives=True, database_reduction=False, - database_reduction_kwargs={}, round_targets=5, dtype=float, - **kwargs, + **database_kwargs, ): """ Get the default Database from the simple given arguments. @@ -994,14 +1032,14 @@ def get_default_database( database_reduction: bool Whether to used a reduced database after a number of training points. - database_reduction_kwargs: dict - A dictionary with the arguments for the reduced database - if it is used. round_targets: int (optional) The number of decimals to round the targets to. If None, the targets are not rounded. dtype: type The data type of the arrays. + database_kwargs: dict (optional) + A dictionary with additional arguments for the database. + Also used for the reduced databases. Returns: database: Database object @@ -1019,6 +1057,7 @@ def get_default_database( # Make the data base ready if isinstance(database_reduction, str): data_kwargs = dict( + fingerprint=fp, reduce_dimensions=True, use_derivatives=use_derivatives, use_fingerprint=use_fingerprint, @@ -1027,53 +1066,54 @@ def get_default_database( npoints=50, initial_indicies=[0, 1], include_last=1, - **kwargs, ) - data_kwargs.update(database_reduction_kwargs) + data_kwargs.update(database_kwargs) if database_reduction.lower() == "distance": from .database_reduction import DatabaseDistance - database = DatabaseDistance(fingerprint=fp, **data_kwargs) + database = DatabaseDistance(**data_kwargs) elif database_reduction.lower() == "random": from .database_reduction import DatabaseRandom - database = DatabaseRandom(fingerprint=fp, **data_kwargs) + database = DatabaseRandom(**data_kwargs) elif database_reduction.lower() == "hybrid": from .database_reduction import DatabaseHybrid - database = DatabaseHybrid(fingerprint=fp, **data_kwargs) + database = DatabaseHybrid(**data_kwargs) elif database_reduction.lower() == "min": from .database_reduction import DatabaseMin - database = DatabaseMin(fingerprint=fp, **data_kwargs) + database = DatabaseMin(**data_kwargs) elif database_reduction.lower() == "last": from .database_reduction import DatabaseLast - database = DatabaseLast(fingerprint=fp, **data_kwargs) + database = DatabaseLast(**data_kwargs) elif database_reduction.lower() == "restart": from .database_reduction import DatabaseRestart - database = DatabaseRestart(fingerprint=fp, **data_kwargs) + database = DatabaseRestart(**data_kwargs) elif database_reduction.lower() == "interest": from .database_reduction import DatabasePointsInterest - database = DatabasePointsInterest(fingerprint=fp, **data_kwargs) + database = DatabasePointsInterest(**data_kwargs) elif database_reduction.lower() == "each_interest": from .database_reduction import DatabasePointsInterestEach - database = DatabasePointsInterestEach( - fingerprint=fp, **data_kwargs - ) + database = DatabasePointsInterestEach(**data_kwargs) else: from .database import Database + data_kwargs = dict( + reduce_dimensions=True, + ) + data_kwargs.update(database_kwargs) database = Database( fingerprint=fp, - reduce_dimensions=True, use_derivatives=use_derivatives, use_fingerprint=use_fingerprint, round_targets=round_targets, dtype=dtype, + **data_kwargs, ) return database @@ -1082,17 +1122,19 @@ def get_default_mlmodel( model="tp", fp=None, baseline=None, + optimize_hp=True, + use_pdis=True, + pdis=None, prior="median", use_derivatives=True, - optimize_hp=True, global_optimization=True, parallel=False, - use_pdis=True, n_reduced=None, + round_hp=3, + all_model_kwargs={}, database_reduction=False, - database_reduction_kwargs={}, round_targets=5, - round_hp=3, + database_kwargs={}, verbose=False, dtype=float, **kwargs, @@ -1110,40 +1152,51 @@ def get_default_mlmodel( Cartesian coordinates are used if it is None. baseline: Baseline object The Baseline object calculator that calculates energy and forces. + optimize_hp: bool + Whether to optimize the hyperparameters when the model is trained. + use_pdis: bool + Whether to make prior distributions for the hyperparameters. + pdis: dict (optional) + A dict of prior distributions for each hyperparameter type. + If None, the default prior distributions are used. + No prior distributions are used if use_pdis=False or pdis is {}. prior: str Specify what prior mean should be used. use_derivatives: bool Whether to use derivatives of the targets. - optimize_hp: bool - Whether to optimize the hyperparameters when the model is trained. global_optimization: bool Whether to perform a global optimization of the hyperparameters. A local optimization is used if global_optimization=False, which can not be parallelized. parallel: bool Whether to optimize the hyperparameters in parallel. - use_pdis: bool - Whether to make prior distributions for the hyperparameters. n_reduced: int or None If n_reduced is an integer, the hyperparameters are only optimized when the data set size is equal to or below the integer. If n_reduced is None, the hyperparameter is always optimized. + round_hp: int (optional) + The number of decimals to round the hyperparameters to. + If None, the hyperparameters are not rounded. + all_model_kwargs: dict (optional) + A dictionary with additional arguments for the model. + It also can include model_kwargs, prior_kwargs, + kernel_kwargs, hpfitter_kwargs, optimizer_kwargs, + lineoptimizer_kwargs, and function_kwargs. database_reduction: bool Whether to used a reduced database after a number of training points. - database_reduction_kwargs: dict - A dictionary with the arguments for the reduced database - if it is used. round_targets: int (optional) The number of decimals to round the targets to. If None, the targets are not rounded. - round_hp: int (optional) - The number of decimals to round the hyperparameters to. - If None, the hyperparameters are not rounded. + database_kwargs: dict + A dictionary with the arguments for the database + if it is used. verbose: bool Whether to print statements in the optimization. dtype: type The data type of the arrays. + kwargs: dict (optional) + Additional keyword arguments for the MLModel class. Returns: mlmodel: MLModel class object @@ -1166,25 +1219,26 @@ def get_default_mlmodel( n_reduced=n_reduced, round_hp=round_hp, dtype=dtype, + **all_model_kwargs, ) # Make the database database = get_default_database( fp=fp, use_derivatives=use_derivatives, database_reduction=database_reduction, - database_reduction_kwargs=database_reduction_kwargs, round_targets=round_targets, dtype=dtype, + **database_kwargs, ) # Make prior distributions for the hyperparameters if specified - if use_pdis: + if use_pdis and pdis is None: from ..pdistributions.normal import Normal_prior pdis = dict( length=Normal_prior(mu=[-0.8], std=[0.2], dtype=dtype), noise=Normal_prior(mu=[-9.0], std=[1.0], dtype=dtype), ) - else: + elif not use_pdis: pdis = None # Make the ML model with database return MLModel( From 7e5e69c80782f6b95665396b6ff18c067a82ba82 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Wed, 23 Jul 2025 12:39:25 +0200 Subject: [PATCH 128/194] Save model option --- .../regression/gp/calculator/hiermodel.py | 44 +++++++++++++------ 1 file changed, 31 insertions(+), 13 deletions(-) diff --git a/catlearn/regression/gp/calculator/hiermodel.py b/catlearn/regression/gp/calculator/hiermodel.py index e5e77f2b..4675e344 100644 --- a/catlearn/regression/gp/calculator/hiermodel.py +++ b/catlearn/regression/gp/calculator/hiermodel.py @@ -22,9 +22,11 @@ def __init__( hp=None, pdis=None, include_noise=False, + to_save_mlmodel=False, + save_mlmodel_kwargs={}, verbose=False, npoints=25, - initial_indicies=[0], + initial_indices=[0], dtype=float, **kwargs, ): @@ -50,12 +52,16 @@ def __init__( A dict of prior distributions for each hyperparameter type. include_noise: bool Whether to include noise in the uncertainty from the model. + to_save_mlmodel: bool + Whether to save the ML model to a file after training. + save_mlmodel_kwargs: dict + Arguments for saving the ML model, like the filename. verbose: bool Whether to print statements in the optimization. npoints: int Number of points that are used from the database in the models. - initial_indicies: list - The indicies of the data points that must be included in + initial_indices: list + The indices of the data points that must be included in the used data base for every model. dtype: type The data type of the arrays. @@ -68,9 +74,11 @@ def __init__( hp=hp, pdis=pdis, include_noise=include_noise, + to_save_mlmodel=to_save_mlmodel, + save_mlmodel_kwargs=save_mlmodel_kwargs, verbose=verbose, npoints=npoints, - initial_indicies=initial_indicies, + initial_indices=initial_indices, dtype=dtype, **kwargs, ) @@ -103,7 +111,7 @@ def add_training(self, atoms_list, **kwargs): ) # Make a new ml model with the mandatory points data_atoms = self.get_data_atoms() - data_atoms = [data_atoms[i] for i in self.initial_indicies] + data_atoms = [data_atoms[i] for i in self.initial_indices] self.reset_database() super().add_training(data_atoms) super().add_training(atoms_list) @@ -123,9 +131,11 @@ def update_arguments( hp=None, pdis=None, include_noise=None, + to_save_mlmodel=None, + save_mlmodel_kwargs=None, verbose=None, npoints=None, - initial_indicies=None, + initial_indices=None, dtype=None, **kwargs, ): @@ -150,14 +160,18 @@ def update_arguments( else the current set is used. pdis: dict A dict of prior distributions for each hyperparameter type. + to_save_mlmodel: bool + Whether to save the ML model to a file after training. + save_mlmodel_kwargs: dict + Arguments for saving the ML model, like the filename. include_noise: bool Whether to include noise in the uncertainty from the model. verbose: bool Whether to print statements in the optimization. npoints: int Number of points that are used from the database in the models. - initial_indicies: list - The indicies of the data points that must be included in + initial_indices: list + The indices of the data points that must be included in the used data base for every model. dtype: type The data type of the arrays. @@ -174,15 +188,17 @@ def update_arguments( hp=hp, pdis=pdis, include_noise=include_noise, + to_save_mlmodel=to_save_mlmodel, + save_mlmodel_kwargs=save_mlmodel_kwargs, verbose=verbose, dtype=dtype, ) # Set the number of points if npoints is not None: self.npoints = int(npoints) - # Set the initial indicies - if initial_indicies is not None: - self.initial_indicies = initial_indicies.copy() + # Set the initial indices + if initial_indices is not None: + self.initial_indices = initial_indices.copy() return self def get_arguments(self): @@ -196,13 +212,15 @@ def get_arguments(self): hp=self.hp, pdis=self.pdis, include_noise=self.include_noise, + to_save_mlmodel=self.to_save_mlmodel, + save_mlmodel_kwargs=self.save_mlmodel_kwargs, verbose=self.verbose, npoints=self.npoints, - initial_indicies=self.initial_indicies, + initial_indices=self.initial_indices, dtype=self.dtype, ) # Get the constants made within the class constant_kwargs = dict() # Get the objects made within the class - object_kwargs = dict(baseline_targets=self.baseline_targets.copy()) + object_kwargs = dict() return arg_kwargs, constant_kwargs, object_kwargs From 260e06b6cc06f7c605537eb9f4ffc1ec57e05aed Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Wed, 23 Jul 2025 12:44:19 +0200 Subject: [PATCH 129/194] Remove baseline_targets (bug fix), and more arguments for creating default mlmodels --- .../gp/calculator/database_reduction.py | 415 +++++++++--------- catlearn/regression/gp/calculator/mlmodel.py | 40 +- 2 files changed, 238 insertions(+), 217 deletions(-) diff --git a/catlearn/regression/gp/calculator/database_reduction.py b/catlearn/regression/gp/calculator/database_reduction.py index a51854c8..749ff5e2 100644 --- a/catlearn/regression/gp/calculator/database_reduction.py +++ b/catlearn/regression/gp/calculator/database_reduction.py @@ -33,7 +33,7 @@ def __init__( seed=None, dtype=float, npoints=25, - initial_indicies=[0], + initial_indices=[0], include_last=1, **kwargs, ): @@ -62,16 +62,16 @@ def __init__( The data type of the arrays. npoints: int Number of points that are used from the database. - initial_indicies: list - The indicies of the data points that must be included + initial_indices: list + The indices of the data points that must be included in the used data base. include_last: int Number of last data point to include in the used data base. """ # The negative forces have to be used since the derivatives are used self.use_negative_forces = True - # Set initial indicies - self.indicies = [] + # Set initial indices + self.indices = [] # Use default fingerprint if it is not given if fingerprint is None: self.set_default_fp( @@ -89,7 +89,7 @@ def __init__( seed=seed, dtype=dtype, npoints=npoints, - initial_indicies=initial_indicies, + initial_indices=initial_indices, include_last=include_last, **kwargs, ) @@ -104,7 +104,7 @@ def update_arguments( seed=None, dtype=None, npoints=None, - initial_indicies=None, + initial_indices=None, include_last=None, **kwargs, ): @@ -134,8 +134,8 @@ def update_arguments( The data type of the arrays. npoints: int Number of points that are used from the database. - initial_indicies: list - The indicies of the data points that must be included + initial_indices: list + The indices of the data points that must be included in the used data base. include_last: int Number of last data point to include in the used data base. @@ -156,18 +156,18 @@ def update_arguments( # Set the number of points to use if npoints is not None: self.npoints = int(npoints) - # Set the initial indicies to keep fixed - if initial_indicies is not None: - self.initial_indicies = array(initial_indicies, dtype=int) + # Set the initial indices to keep fixed + if initial_indices is not None: + self.initial_indices = array(initial_indices, dtype=int) # Set the number of last points to include if include_last is not None: self.include_last = int(abs(include_last)) # Check that too many last points are not included - n_extra = self.npoints - len(self.initial_indicies) + n_extra = self.npoints - len(self.initial_indices) if self.include_last > n_extra: self.include_last = n_extra if n_extra >= 0 else 0 # Store that the data base has changed - self.update_indicies = True + self.update_indices = True return self def get_all_data_atoms(self, **kwargs): @@ -186,12 +186,9 @@ def get_data_atoms(self, **kwargs): Returns: list: A list of the saved ASE Atoms objects. """ - indicies = self.get_reduction_indicies() - return [ - atoms - for i, atoms in enumerate(self.get_all_data_atoms(**kwargs)) - if i in indicies - ] + indices = self.get_reduction_indices() + atoms_list = self.get_all_data_atoms(**kwargs) + return [atoms_list[i] for i in indices] def get_features(self, **kwargs): """ @@ -200,10 +197,10 @@ def get_features(self, **kwargs): Returns: array: A matrix array with the saved features or fingerprints. """ - indicies = self.get_reduction_indicies() + indices = self.get_reduction_indices() if self.use_fingerprint: - return array(self.features)[indicies] - return array(self.features, dtype=self.dtype)[indicies] + return array(self.features)[indices] + return array(self.features, dtype=self.dtype)[indices] def get_all_feature_vectors(self, **kwargs): "Get all the features in numpy array form." @@ -219,8 +216,8 @@ def get_targets(self, **kwargs): Returns: array: A matrix array with the saved targets. """ - indicies = self.get_reduction_indicies() - return array(self.targets, dtype=self.dtype)[indicies] + indices = self.get_reduction_indices() + return array(self.targets, dtype=self.dtype)[indices] def get_all_targets(self, **kwargs): """ @@ -231,77 +228,77 @@ def get_all_targets(self, **kwargs): """ return array(self.targets, dtype=self.dtype) - def get_initial_indicies(self, **kwargs): + def get_initial_indices(self, **kwargs): """ - Get the initial indicies of the used atoms in the database. + Get the initial indices of the used atoms in the database. Returns: - array: The initial indicies of the atoms used. + array: The initial indices of the atoms used. """ - return self.initial_indicies.copy() + return array(self.initial_indices, dtype=int) - def get_last_indicies(self, indicies, not_indicies, **kwargs): + def get_last_indices(self, indices, not_indices, **kwargs): """ - Include the last indicies that are not in the used indicies list. + Include the last indices that are not in the used indices list. Parameters: - indicies: list - A list of used indicies. - not_indicies: list - A list of indicies that not used yet. + indices: list + A list of used indices. + not_indices: list + A list of indices that not used yet. Returns: - list: A list of the used indicies including the last indicies. + list: A list of the used indices including the last indices. """ if self.include_last != 0: last = -self.include_last - indicies = append( - indicies, - [not_indicies[last:]], + indices = append( + indices, + [not_indices[last:]], ) - return indicies + return indices - def get_not_indicies(self, indicies, all_indicies, **kwargs): + def get_not_indices(self, indices, all_indices, **kwargs): """ - Get a list of the indicies that are not in the used indicies list. + Get a list of the indices that are not in the used indices list. Parameters: - indicies: list - A list of indicies. - all_indicies: list - A list of all indicies. + indices: list + A list of indices. + all_indices: list + A list of all indices. Returns: - list: A list of indicies that not used. + list: A list of indices that not used. """ - return list(set(all_indicies).difference(indicies)) + return list(set(all_indices).difference(indices)) def append(self, atoms, **kwargs): "Append the atoms object, the fingerprint, and target(s) to lists." # Store that the data base has changed - self.update_indicies = True + self.update_indices = True # Append to the data base super().append(atoms, **kwargs) return self - def get_reduction_indicies(self, **kwargs): - "Get the indicies of the reduced data used." - # If the indicies is already calculated then give them - if not self.update_indicies: - return self.indicies - # Set up all the indicies - self.update_indicies = False + def get_reduction_indices(self, **kwargs): + "Get the indices of the reduced data used." + # If the indices is already calculated then give them + if not self.update_indices: + return self.indices + # Set up all the indices + self.update_indices = False data_len = self.__len__() - all_indicies = arange(data_len) + all_indices = arange(data_len) # No reduction is needed if the database is not large if data_len <= self.npoints: - self.indicies = all_indicies.copy() - return self.indicies + self.indices = all_indices.copy() + return self.indices # Reduce the data base - self.indicies = self.make_reduction(all_indicies) - return self.indicies + self.indices = self.make_reduction(all_indices) + return self.indices - def make_reduction(self, all_indicies, **kwargs): + def make_reduction(self, all_indices, **kwargs): "Make the reduction of the data base with a chosen method." raise NotImplementedError() @@ -317,17 +314,17 @@ def get_arguments(self): seed=self.seed, dtype=self.dtype, npoints=self.npoints, - initial_indicies=self.initial_indicies, + initial_indices=self.initial_indices, include_last=self.include_last, ) # Get the constants made within the class - constant_kwargs = dict(update_indicies=self.update_indicies) + constant_kwargs = dict(update_indices=self.update_indices) # Get the objects made within the class object_kwargs = dict( atoms_list=self.atoms_list.copy(), features=self.features.copy(), targets=self.targets.copy(), - indicies=self.indicies.copy(), + indices=self.indices.copy(), ) return arg_kwargs, constant_kwargs, object_kwargs @@ -341,32 +338,33 @@ class DatabaseDistance(DatabaseReduction): largest distances from each other. """ - def make_reduction(self, all_indicies, **kwargs): + def make_reduction(self, all_indices, **kwargs): "Reduce the training set with the points farthest from each other." - # Get the fixed indicies - indicies = self.get_initial_indicies() - # Get the indicies for the system not already included - not_indicies = self.get_not_indicies(indicies, all_indicies) + # Get the fixed indices + indices = self.get_initial_indices() + # Get the indices for the system not already included + not_indices = self.get_not_indices(indices, all_indices) # Include the last point - indicies = self.get_last_indicies(indicies, not_indicies) + indices = self.get_last_indices(indices, not_indices) # Get a random index if no fixed index exist - if len(indicies) == 0: - indicies = asarray([self.rng.choice(not_indicies)]) + if len(indices) == 0: + indices = asarray([self.rng.choice(not_indices)], dtype=int) + not_indices = self.get_not_indices(indices, all_indices) # Get all the features features = self.get_all_feature_vectors() fdim = len(features[0]) - for i in range(len(indicies), self.npoints): - # Get the indicies for the system not already included - not_indicies = self.get_not_indicies(indicies, all_indicies) + for i in range(len(indices), self.npoints): + # Get the indices for the system not already included + not_indices = self.get_not_indices(indices, all_indices) # Calculate the distances to the points already used dist = cdist( - features[indicies].reshape(-1, fdim), - features[not_indicies].reshape(-1, fdim), + features[indices].reshape(-1, fdim), + features[not_indices].reshape(-1, fdim), ) # Choose the point furthest from the points already used i_max = argmax(nanmin(dist, axis=0)) - indicies = append(indicies, [not_indicies[i_max]]) - return array(indicies, dtype=int) + indices = append(indices, [not_indices[i_max]]) + return array(indices, dtype=int) class DatabaseRandom(DatabaseReduction): @@ -377,24 +375,24 @@ class DatabaseRandom(DatabaseReduction): The reduction is done by selecting the points randomly. """ - def make_reduction(self, all_indicies, **kwargs): + def make_reduction(self, all_indices, **kwargs): "Random select the training points." - # Get the fixed indicies - indicies = self.get_initial_indicies() - # Get the indicies for the system not already included - not_indicies = self.get_not_indicies(indicies, all_indicies) + # Get the fixed indices + indices = self.get_initial_indices() + # Get the indices for the system not already included + not_indices = self.get_not_indices(indices, all_indices) # Include the last point - indicies = self.get_last_indicies(indicies, not_indicies) - # Get the indicies for the system not already included - not_indicies = self.get_not_indicies(indicies, all_indicies) + indices = self.get_last_indices(indices, not_indices) + # Get the indices for the system not already included + not_indices = self.get_not_indices(indices, all_indices) # Get the number of missing points - npoints = int(self.npoints - len(indicies)) - # Randomly get the indicies - indicies = append( - indicies, - self.rng.permutation(not_indicies)[:npoints], + npoints = int(self.npoints - len(indices)) + # Randomly get the indices + indices = append( + indices, + self.rng.permutation(not_indices)[:npoints], ) - return array(indicies, dtype=int) + return array(indices, dtype=int) class DatabaseHybrid(DatabaseReduction): @@ -417,7 +415,7 @@ def __init__( seed=None, dtype=float, npoints=25, - initial_indicies=[0], + initial_indices=[0], include_last=1, random_fraction=3, **kwargs, @@ -447,8 +445,8 @@ def __init__( The data type of the arrays. npoints: int Number of points that are used from the database. - initial_indicies: list - The indicies of the data points that must be included + initial_indices: list + The indices of the data points that must be included in the used data base. include_last: int Number of last data point to include in the used data base. @@ -464,7 +462,7 @@ def __init__( seed=seed, dtype=dtype, npoints=npoints, - initial_indicies=initial_indicies, + initial_indices=initial_indices, include_last=include_last, random_fraction=random_fraction, **kwargs, @@ -480,7 +478,7 @@ def update_arguments( seed=None, dtype=None, npoints=None, - initial_indicies=None, + initial_indices=None, include_last=None, random_fraction=None, **kwargs, @@ -511,8 +509,8 @@ def update_arguments( The data type of the arrays. npoints: int Number of points that are used from the database. - initial_indicies: list - The indicies of the data points that must be included + initial_indices: list + The indices of the data points that must be included in the used data base. include_last: int Number of last data point to include in the used data base. @@ -532,7 +530,7 @@ def update_arguments( seed=seed, dtype=dtype, npoints=npoints, - initial_indicies=initial_indicies, + initial_indices=initial_indices, include_last=include_last, ) # Set the random fraction @@ -542,39 +540,40 @@ def update_arguments( self.random_fraction = 1 return self - def make_reduction(self, all_indicies, **kwargs): + def make_reduction(self, all_indices, **kwargs): """ Use a combination of random sampling and farthest distance to reduce training set. """ - # Get the fixed indicies - indicies = self.get_initial_indicies() - # Get the indicies for the system not already included - not_indicies = self.get_not_indicies(indicies, all_indicies) + # Get the fixed indices + indices = self.get_initial_indices() + # Get the indices for the system not already included + not_indices = self.get_not_indices(indices, all_indices) # Include the last point - indicies = self.get_last_indicies(indicies, not_indicies) + indices = self.get_last_indices(indices, not_indices) # Get a random index if no fixed index exist - if len(indicies) == 0: - indicies = [self.rng.choice(not_indicies)] + if len(indices) == 0: + indices = asarray([self.rng.choice(not_indices)], dtype=int) + not_indices = self.get_not_indices(indices, all_indices) # Get all the features features = self.get_all_feature_vectors() fdim = len(features[0]) - for i in range(len(indicies), self.npoints): - # Get the indicies for the system not already included - not_indicies = self.get_not_indicies(indicies, all_indicies) + for i in range(len(indices), self.npoints): + # Get the indices for the system not already included + not_indices = self.get_not_indices(indices, all_indices) if i % self.random_fraction == 0: # Get a random index - indicies = append(indicies, [self.rng.choice(not_indicies)]) + indices = append(indices, [self.rng.choice(not_indices)]) else: # Calculate the distances to the points already used dist = cdist( - features[indicies].reshape(-1, fdim), - features[not_indicies].reshape(-1, fdim), + features[indices].reshape(-1, fdim), + features[not_indices].reshape(-1, fdim), ) # Choose the point furthest from the points already used i_max = argmax(nanmin(dist, axis=0)) - indicies = append(indicies, [not_indicies[i_max]]) - return array(indicies, dtype=int) + indices = append(indices, [not_indices[i_max]]) + return array(indices, dtype=int) def get_arguments(self): "Get the arguments of the class itself." @@ -588,18 +587,18 @@ def get_arguments(self): seed=self.seed, dtype=self.dtype, npoints=self.npoints, - initial_indicies=self.initial_indicies, + initial_indices=self.initial_indices, include_last=self.include_last, random_fraction=self.random_fraction, ) # Get the constants made within the class - constant_kwargs = dict(update_indicies=self.update_indicies) + constant_kwargs = dict(update_indices=self.update_indices) # Get the objects made within the class object_kwargs = dict( atoms_list=self.atoms_list.copy(), features=self.features.copy(), targets=self.targets.copy(), - indicies=self.indicies.copy(), + indices=self.indices.copy(), ) return arg_kwargs, constant_kwargs, object_kwargs @@ -623,7 +622,7 @@ def __init__( seed=None, dtype=float, npoints=25, - initial_indicies=[0], + initial_indices=[0], include_last=1, force_targets=False, **kwargs, @@ -653,8 +652,8 @@ def __init__( The data type of the arrays. npoints: int Number of points that are used from the database. - initial_indicies: list - The indicies of the data points that must be included + initial_indices: list + The indices of the data points that must be included in the used data base. include_last: int Number of last data point to include in the used data base. @@ -671,7 +670,7 @@ def __init__( seed=seed, dtype=dtype, npoints=npoints, - initial_indicies=initial_indicies, + initial_indices=initial_indices, include_last=include_last, force_targets=force_targets, **kwargs, @@ -687,7 +686,7 @@ def update_arguments( seed=None, dtype=None, npoints=None, - initial_indicies=None, + initial_indices=None, include_last=None, force_targets=None, **kwargs, @@ -718,8 +717,8 @@ def update_arguments( The data type of the arrays. npoints: int Number of points that are used from the database. - initial_indicies: list - The indicies of the data points that must be included + initial_indices: list + The indices of the data points that must be included in the used data base. include_last: int Number of last data point to include in the used data base. @@ -740,7 +739,7 @@ def update_arguments( seed=seed, dtype=dtype, npoints=npoints, - initial_indicies=initial_indicies, + initial_indices=initial_indices, include_last=include_last, ) # Set the force targets @@ -748,18 +747,18 @@ def update_arguments( self.force_targets = force_targets return self - def make_reduction(self, all_indicies, **kwargs): + def make_reduction(self, all_indices, **kwargs): "Use the targets with smallest norms in the training set." - # Get the fixed indicies - indicies = self.get_initial_indicies() - # Get the indicies for the system not already included - not_indicies = self.get_not_indicies(indicies, all_indicies) + # Get the fixed indices + indices = self.get_initial_indices() + # Get the indices for the system not already included + not_indices = self.get_not_indices(indices, all_indices) # Include the last point - indicies = self.get_last_indicies(indicies, not_indicies) - # Get the indicies for the system not already included - not_indicies = array(self.get_not_indicies(indicies, all_indicies)) + indices = self.get_last_indices(indices, not_indices) + # Get the indices for the system not already included + not_indices = array(self.get_not_indices(indices, all_indices)) # Get the targets - targets = self.get_all_targets()[not_indicies] + targets = self.get_all_targets()[not_indices] # Get sorting of the targets if self.force_targets: # Get the points with the lowest norm of the targets @@ -769,11 +768,11 @@ def make_reduction(self, all_indicies, **kwargs): # Get the points with the lowest energies i_sort = argsort(targets[:, 0]) # Get the number of missing points - npoints = int(self.npoints - len(indicies)) - # Get the indicies for the system not already included + npoints = int(self.npoints - len(indices)) + # Get the indices for the system not already included i_sort = i_sort[:npoints] - indicies = append(indicies, not_indicies[i_sort]) - return array(indicies, dtype=int) + indices = append(indices, not_indices[i_sort]) + return array(indices, dtype=int) def get_arguments(self): "Get the arguments of the class itself." @@ -787,18 +786,18 @@ def get_arguments(self): seed=self.seed, dtype=self.dtype, npoints=self.npoints, - initial_indicies=self.initial_indicies, + initial_indices=self.initial_indices, include_last=self.include_last, force_targets=self.force_targets, ) # Get the constants made within the class - constant_kwargs = dict(update_indicies=self.update_indicies) + constant_kwargs = dict(update_indices=self.update_indices) # Get the objects made within the class object_kwargs = dict( atoms_list=self.atoms_list.copy(), features=self.features.copy(), targets=self.targets.copy(), - indicies=self.indicies.copy(), + indices=self.indices.copy(), ) return arg_kwargs, constant_kwargs, object_kwargs @@ -811,18 +810,18 @@ class DatabaseLast(DatabaseReduction): The reduction is done by selecting the last points in the database. """ - def make_reduction(self, all_indicies, **kwargs): + def make_reduction(self, all_indices, **kwargs): "Use the last data points." - # Get the fixed indicies - indicies = self.get_initial_indicies() - # Get the indicies for the system not already included - not_indicies = self.get_not_indicies(indicies, all_indicies) + # Get the fixed indices + indices = self.get_initial_indices() + # Get the indices for the system not already included + not_indices = self.get_not_indices(indices, all_indices) # Get the number of missing points - npoints = int(self.npoints - len(indicies)) + npoints = int(self.npoints - len(indices)) # Get the last points in the database if npoints > 0: - indicies = append(indicies, not_indicies[-npoints:]) - return array(indicies, dtype=int) + indices = append(indices, not_indices[-npoints:]) + return array(indices, dtype=int) class DatabaseRestart(DatabaseReduction): @@ -831,35 +830,35 @@ class DatabaseRestart(DatabaseReduction): into stored fingerprints and targets. The used Database is a reduced set of the full Database. The reduced data set is selected from restarts after npoints are used. - The initial indicies and the last data point is used at each restart. + The initial indices and the last data point is used at each restart. """ - def make_reduction(self, all_indicies, **kwargs): + def make_reduction(self, all_indices, **kwargs): "Make restart of used data set." - # Get the fixed indicies - indicies = self.get_initial_indicies() + # Get the fixed indices + indices = self.get_initial_indices() # Get the data set size - data_len = len(all_indicies) + data_len = len(all_indices) # Check how many last points are used lasts = self.include_last if lasts == 0: lasts = 1 # Get the minimum number of points in the database - n_initial = len(indicies) + n_initial = len(indices) if lasts > 1: n_initial += lasts - 1 # Get the number of data point after the first restart n_use = data_len - self.npoints - 1 - # Get the number of points that are not initial or last indicies + # Get the number of points that are not initial or last indices nfree = self.npoints - n_initial # Get the excess of data points after each restart n_extra = int(n_use % nfree) - # Get the indicies for the system not already included - not_indicies = self.get_not_indicies(indicies, all_indicies) - # Include the indicies + # Get the indices for the system not already included + not_indices = self.get_not_indices(indices, all_indices) + # Include the indices lasts_i = -(n_extra + lasts) - indicies = append(indicies, not_indicies[lasts_i:]) - return array(indicies, dtype=int) + indices = append(indices, not_indices[lasts_i:]) + return array(indices, dtype=int) class DatabasePointsInterest(DatabaseLast): @@ -883,7 +882,7 @@ def __init__( seed=None, dtype=float, npoints=25, - initial_indicies=[0], + initial_indices=[0], include_last=1, feature_distance=True, point_interest=[], @@ -910,8 +909,8 @@ def __init__( The data type of the arrays. npoints: int Number of points that are used from the database. - initial_indicies: list - The indicies of the data points that must be included + initial_indices: list + The indices of the data points that must be included in the used data base. include_last: int Number of last data point to include in the used data base. @@ -930,7 +929,7 @@ def __init__( seed=seed, dtype=dtype, npoints=npoints, - initial_indicies=initial_indicies, + initial_indices=initial_indices, include_last=include_last, feature_distance=feature_distance, point_interest=point_interest, @@ -988,13 +987,13 @@ def get_all_positions(self, **kwargs): """ return self.get_positions(self.get_all_data_atoms()) - def get_distances(self, not_indicies, **kwargs): + def get_distances(self, not_indices, **kwargs): """ Calculate the distances to the points of interest. Parameters: - not_indicies: list - A list of indicies that not used yet. + not_indices: list + A list of indices that not used yet. Returns: array: The distances to the points of interest. @@ -1012,7 +1011,7 @@ def get_distances(self, not_indicies, **kwargs): fdim = len(features[0]) # Calculate the minimum distances to the points of interest dist = cdist( - features_interest, features[not_indicies].reshape(-1, fdim) + features_interest, features[not_indices].reshape(-1, fdim) ) return dist @@ -1026,7 +1025,7 @@ def update_arguments( seed=None, dtype=None, npoints=None, - initial_indicies=None, + initial_indices=None, include_last=None, feature_distance=None, point_interest=None, @@ -1058,8 +1057,8 @@ def update_arguments( The data type of the arrays. npoints: int Number of points that are used from the database. - initial_indicies: list - The indicies of the data points that must be included + initial_indices: list + The indices of the data points that must be included in the used data base. include_last: int Number of last data point to include in the used data base. @@ -1082,7 +1081,7 @@ def update_arguments( seed=seed, dtype=dtype, npoints=npoints, - initial_indicies=initial_indicies, + initial_indices=initial_indices, include_last=include_last, ) # Set the feature distance @@ -1096,32 +1095,32 @@ def update_arguments( ] return self - def make_reduction(self, all_indicies, **kwargs): + def make_reduction(self, all_indices, **kwargs): """ Reduce the training set with the points closest to the points of interests. """ # Check if there are points of interest else use the Parent class if len(self.point_interest) == 0: - return super().make_reduction(all_indicies, **kwargs) - # Get the fixed indicies - indicies = self.get_initial_indicies() - # Get the indicies for the system not already included - not_indicies = self.get_not_indicies(indicies, all_indicies) + return super().make_reduction(all_indices, **kwargs) + # Get the fixed indices + indices = self.get_initial_indices() + # Get the indices for the system not already included + not_indices = self.get_not_indices(indices, all_indices) # Include the last point - indicies = self.get_last_indicies(indicies, not_indicies) - # Get the indicies for the system not already included - not_indicies = array(self.get_not_indicies(indicies, all_indicies)) + indices = self.get_last_indices(indices, not_indices) + # Get the indices for the system not already included + not_indices = array(self.get_not_indices(indices, all_indices)) # Get the number of missing points - npoints = int(self.npoints - len(indicies)) + npoints = int(self.npoints - len(indices)) # Calculate the distances to the points of interest - dist = self.get_distances(not_indicies) + dist = self.get_distances(not_indices) # Get the minimum distances to the points of interest dist = dist.min(axis=0) i_min = argsort(dist)[:npoints] - # Get the indicies - indicies = append(indicies, [not_indicies[i_min]]) - return array(indicies, dtype=int) + # Get the indices + indices = append(indices, [not_indices[i_min]]) + return array(indices, dtype=int) def get_arguments(self): "Get the arguments of the class itself." @@ -1135,19 +1134,19 @@ def get_arguments(self): seed=self.seed, dtype=self.dtype, npoints=self.npoints, - initial_indicies=self.initial_indicies, + initial_indices=self.initial_indices, include_last=self.include_last, feature_distance=self.feature_distance, point_interest=self.point_interest, ) # Get the constants made within the class - constant_kwargs = dict(update_indicies=self.update_indicies) + constant_kwargs = dict(update_indices=self.update_indices) # Get the objects made within the class object_kwargs = dict( atoms_list=self.atoms_list.copy(), features=self.features.copy(), targets=self.targets.copy(), - indicies=self.indicies.copy(), + indices=self.indices.copy(), ) return arg_kwargs, constant_kwargs, object_kwargs @@ -1163,38 +1162,38 @@ class DatabasePointsInterestEach(DatabasePointsInterest): and it is performed iteratively. """ - def make_reduction(self, all_indicies, **kwargs): + def make_reduction(self, all_indices, **kwargs): """ Reduce the training set with the points closest to the points of interests. """ # Check if there are points of interest else use the Parent class if len(self.point_interest) == 0: - return super().make_reduction(all_indicies, **kwargs) - # Get the fixed indicies - indicies = self.get_initial_indicies() - # Get the indicies for the system not already included - not_indicies = self.get_not_indicies(indicies, all_indicies) + return super().make_reduction(all_indices, **kwargs) + # Get the fixed indices + indices = self.get_initial_indices() + # Get the indices for the system not already included + not_indices = self.get_not_indices(indices, all_indices) # Include the last point - indicies = self.get_last_indicies(indicies, not_indicies) - # Get the indicies for the system not already included - not_indicies = array(self.get_not_indicies(indicies, all_indicies)) + indices = self.get_last_indices(indices, not_indices) + # Get the indices for the system not already included + not_indices = array(self.get_not_indices(indices, all_indices)) # Calculate the distances to the points of interest - dist = self.get_distances(not_indicies) + dist = self.get_distances(not_indices) # Get the number of points of interest n_points_interest = len(dist) # Iterate over the points of interests p = 0 - while len(indicies) < self.npoints: + while len(indices) < self.npoints: # Get the point with the minimum distance i_min = argmin(dist[p]) # Get and append the index - indicies = append(indicies, [not_indicies[i_min]]) + indices = append(indices, [not_indices[i_min]]) # Remove the index - not_indicies = delete(not_indicies, i_min) + not_indices = delete(not_indices, i_min) dist = delete(dist, i_min, axis=1) # Use the next point p += 1 if p >= n_points_interest: p = 0 - return array(indicies, dtype=int) + return array(indices, dtype=int) diff --git a/catlearn/regression/gp/calculator/mlmodel.py b/catlearn/regression/gp/calculator/mlmodel.py index f1d07a7c..bd9f864b 100644 --- a/catlearn/regression/gp/calculator/mlmodel.py +++ b/catlearn/regression/gp/calculator/mlmodel.py @@ -103,9 +103,9 @@ def train_model(self, **kwargs): self: The updated object itself. """ # Get data from the data base - features, targets = self.get_data() + features, targets, atoms_list = self.get_data() # Correct targets with the baseline - targets = self.get_baseline_corrected_targets(targets) + targets = self.get_baseline_corrected_targets(atoms_list, targets) # Train model if self.optimize: # Optimize the hyperparameters and train the ML model @@ -529,14 +529,12 @@ def add_baseline_correction( targets += asarray(y_base, dtype=self.dtype)[0] return targets - def get_baseline_corrected_targets(self, targets, **kwargs): + def get_baseline_corrected_targets(self, atoms_list, targets, **kwargs): """ Get the baseline corrected targets if a baseline is used. The baseline correction is subtracted from training targets. """ if self.use_baseline: - # Get the ASE atoms list from the database - atoms_list = self.get_data_atoms() # Calculate the baseline for each ASE atoms instance y_base = self.calculate_baseline( atoms_list, @@ -565,7 +563,7 @@ def calculate_baseline(self, atoms_list, use_derivatives=True, **kwargs): def not_masked_reshape(self, nm_array, not_masked, natoms, **kwargs): """ Reshape an array so that it works for all atom coordinates and - set constrained indicies to 0. + set constrained indices to 0. """ full_array = zeros((natoms, 3), dtype=self.dtype) full_array[not_masked] = nm_array.reshape(-1, 3) @@ -575,7 +573,8 @@ def get_data(self, **kwargs): "Get data from the data base." features = self.database.get_features() targets = self.database.get_targets() - return features, targets + atoms_list = self.get_data_atoms() + return features, targets, atoms_list def get_data_atoms(self, **kwargs): """ @@ -606,7 +605,7 @@ def make_targets(self, atoms, use_derivatives=True, **kwargs): def get_constraints(self, atoms, **kwargs): """ - Get the number of atoms and the indicies of + Get the number of atoms and the indices of the atoms without constraints. """ natoms = len(atoms) @@ -784,6 +783,7 @@ def get_default_model( parallel=False, n_reduced=None, round_hp=3, + seed=None, dtype=float, model_kwargs={}, prior_kwargs={}, @@ -821,6 +821,10 @@ def get_default_model( round_hp: int (optional) The number of decimals to round the hyperparameters to. If None, the hyperparameters are not rounded. + seed: int (optional) + The random seed for the optimization. + The seed an also be a RandomState or Generator instance. + If not given, the default random number generator is used. dtype: type The data type of the arrays. model_kwargs: dict (optional) @@ -1009,6 +1013,10 @@ def get_default_model( **hpfitter_kwargs, ) model.update_arguments(hpfitter=hpfitter) + # Set the seed for the model + if seed is not None: + model.set_seed(seed=seed) + # Return the model return model @@ -1017,6 +1025,7 @@ def get_default_database( use_derivatives=True, database_reduction=False, round_targets=5, + seed=None, dtype=float, **database_kwargs, ): @@ -1035,6 +1044,10 @@ def get_default_database( round_targets: int (optional) The number of decimals to round the targets to. If None, the targets are not rounded. + seed: int (optional) + The random seed for the optimization. + The seed an also be a RandomState or Generator instance. + If not given, the default random number generator is used. dtype: type The data type of the arrays. database_kwargs: dict (optional) @@ -1062,9 +1075,10 @@ def get_default_database( use_derivatives=use_derivatives, use_fingerprint=use_fingerprint, round_targets=round_targets, + seed=seed, dtype=dtype, npoints=50, - initial_indicies=[0, 1], + initial_indices=[0, 1], include_last=1, ) data_kwargs.update(database_kwargs) @@ -1112,6 +1126,7 @@ def get_default_database( use_derivatives=use_derivatives, use_fingerprint=use_fingerprint, round_targets=round_targets, + seed=seed, dtype=dtype, **data_kwargs, ) @@ -1136,6 +1151,7 @@ def get_default_mlmodel( round_targets=5, database_kwargs={}, verbose=False, + seed=None, dtype=float, **kwargs, ): @@ -1193,6 +1209,10 @@ def get_default_mlmodel( if it is used. verbose: bool Whether to print statements in the optimization. + seed: int (optional) + The random seed for the optimization. + The seed an also be a RandomState or Generator instance. + If not given, the default random number generator is used. dtype: type The data type of the arrays. kwargs: dict (optional) @@ -1218,6 +1238,7 @@ def get_default_mlmodel( parallel=parallel, n_reduced=n_reduced, round_hp=round_hp, + seed=seed, dtype=dtype, **all_model_kwargs, ) @@ -1227,6 +1248,7 @@ def get_default_mlmodel( use_derivatives=use_derivatives, database_reduction=database_reduction, round_targets=round_targets, + seed=seed, dtype=dtype, **database_kwargs, ) From 416ecfdc819a3a5a18796588d2f81f279ede188d Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Wed, 23 Jul 2025 12:54:57 +0200 Subject: [PATCH 130/194] Debug in reduced database, debug fingerprint for when all atoms are fixed, and misspelling --- catlearn/regression/gp/calculator/database.py | 4 +- .../gp/ensemble/clustering/clustering.py | 6 +- .../gp/ensemble/clustering/fixed.py | 6 +- .../gp/ensemble/clustering/k_means.py | 10 +-- .../gp/ensemble/clustering/k_means_auto.py | 51 ++++++------ .../gp/ensemble/clustering/k_means_number.py | 49 ++++++------ .../gp/ensemble/clustering/random.py | 32 ++++---- .../gp/ensemble/clustering/random_number.py | 24 +++--- .../gp/ensemble/ensemble_clustering.py | 6 +- .../regression/gp/fingerprint/distances.py | 38 +++++---- .../regression/gp/fingerprint/fpwrapper.py | 14 +++- .../regression/gp/fingerprint/geometry.py | 78 +++++++++---------- .../gp/fingerprint/meandistances.py | 30 +++---- .../gp/fingerprint/meandistancespower.py | 30 +++---- .../gp/fingerprint/sorteddistances.py | 32 ++++---- .../regression/gp/fingerprint/sumdistances.py | 64 +++++++-------- .../gp/fingerprint/sumdistancespower.py | 36 ++++----- .../regression/gp/objectivefunctions/batch.py | 22 +++--- .../gp/objectivefunctions/best_batch.py | 4 +- .../regression/gp/optimizers/linesearcher.py | 8 +- tests/test_gp_calc.py | 36 ++++----- 21 files changed, 293 insertions(+), 287 deletions(-) diff --git a/catlearn/regression/gp/calculator/database.py b/catlearn/regression/gp/calculator/database.py index e9f4ecbb..93dbdd68 100644 --- a/catlearn/regression/gp/calculator/database.py +++ b/catlearn/regression/gp/calculator/database.py @@ -101,7 +101,7 @@ def add_set(self, atoms_list, **kwargs): def get_constraints(self, atoms, **kwargs): """ - Get the indicies of the atoms that does not have fixed constraints. + Get the indices of the atoms that does not have fixed constraints. Parameters: atoms: ASE Atoms @@ -109,7 +109,7 @@ def get_constraints(self, atoms, **kwargs): Returns: not_masked: list - A list of indicies for the moving atoms. + A list of indices for the moving atoms. """ not_masked = list(range(len(atoms))) if not self.reduce_dimensions: diff --git a/catlearn/regression/gp/ensemble/clustering/clustering.py b/catlearn/regression/gp/ensemble/clustering/clustering.py index 78fcc962..8049c9fa 100644 --- a/catlearn/regression/gp/ensemble/clustering/clustering.py +++ b/catlearn/regression/gp/ensemble/clustering/clustering.py @@ -3,7 +3,7 @@ class Clustering: """ - Clustering algorithn class for data sets. + Clustering algorithm class for data sets. """ def __init__( @@ -53,7 +53,7 @@ def cluster_fit_data(self, X, **kwargs): Training features with N data points. Returns: - list: A list of indicies to the training data for each cluster. + list: A list of indices to the training data for each cluster. """ raise NotImplementedError() @@ -66,7 +66,7 @@ def cluster(self, X, **kwargs): Features with M data points. Returns: - list: A list of indicies to the data for each cluster. + list: A list of indices to the data for each cluster. """ raise NotImplementedError() diff --git a/catlearn/regression/gp/ensemble/clustering/fixed.py b/catlearn/regression/gp/ensemble/clustering/fixed.py index 72a22b7f..dfefe48f 100644 --- a/catlearn/regression/gp/ensemble/clustering/fixed.py +++ b/catlearn/regression/gp/ensemble/clustering/fixed.py @@ -4,7 +4,7 @@ class FixedClustering(K_means): """ - Clustering algorithn class for data sets. + Clustering algorithm class for data sets. It uses the distances to pre-defined fixed centroids for clustering. """ @@ -43,9 +43,9 @@ def __init__( ) def cluster_fit_data(self, X, **kwargs): - indicies = arange(len(X)) + indices = arange(len(X)) i_min = argmin(self.calculate_distances(X, self.centroids), axis=1) - return [indicies[i_min == ki] for ki in range(self.n_clusters)] + return [indices[i_min == ki] for ki in range(self.n_clusters)] def update_arguments( self, metric=None, centroids=None, seed=None, dtype=None, **kwargs diff --git a/catlearn/regression/gp/ensemble/clustering/k_means.py b/catlearn/regression/gp/ensemble/clustering/k_means.py index f1efde68..78ff5a42 100644 --- a/catlearn/regression/gp/ensemble/clustering/k_means.py +++ b/catlearn/regression/gp/ensemble/clustering/k_means.py @@ -6,7 +6,7 @@ class K_means(Clustering): """ - Clustering algorithn class for data sets. + Clustering algorithm class for data sets. It uses the K-means++ algorithm for clustering. """ @@ -57,7 +57,7 @@ def __init__( def cluster_fit_data(self, X, **kwargs): # Copy the data X = array(X, dtype=self.dtype) - # If only one cluster is used give the full data + # If only one cluster is used, give the full data if self.n_clusters == 1: self.centroids = asarray([X.mean(axis=0)]) return [arange(len(X))] @@ -65,13 +65,13 @@ def cluster_fit_data(self, X, **kwargs): centroids = self.initiate_centroids(X) # Optimize position of the centroids self.centroids = self.optimize_centroids(X, centroids) - # Return the cluster indicies + # Return the cluster indices return self.cluster(X) def cluster(self, X, **kwargs): - indicies = arange(len(X)) + indices = arange(len(X)) i_min = argmin(self.calculate_distances(X, self.centroids), axis=1) - return [indicies[i_min == ki] for ki in range(self.n_clusters)] + return [indices[i_min == ki] for ki in range(self.n_clusters)] def set_centroids(self, centroids, **kwargs): """ diff --git a/catlearn/regression/gp/ensemble/clustering/k_means_auto.py b/catlearn/regression/gp/ensemble/clustering/k_means_auto.py index ed5bb439..7b585ebb 100644 --- a/catlearn/regression/gp/ensemble/clustering/k_means_auto.py +++ b/catlearn/regression/gp/ensemble/clustering/k_means_auto.py @@ -5,7 +5,7 @@ class K_means_auto(K_means): """ - Clustering algorithn class for data sets. + Clustering algorithm class for data sets. It uses the K-means++ algorithm for clustering. It uses a interval of number of data points in each cluster. """ @@ -59,19 +59,19 @@ def cluster_fit_data(self, X, **kwargs): X = array(X, dtype=self.dtype) # Calculate the number of clusters self.n_clusters = self.calc_n_clusters(X) - # If only one cluster is used give the full data + # If only one cluster is used, give the full data if self.n_clusters == 1: self.centroids = asarray([X.mean(axis=0)]) return [arange(len(X))] # Initiate the centroids centroids = self.initiate_centroids(X) # Optimize position of the centroids - self.centroids, cluster_indicies = self.optimize_centroids( + self.centroids, cluster_indices = self.optimize_centroids( X, centroids, ) - # Return the cluster indicies - return cluster_indicies + # Return the cluster indices + return cluster_indices def update_arguments( self, @@ -140,57 +140,54 @@ def calc_n_clusters(self, X, **kwargs): def optimize_centroids(self, X, centroids, **kwargs): "Optimize the positions of the centroids." - indicies = arange(len(X)) + indices = arange(len(X)) for _ in range(1, self.maxiter + 1): # Store the old centroids centroids_old = centroids.copy() # Calculate which centroids that are closest distance_matrix = self.calculate_distances(X, centroids) - cluster_indicies = self.count_clusters( + cluster_indices = self.count_clusters( X, - indicies, + indices, distance_matrix, ) centroids = asarray( - [ - X[indicies_ki].mean(axis=0) - for indicies_ki in cluster_indicies - ] + [X[indices_ki].mean(axis=0) for indices_ki in cluster_indices] ) # Check if it is converged if norm(centroids - centroids_old) <= self.tol: break - return centroids, cluster_indicies + return centroids, cluster_indices - def count_clusters(self, X, indicies, distance_matrix, **kwargs): + def count_clusters(self, X, indices, distance_matrix, **kwargs): """ - Get the indicies for each of the clusters. + Get the indices for each of the clusters. The number of data points in each cluster is counted and restricted between the minimum and maximum number of allowed cluster sizes. """ - # Make a list cluster indicies + # Make a list cluster indices klist = arange(self.n_clusters).reshape(-1, 1) # Find the cluster that each point is closest to - k_indicies = argmin(distance_matrix, axis=1) - indicies_ki_bool = klist == k_indicies + k_indices = argmin(distance_matrix, axis=1) + indices_ki_bool = klist == k_indices # Check the number of points per cluster - n_ki = indicies_ki_bool.sum(axis=1) + n_ki = indices_ki_bool.sum(axis=1) # Ensure the number is within the conditions n_ki[n_ki > self.max_data] = self.max_data n_ki[n_ki < self.min_data] = self.min_data - # Sort the indicies as function of the distances to the centroids - d_indicies = argsort(distance_matrix, axis=0) - indicies_sorted = indicies[d_indicies.T] - indicies_ki_bool = indicies_ki_bool[klist, indicies_sorted] + # Sort the indices as function of the distances to the centroids + d_indices = argsort(distance_matrix, axis=0) + indices_sorted = indices[d_indices.T] + indices_ki_bool = indices_ki_bool[klist, indices_sorted] # Prioritize the points that is part of each cluster - cluster_indicies = [ + cluster_indices = [ append( - indicies_sorted[ki, indicies_ki_bool[ki]], - indicies_sorted[ki, ~indicies_ki_bool[ki]], + indices_sorted[ki, indices_ki_bool[ki]], + indices_sorted[ki, ~indices_ki_bool[ki]], )[: n_ki[ki]] for ki in range(self.n_clusters) ] - return cluster_indicies + return cluster_indices def get_arguments(self): "Get the arguments of the class itself." diff --git a/catlearn/regression/gp/ensemble/clustering/k_means_number.py b/catlearn/regression/gp/ensemble/clustering/k_means_number.py index eeed21b6..adb0833f 100644 --- a/catlearn/regression/gp/ensemble/clustering/k_means_number.py +++ b/catlearn/regression/gp/ensemble/clustering/k_means_number.py @@ -5,7 +5,7 @@ class K_means_number(K_means): """ - Clustering algorithn class for data sets. + Clustering algorithm class for data sets. It uses the K-means++ algorithm for clustering. It uses a fixed number of data points in each cluster. """ @@ -55,19 +55,19 @@ def cluster_fit_data(self, X, **kwargs): X = array(X, dtype=self.dtype) # Calculate the number of clusters self.n_clusters = self.calc_n_clusters(X) - # If only one cluster is used give the full data + # If only one cluster is used, give the full data if self.n_clusters == 1: self.centroids = asarray([X.mean(axis=0)]) return [arange(len(X))] # Initiate the centroids centroids = self.initiate_centroids(X) # Optimize position of the centroids - self.centroids, cluster_indicies = self.optimize_centroids( + self.centroids, cluster_indices = self.optimize_centroids( X, centroids, ) - # Return the cluster indicies - return cluster_indicies + # Return the cluster indices + return cluster_indices def update_arguments( self, @@ -128,52 +128,49 @@ def calc_n_clusters(self, X, **kwargs): def optimize_centroids(self, X, centroids, **kwargs): "Optimize the positions of the centroids." - indicies = arange(len(X)) + indices = arange(len(X)) for _ in range(1, self.maxiter + 1): # Store the old centroids centroids_old = centroids.copy() # Calculate which centroids that are closest distance_matrix = self.calculate_distances(X, centroids) - cluster_indicies = self.count_clusters( + cluster_indices = self.count_clusters( X, - indicies, + indices, distance_matrix, ) centroids = asarray( - [ - X[indicies_ki].mean(axis=0) - for indicies_ki in cluster_indicies - ] + [X[indices_ki].mean(axis=0) for indices_ki in cluster_indices] ) # Check if it is converged if norm(centroids - centroids_old) <= self.tol: break - return centroids, cluster_indicies + return centroids, cluster_indices - def count_clusters(self, X, indicies, distance_matrix, **kwargs): + def count_clusters(self, X, indices, distance_matrix, **kwargs): """ - Get the indicies for each of the clusters. + Get the indices for each of the clusters. The number of data points in each cluster is counted and restricted between the minimum and maximum number of allowed cluster sizes. """ - # Make a list cluster indicies + # Make a list cluster indices klist = arange(self.n_clusters).reshape(-1, 1) # Find the cluster that each point is closest to - k_indicies = argmin(distance_matrix, axis=1) - indicies_ki_bool = klist == k_indicies - # Sort the indicies as function of the distances to the centroids - d_indicies = argsort(distance_matrix, axis=0) - indicies_sorted = indicies[d_indicies.T] - indicies_ki_bool = indicies_ki_bool[klist, indicies_sorted] + k_indices = argmin(distance_matrix, axis=1) + indices_ki_bool = klist == k_indices + # Sort the indices as function of the distances to the centroids + d_indices = argsort(distance_matrix, axis=0) + indices_sorted = indices[d_indices.T] + indices_ki_bool = indices_ki_bool[klist, indices_sorted] # Prioritize the points that is part of each cluster - cluster_indicies = [ + cluster_indices = [ append( - indicies_sorted[ki, indicies_ki_bool[ki]], - indicies_sorted[ki, ~indicies_ki_bool[ki]], + indices_sorted[ki, indices_ki_bool[ki]], + indices_sorted[ki, ~indices_ki_bool[ki]], )[: self.data_number] for ki in range(self.n_clusters) ] - return cluster_indicies + return cluster_indices def get_arguments(self): "Get the arguments of the class itself." diff --git a/catlearn/regression/gp/ensemble/clustering/random.py b/catlearn/regression/gp/ensemble/clustering/random.py index b99f9bcd..6a9939a7 100644 --- a/catlearn/regression/gp/ensemble/clustering/random.py +++ b/catlearn/regression/gp/ensemble/clustering/random.py @@ -4,7 +4,7 @@ class RandomClustering(Clustering): """ - Clustering algorithn class for data sets. + Clustering algorithm class for data sets. It uses randomized clusters for clustering. """ @@ -41,15 +41,15 @@ def __init__( ) def cluster_fit_data(self, X, **kwargs): - # Make indicies + # Make indices n_data = len(X) - indicies = arange(n_data) - # If only one cluster is used give the full data + indices = arange(n_data) + # If only one cluster is used, give the full data if self.n_clusters == 1: - return [indicies] + return [indices] # Randomly make clusters - i_clusters = self.randomized_clusters(indicies, n_data) - # Return the cluster indicies + i_clusters = self.randomized_clusters(indices, n_data) + # Return the cluster indices return i_clusters def cluster(self, X, **kwargs): @@ -94,22 +94,22 @@ def update_arguments( ) return self - def randomized_clusters(self, indicies, n_data, **kwargs): - "Randomized indicies used for each cluster." - # Permute the indicies - i_perm = self.get_permutation(indicies) + def randomized_clusters(self, indices, n_data, **kwargs): + "Randomized indices used for each cluster." + # Permute the indices + i_perm = self.get_permutation(indices) # Ensure equal sizes of clusters if chosen if self.equal_size: i_perm = self.ensure_equal_sizes(i_perm, n_data) i_clusters = array_split(i_perm, self.n_clusters) return i_clusters - def get_permutation(self, indicies): - "Permute the indicies" - return self.rng.permutation(indicies) + def get_permutation(self, indices): + "Permute the indices" + return self.rng.permutation(indices) def ensure_equal_sizes(self, i_perm, n_data, **kwargs): - "Extend the permuted indicies so the clusters have equal sizes." + "Extend the permuted indices so the clusters have equal sizes." # Find the number of excess points left n_left = n_data % self.n_clusters # Find the number of points that should be added @@ -117,7 +117,7 @@ def ensure_equal_sizes(self, i_perm, n_data, **kwargs): n_missing = self.n_clusters - n_left else: n_missing = 0 - # Extend the permuted indicies + # Extend the permuted indices if n_missing > 0: if n_missing > n_data: i_perm = append( diff --git a/catlearn/regression/gp/ensemble/clustering/random_number.py b/catlearn/regression/gp/ensemble/clustering/random_number.py index eb08893b..fed91677 100644 --- a/catlearn/regression/gp/ensemble/clustering/random_number.py +++ b/catlearn/regression/gp/ensemble/clustering/random_number.py @@ -4,7 +4,7 @@ class RandomClustering_number(RandomClustering): """ - Clustering algorithn class for data sets. + Clustering algorithm class for data sets. It uses randomized clusters for clustering. It uses a fixed number of data points in each cluster. """ @@ -32,19 +32,19 @@ def __init__(self, data_number=25, seed=None, dtype=float, **kwargs): ) def cluster_fit_data(self, X, **kwargs): - # Make indicies + # Make indices n_data = len(X) - indicies = arange(n_data) + indices = arange(n_data) # Calculate the number of clusters self.n_clusters = int(n_data // self.data_number) if n_data - (self.n_clusters * self.data_number): self.n_clusters = self.n_clusters + 1 - # If only one cluster is used give the full data + # If only one cluster is used, give the full data if self.n_clusters == 1: - return [indicies] + return [indices] # Randomly make clusters - i_clusters = self.randomized_clusters(indicies, n_data) - # Return the cluster indicies + i_clusters = self.randomized_clusters(indices, n_data) + # Return the cluster indices return i_clusters def update_arguments( @@ -81,19 +81,19 @@ def update_arguments( ) return self - def randomized_clusters(self, indicies, n_data, **kwargs): - # Permute the indicies - i_perm = self.get_permutation(indicies) + def randomized_clusters(self, indices, n_data, **kwargs): + # Permute the indices + i_perm = self.get_permutation(indices) # Ensure equal sizes of clusters i_perm = self.ensure_equal_sizes(i_perm, n_data) i_clusters = array_split(i_perm, self.n_clusters) return i_clusters def ensure_equal_sizes(self, i_perm, n_data, **kwargs): - "Extend the permuted indicies so the clusters have equal sizes." + "Extend the permuted indices so the clusters have equal sizes." # Find the number of points that should be added n_missing = (self.n_clusters * self.data_number) - n_data - # Extend the permuted indicies + # Extend the permuted indices if n_missing > 0: if n_missing > n_data: i_perm = append( diff --git a/catlearn/regression/gp/ensemble/ensemble_clustering.py b/catlearn/regression/gp/ensemble/ensemble_clustering.py index f2b51b53..f5a64302 100644 --- a/catlearn/regression/gp/ensemble/ensemble_clustering.py +++ b/catlearn/regression/gp/ensemble/ensemble_clustering.py @@ -201,10 +201,10 @@ def cluster(self, features, targets, **kwargs): [feature.get_vector() for feature in features], dtype=self.dtype, ) - cluster_indicies = self.clustering.cluster_fit_data(X) + cluster_indices = self.clustering.cluster_fit_data(X) return [ - (features[indicies_ki], targets[indicies_ki]) - for indicies_ki in cluster_indicies + (features[indices_ki], targets[indices_ki]) + for indices_ki in cluster_indices ] def get_arguments(self): diff --git a/catlearn/regression/gp/fingerprint/distances.py b/catlearn/regression/gp/fingerprint/distances.py index 43319dab..6a8841bb 100644 --- a/catlearn/regression/gp/fingerprint/distances.py +++ b/catlearn/regression/gp/fingerprint/distances.py @@ -4,7 +4,7 @@ get_all_distances, get_constraints, get_covalent_distances, - get_mask_indicies, + get_mask_indices, get_periodic_softmax, get_periodic_sum, ) @@ -100,7 +100,13 @@ def make_fingerprint(self, atoms, **kwargs): atoms, reduce_dimensions=self.reduce_dimensions, ) - # Initialize the masking and indicies + # Check if there are any not masked atoms + if len(not_masked) == 0: + fp = zeros((0), dtype=self.dtype) + if self.use_derivatives: + return fp, zeros((0, 0), dtype=self.dtype) + return fp, None + # Initialize the masking and indices ( not_masked, masked, @@ -108,7 +114,7 @@ def make_fingerprint(self, atoms, **kwargs): nmj, nmi_ind, nmj_ind, - ) = get_mask_indicies(atoms, not_masked=not_masked, masked=masked) + ) = get_mask_indices(atoms, not_masked=not_masked, masked=masked) # Get the periodicity pbc = atoms.pbc # Check what distance method should be used @@ -367,7 +373,7 @@ def element_setup( self.atomic_numbers is not None or self.not_masked is not None or self.tags is not None - or self.split_indicies is not None + or self.split_indices is not None ): atoms_equal = check_atoms( atomic_numbers=self.atomic_numbers, @@ -379,7 +385,7 @@ def element_setup( **kwargs, ) if atoms_equal: - return self.split_indicies + return self.split_indices # Save the atomic setup self.atomic_numbers = atomic_numbers self.not_masked = not_masked @@ -395,28 +401,28 @@ def element_setup( combis_m = list(zip(atomic_numbers[masked], tags[masked])) else: combis_m = [] - split_indicies = {} + split_indices = {} t = 0 for i, i_nm in enumerate(combis_nm): i1 = i + 1 for j_m in combis_m: - split_indicies.setdefault(i_nm + j_m, []).append(t) + split_indices.setdefault(i_nm + j_m, []).append(t) t += 1 for j_nm in combis_nm[i1:]: - split_indicies.setdefault(i_nm + j_nm, []).append(t) + split_indices.setdefault(i_nm + j_nm, []).append(t) t += 1 # Include the neighboring cells if use_include_ncells and c_dim is not None: n_combi = full((c_dim, 1), t, dtype=int) - split_indicies = { + split_indices = { k: (asarray(v) + n_combi).reshape(-1) - for k, v in split_indicies.items() + for k, v in split_indices.items() } else: - split_indicies = {k: asarray(v) for k, v in split_indicies.items()} - # Save the split indicies - self.split_indicies = split_indicies - return split_indicies + split_indices = {k: asarray(v) for k, v in split_indices.items()} + # Save the split indices + self.split_indices = split_indices + return split_indices def update_arguments( self, @@ -509,8 +515,8 @@ def update_arguments( self.atomic_numbers = None if not hasattr(self, "tags"): self.tags = None - if not hasattr(self, "split_indicies"): - self.split_indicies = None + if not hasattr(self, "split_indices"): + self.split_indices = None # Tags is not implemented self.use_tags = False self.reuse_combinations = False diff --git a/catlearn/regression/gp/fingerprint/fpwrapper.py b/catlearn/regression/gp/fingerprint/fpwrapper.py index 468bd6b6..03cb6929 100644 --- a/catlearn/regression/gp/fingerprint/fpwrapper.py +++ b/catlearn/regression/gp/fingerprint/fpwrapper.py @@ -1,6 +1,6 @@ from .fingerprint import Fingerprint from .geometry import get_constraints -from numpy import asarray, concatenate, transpose +from numpy import asarray, concatenate, transpose, zeros class FingerprintWrapperGPAtom(Fingerprint): @@ -86,6 +86,12 @@ def make_fingerprint(self, atoms, **kwargs): atoms, reduce_dimensions=self.reduce_dimensions, ) + # Check if there are any not masked atoms + if len(not_masked) == 0: + fp = zeros((0), dtype=self.dtype) + if self.use_derivatives: + return fp, zeros((0, 0), dtype=self.dtype) + return fp, None # Get the fingerprint fp = self.fingerprint( atoms, @@ -212,6 +218,12 @@ def make_fingerprint(self, atoms, **kwargs): atoms, reduce_dimensions=self.reduce_dimensions, ) + # Check if there are any not masked atoms + if len(not_masked) == 0: + fp = zeros((0), dtype=self.dtype) + if self.use_derivatives: + return fp, zeros((0, 0), dtype=self.dtype) + return fp, None # Get the fingerprint if self.use_derivatives: derivative, vector = self.fingerprint.derivatives( diff --git a/catlearn/regression/gp/fingerprint/geometry.py b/catlearn/regression/gp/fingerprint/geometry.py index 39a536de..9ea110a1 100644 --- a/catlearn/regression/gp/fingerprint/geometry.py +++ b/catlearn/regression/gp/fingerprint/geometry.py @@ -22,7 +22,7 @@ def get_constraints(atoms, reduce_dimensions=True, **kwargs): """ - Get the indicies of the atoms that does not have fixed constraints. + Get the indices of the atoms that does not have fixed constraints. Parameters: atoms: ASE Atoms @@ -32,9 +32,9 @@ def get_constraints(atoms, reduce_dimensions=True, **kwargs): Returns: not_masked: (Nnm) list - A list of indicies for the moving atoms if constraints are used. + A list of indices for the moving atoms if constraints are used. masked: (Nm) list - A list of indicies for the fixed atoms if constraints are used. + A list of indices for the fixed atoms if constraints are used. """ not_masked = list(range(len(atoms))) @@ -54,7 +54,7 @@ def get_constraints(atoms, reduce_dimensions=True, **kwargs): return asarray(not_masked), asarray(masked) -def get_mask_indicies( +def get_mask_indices( atoms, not_masked=None, masked=None, @@ -65,36 +65,36 @@ def get_mask_indicies( **kwargs, ): """ - Get the indicies of the atoms that are masked and not masked. + Get the indices of the atoms that are masked and not masked. Parameters: atoms: ASE Atoms The ASE Atoms instance. not_masked: (Nnm) list (optional) - A list of indicies for the moving atoms if constraints are used. + A list of indices for the moving atoms if constraints are used. Else all atoms are treated to be moving. masked: (Nn) list (optional) - A list of indicies for the fixed atoms if constraints are used. + A list of indices for the fixed atoms if constraints are used. nmi: list (optional) - The upper triangle indicies of the not masked atoms. + The upper triangle indices of the not masked atoms. nmj: list (optional) - The upper triangle indicies of the not masked atoms. + The upper triangle indices of the not masked atoms. nmi_ind: list (optional) - The indicies of the not masked atoms. + The indices of the not masked atoms. nmj_ind: list (optional) - The indicies of the not masked atoms. + The indices of the not masked atoms. Returns: not_masked: (Nnm) list - A list of indicies for the moving atoms if constraints are used. + A list of indices for the moving atoms if constraints are used. masked: (Nm) list - A list of indicies for the fixed atoms if constraints are used. + A list of indices for the fixed atoms if constraints are used. nmi: list - The upper triangle indicies of the not masked atoms. + The upper triangle indices of the not masked atoms. nmi_ind: list - The indicies of the not masked atoms. + The indices of the not masked atoms. nmj_ind: list - The indicies of the not masked atoms. + The indices of the not masked atoms. """ # If a not masked list is given, all atoms is treated to be not masked if not_masked is None: @@ -104,7 +104,7 @@ def get_mask_indicies( masked = asarray( list(set(range(len(atoms))).difference(set(not_masked))) ) - # Make indicies of not masked atoms with itself + # Make indices of not masked atoms with itself if nmi is None or nmj is None or nmi_ind is None or nmj_ind is None: nmi, nmj = triu_indices(len(not_masked), k=1, m=None) nmi_ind = not_masked[nmi] @@ -146,9 +146,9 @@ def check_atoms( pbc_test: (3) list (optional) The periodic boundary conditions of the tested atoms. not_masked: (Nnm) list (optional) - A list of indicies for the moving atoms if constraints are used. + A list of indices for the moving atoms if constraints are used. not_masked_test: (Nnm) list (optional) - A list of indicies for the moving atoms if constraints + A list of indices for the moving atoms if constraints are used in the tested atoms. Returns: @@ -262,7 +262,7 @@ def get_full_distance_matrix( atoms: ASE Atoms The ASE Atoms instance. not_masked: Nnm list (optional) - A list of indicies for the moving atoms if constraints are used. + A list of indices for the moving atoms if constraints are used. Else all atoms are treated to be moving. use_vector: bool If the distance vectors should be returned. @@ -358,16 +358,16 @@ def get_all_distances( atoms: ASE Atoms The ASE Atoms instance. not_masked: Nnm list (optional) - A list of indicies for the moving atoms if constraints are used. + A list of indices for the moving atoms if constraints are used. Else all atoms are treated to be moving. masked: Nm list (optional) - A list of indicies for the fixed atoms if constraints are used. + A list of indices for the fixed atoms if constraints are used. nmi: list (optional) - The upper triangle indicies of the not masked atoms. + The upper triangle indices of the not masked atoms. nmi_ind: list (optional) - The indicies of the not masked atoms. + The indices of the not masked atoms. nmj_ind: list (optional) - The indicies of the not masked atoms. + The indices of the not masked atoms. use_vector: bool If the distance vectors should be returned. wrap: bool @@ -390,8 +390,8 @@ def get_all_distances( (Nc, Nnm*N+(Nnm*(Nnm-1)/2), 3) array The unique distances with directions if use_vector=True. """ - # Make indicies - not_masked, masked, nmi, _, nmi_ind, nmj_ind = get_mask_indicies( + # Make indices + not_masked, masked, nmi, _, nmi_ind, nmj_ind = get_mask_indices( atoms, not_masked=not_masked, masked=masked, @@ -470,13 +470,13 @@ def get_distances( pos: (N, 3) array The atomic positions. not_masked: Nnm list - A list of indicies for the moving atoms if constraints are used. + A list of indices for the moving atoms if constraints are used. masked: Nm list - A list of indicies for the fixed atoms if constraints are used. + A list of indices for the fixed atoms if constraints are used. nmi: list - The upper triangle indicies of the not masked atoms. + The upper triangle indices of the not masked atoms. nmj_ind: list - The indicies of the not masked atoms. + The indices of the not masked atoms. Returns: dist: (Nnm*Nm+(Nnm*(Nnm-1)/2)) array @@ -511,13 +511,13 @@ def get_distance_vectors( pos: (N, 3) array The atomic positions. not_masked: Nnm list - A list of indicies for the moving atoms if constraints are used. + A list of indices for the moving atoms if constraints are used. masked: Nm list - A list of indicies for the fixed atoms if constraints are used. + A list of indices for the fixed atoms if constraints are used. nmi_ind: list - The indicies of the not masked atoms. + The indices of the not masked atoms. nmj_ind: list - The indicies of the not masked atoms. + The indices of the not masked atoms. Returns: dist_vec: (Nnm*Nm+(Nnm*(Nnm-1)/2), 3) array @@ -554,13 +554,13 @@ def get_covalent_distances( atomic_numbers: (N) list The atomic numbers of the atoms. not_masked: Nnm list - A list of indicies for the moving atoms if constraints are used. + A list of indices for the moving atoms if constraints are used. masked: Nm list - A list of indicies for the fixed atoms if constraints are used. + A list of indices for the fixed atoms if constraints are used. nmi_ind: list - The indicies of the not masked atoms. + The indices of the not masked atoms. nmj_ind: list - The indicies of the not masked atoms. + The indices of the not masked atoms. dtype: type The data type of the arrays. diff --git a/catlearn/regression/gp/fingerprint/meandistances.py b/catlearn/regression/gp/fingerprint/meandistances.py index 0184a951..35223f98 100644 --- a/catlearn/regression/gp/fingerprint/meandistances.py +++ b/catlearn/regression/gp/fingerprint/meandistances.py @@ -16,8 +16,8 @@ def modify_fp_pairs( g, not_masked, use_include_ncells, - split_indicies_nm, - split_indicies, + split_indices_nm, + split_indices, **kwargs, ): # Mean the fingerprints and derivatives if neighboring cells are used @@ -27,7 +27,7 @@ def modify_fp_pairs( g = g.mean(axis=0) # Make the new fingerprint fp_new = zeros( - (len(split_indicies_nm), len(split_indicies)), + (len(split_indices_nm), len(split_indices)), dtype=self.dtype, ) # Calculate the new derivatives @@ -35,30 +35,30 @@ def modify_fp_pairs( # Make the new derivatives g_new = zeros( ( - len(split_indicies_nm), - len(split_indicies), + len(split_indices_nm), + len(split_indices), len(not_masked), 3, ), dtype=self.dtype, ) # Mean the fingerprint and derivatives - for i, i_v in enumerate(split_indicies_nm.values()): + for i, i_v in enumerate(split_indices_nm.values()): fp_i = fp[i_v] g_i = g[i_v] g_ij = g_i[:, not_masked].sum(axis=0) - for j, (comb, j_v) in enumerate(split_indicies.items()): + for j, (comb, j_v) in enumerate(split_indices.items()): fp_new[i, j] = fp_i[:, j_v].mean() n_comb = len(i_v) * len(j_v) g_new[i, j, i_v] = g_i[:, j_v].sum(axis=1) / n_comb - if comb in split_indicies_nm: - ij_comb = split_indicies_nm[comb] + if comb in split_indices_nm: + ij_comb = split_indices_nm[comb] g_new[i, j, ij_comb] -= g_ij[ij_comb] / n_comb return fp_new.reshape(-1), g_new.reshape(-1, len(not_masked) * 3) # Mean the fingerprints - for i, i_v in enumerate(split_indicies_nm.values()): + for i, i_v in enumerate(split_indices_nm.values()): fp_i = fp[i_v] - for j, j_v in enumerate(split_indicies.values()): + for j, j_v in enumerate(split_indices.values()): fp_new[i, j] = fp_i[:, j_v].mean() return fp_new.reshape(-1), None @@ -68,7 +68,7 @@ def modify_fp_elements( g, not_masked, use_include_ncells, - split_indicies_nm, + split_indices_nm, **kwargs, ): # Mean the fingerprints and derivatives if neighboring cells are used @@ -80,13 +80,13 @@ def modify_fp_elements( n_atoms = fp.shape[1] fp = fp.mean(axis=1) fp = asarray( - [fp[i_v].mean() for i_v in split_indicies_nm.values()], + [fp[i_v].mean() for i_v in split_indices_nm.values()], dtype=self.dtype, ) # Calculate the new derivatives if g is not None: - g_new = zeros((len(split_indicies_nm), len(not_masked), 3)) - for i, i_v in enumerate(split_indicies_nm.values()): + g_new = zeros((len(split_indices_nm), len(not_masked), 3)) + for i, i_v in enumerate(split_indices_nm.values()): g_new[i, i_v] = g[i_v].sum(axis=1) g_new[i] -= g[i_v][:, not_masked].sum(axis=0) g_new[i] /= len(i_v) * n_atoms diff --git a/catlearn/regression/gp/fingerprint/meandistancespower.py b/catlearn/regression/gp/fingerprint/meandistancespower.py index e8f521c7..3bd34409 100644 --- a/catlearn/regression/gp/fingerprint/meandistancespower.py +++ b/catlearn/regression/gp/fingerprint/meandistancespower.py @@ -17,8 +17,8 @@ def modify_fp_pairs( g, not_masked, use_include_ncells, - split_indicies_nm, - split_indicies, + split_indices_nm, + split_indices, **kwargs, ): # Mean the fingerprints and derivatives if neighboring cells are used @@ -28,7 +28,7 @@ def modify_fp_pairs( g = g.mean(axis=0) # Make the new fingerprint fp_new = zeros( - (len(split_indicies_nm), len(split_indicies)), + (len(split_indices_nm), len(split_indices)), dtype=self.dtype, ) # Calculate the new derivatives @@ -36,30 +36,30 @@ def modify_fp_pairs( # Make the new derivatives g_new = zeros( ( - len(split_indicies_nm), - len(split_indicies), + len(split_indices_nm), + len(split_indices), len(not_masked), 3, ), dtype=self.dtype, ) # Mean the fingerprint and derivatives - for i, i_v in enumerate(split_indicies_nm.values()): + for i, i_v in enumerate(split_indices_nm.values()): fp_i = fp[i_v] g_i = g[i_v] g_ij = g_i[:, not_masked].sum(axis=0) - for j, (comb, j_v) in enumerate(split_indicies.items()): + for j, (comb, j_v) in enumerate(split_indices.items()): fp_new[i, j] = fp_i[:, j_v].mean() n_comb = len(i_v) * len(j_v) g_new[i, j, i_v] = g_i[:, j_v].sum(axis=1) / n_comb - if comb in split_indicies_nm: - ij_comb = split_indicies_nm[comb] + if comb in split_indices_nm: + ij_comb = split_indices_nm[comb] g_new[i, j, ij_comb] -= g_ij[ij_comb] / n_comb return fp_new.reshape(-1), g_new.reshape(-1, len(not_masked) * 3) # Mean the fingerprints - for i, i_v in enumerate(split_indicies_nm.values()): + for i, i_v in enumerate(split_indices_nm.values()): fp_i = fp[i_v] - for j, j_v in enumerate(split_indicies.values()): + for j, j_v in enumerate(split_indices.values()): fp_new[i, j] = fp_i[:, j_v].mean() return fp_new.reshape(-1), None @@ -69,7 +69,7 @@ def modify_fp_elements( g, not_masked, use_include_ncells, - split_indicies_nm, + split_indices_nm, **kwargs, ): # Mean the fingerprints and derivatives if neighboring cells are used @@ -81,13 +81,13 @@ def modify_fp_elements( n_atoms = fp.shape[1] fp = fp.mean(axis=1) fp = asarray( - [fp[i_v].mean() for i_v in split_indicies_nm.values()], + [fp[i_v].mean() for i_v in split_indices_nm.values()], dtype=self.dtype, ) # Calculate the new derivatives if g is not None: - g_new = zeros((len(split_indicies_nm), len(not_masked), 3)) - for i, i_v in enumerate(split_indicies_nm.values()): + g_new = zeros((len(split_indices_nm), len(not_masked), 3)) + for i, i_v in enumerate(split_indices_nm.values()): g_new[i, i_v] = g[i_v].sum(axis=1) g_new[i] -= g[i_v][:, not_masked].sum(axis=0) g_new[i] /= len(i_v) * n_atoms diff --git a/catlearn/regression/gp/fingerprint/sorteddistances.py b/catlearn/regression/gp/fingerprint/sorteddistances.py index 37f14eae..659dca44 100644 --- a/catlearn/regression/gp/fingerprint/sorteddistances.py +++ b/catlearn/regression/gp/fingerprint/sorteddistances.py @@ -135,13 +135,13 @@ def modify_fp( "Modify the fingerprint." # Sort the fingerprint if self.use_sort_all: - fp, indicies = self.sort_fp_all( + fp, indices = self.sort_fp_all( fp, use_include_ncells=use_include_ncells, **kwargs, ) else: - fp, indicies = self.sort_fp_pair( + fp, indices = self.sort_fp_pair( fp, atomic_numbers, tags, @@ -151,7 +151,7 @@ def modify_fp( **kwargs, ) # Sort the fingerprints and their derivatives - fp = fp[indicies] + fp = fp[indices] # Insert the derivatives into the derivative matrix if g is not None: g = self.insert_to_deriv_matrix( @@ -162,17 +162,17 @@ def modify_fp( nmj=nmj, use_include_ncells=use_include_ncells, ) - g = g[indicies] + g = g[indices] return fp, g def sort_fp_all(self, fp, use_include_ncells=False, **kwargs): - "Get the indicies for sorting the fingerprint." + "Get the indices for sorting the fingerprint." # Reshape the fingerprint if use_include_ncells: fp = fp.reshape(-1) - # Get the sorted indicies - indicies = argsort(fp) - return fp, indicies + # Get the sorted indices + indices = argsort(fp) + return fp, indices def sort_fp_pair( self, @@ -184,9 +184,9 @@ def sort_fp_pair( use_include_ncells=False, **kwargs, ): - "Get the indicies for sorting the fingerprint." - # Get the indicies of the atomic combinations - split_indicies = self.element_setup( + "Get the indices for sorting the fingerprint." + # Get the indices of the atomic combinations + split_indices = self.element_setup( atomic_numbers, tags, not_masked, @@ -198,12 +198,10 @@ def sort_fp_pair( # Reshape the fingerprint if use_include_ncells: fp = fp.reshape(-1) - # Sort the indicies after inverse distance magnitude - indicies = [ - indi[argsort(fp[indi])] for indi in split_indicies.values() - ] - indicies = concatenate(indicies) - return fp, indicies + # Sort the indices after inverse distance magnitude + indices = [indi[argsort(fp[indi])] for indi in split_indices.values()] + indices = concatenate(indices) + return fp, indices def update_arguments( self, diff --git a/catlearn/regression/gp/fingerprint/sumdistances.py b/catlearn/regression/gp/fingerprint/sumdistances.py index ccd8b3b3..ecf6c95f 100644 --- a/catlearn/regression/gp/fingerprint/sumdistances.py +++ b/catlearn/regression/gp/fingerprint/sumdistances.py @@ -139,8 +139,8 @@ def modify_fp( **kwargs, ): "Modify the fingerprint." - # Get the indicies of the atomic combinations - split_indicies_nm, split_indicies = self.element_setup( + # Get the indices of the atomic combinations + split_indices_nm, split_indices = self.element_setup( atomic_numbers, tags, not_masked, @@ -154,8 +154,8 @@ def modify_fp( g=g, not_masked=not_masked, use_include_ncells=use_include_ncells, - split_indicies_nm=split_indicies_nm, - split_indicies=split_indicies, + split_indices_nm=split_indices_nm, + split_indices=split_indices, **kwargs, ) else: @@ -165,7 +165,7 @@ def modify_fp( g=g, not_masked=not_masked, use_include_ncells=use_include_ncells, - split_indicies_nm=split_indicies_nm, + split_indices_nm=split_indices_nm, **kwargs, ) return fp, g @@ -176,8 +176,8 @@ def modify_fp_pairs( g, not_masked, use_include_ncells, - split_indicies_nm, - split_indicies, + split_indices_nm, + split_indices, **kwargs, ): "Modify the fingerprint over pairs of elements." @@ -188,13 +188,13 @@ def modify_fp_pairs( g = g.sum(axis=0) # Make the new fingerprint fp_new = zeros( - (len(split_indicies_nm), len(split_indicies)), + (len(split_indices_nm), len(split_indices)), dtype=self.dtype, ) # Sum the fingerprints - for i, i_v in enumerate(split_indicies_nm.values()): + for i, i_v in enumerate(split_indices_nm.values()): fp_i = fp[i_v] - for j, j_v in enumerate(split_indicies.values()): + for j, j_v in enumerate(split_indices.values()): fp_new[i, j] = fp_i[:, j_v].sum() fp_new = fp_new.reshape(-1) # Calculate the new derivatives @@ -202,21 +202,21 @@ def modify_fp_pairs( # Make the new derivatives g_new = zeros( ( - len(split_indicies_nm), - len(split_indicies), + len(split_indices_nm), + len(split_indices), len(not_masked), 3, ), dtype=self.dtype, ) # Sum the derivatives - for i, i_v in enumerate(split_indicies_nm.values()): + for i, i_v in enumerate(split_indices_nm.values()): g_i = g[i_v] g_ij = g_i[:, not_masked].sum(axis=0) - for j, (comb, j_v) in enumerate(split_indicies.items()): + for j, (comb, j_v) in enumerate(split_indices.items()): g_new[i, j, i_v] = g_i[:, j_v].sum(axis=1) - if comb in split_indicies_nm: - ij_comb = split_indicies_nm[comb] + if comb in split_indices_nm: + ij_comb = split_indices_nm[comb] g_new[i, j, ij_comb] -= g_ij[ij_comb] g_new = g_new.reshape(-1, len(not_masked) * 3) return fp_new, g_new @@ -228,7 +228,7 @@ def modify_fp_elements( g, not_masked, use_include_ncells, - split_indicies_nm, + split_indices_nm, **kwargs, ): "Modify the fingerprint over all elements." @@ -240,13 +240,13 @@ def modify_fp_elements( # Sum the fingerprints fp = fp.sum(axis=1) fp = asarray( - [fp[i_v].sum() for i_v in split_indicies_nm.values()], + [fp[i_v].sum() for i_v in split_indices_nm.values()], dtype=self.dtype, ) # Calculate the new derivatives if g is not None: - g_new = zeros((len(split_indicies_nm), len(not_masked), 3)) - for i, i_v in enumerate(split_indicies_nm.values()): + g_new = zeros((len(split_indices_nm), len(not_masked), 3)) + for i, i_v in enumerate(split_indices_nm.values()): g_new[i, i_v] = g[i_v].sum(axis=1) g_new[i] -= g[i_v][:, not_masked].sum(axis=0) g_new = g_new.reshape(-1, len(not_masked) * 3) @@ -363,8 +363,8 @@ def update_arguments( self.use_pairs = use_pairs if reuse_combinations is not None: self.reuse_combinations = reuse_combinations - if not hasattr(self, "split_indicies_nm"): - self.split_indicies_nm = None + if not hasattr(self, "split_indices_nm"): + self.split_indices_nm = None return self def calc_fp( @@ -501,8 +501,8 @@ def element_setup( self.atomic_numbers is not None or self.not_masked is not None or self.tags is not None - or self.split_indicies is not None - or self.split_indicies_nm is not None + or self.split_indices is not None + or self.split_indices_nm is not None ): atoms_equal = check_atoms( atomic_numbers=self.atomic_numbers, @@ -514,7 +514,7 @@ def element_setup( **kwargs, ) if atoms_equal: - return self.split_indicies_nm, self.split_indicies + return self.split_indices_nm, self.split_indices # Save the atomic numbers and tags self.atomic_numbers = atomic_numbers self.tags = tags @@ -523,17 +523,17 @@ def element_setup( if not self.use_tags: tags = zeros((len(atomic_numbers)), dtype=int) combis = list(zip(atomic_numbers, tags)) - split_indicies = {} + split_indices = {} for i, combi in enumerate(combis): - split_indicies.setdefault(combi, []).append(i) - self.split_indicies = split_indicies + split_indices.setdefault(combi, []).append(i) + self.split_indices = split_indices # Get the atomic types of the not masked atoms combis = list(zip(atomic_numbers[not_masked], tags[not_masked])) - split_indicies_nm = {} + split_indices_nm = {} for i, combi in enumerate(combis): - split_indicies_nm.setdefault(combi, []).append(i) - self.split_indicies_nm = split_indicies_nm - return split_indicies_nm, split_indicies + split_indices_nm.setdefault(combi, []).append(i) + self.split_indices_nm = split_indices_nm + return split_indices_nm, split_indices def get_arguments(self): "Get the arguments of the class itself." diff --git a/catlearn/regression/gp/fingerprint/sumdistancespower.py b/catlearn/regression/gp/fingerprint/sumdistancespower.py index 2f6cd8c3..af3e2ccf 100644 --- a/catlearn/regression/gp/fingerprint/sumdistancespower.py +++ b/catlearn/regression/gp/fingerprint/sumdistancespower.py @@ -140,8 +140,8 @@ def modify_fp( use_include_ncells, **kwargs, ): - # Get the indicies of the atomic combinations - split_indicies_nm, split_indicies = self.element_setup( + # Get the indices of the atomic combinations + split_indices_nm, split_indices = self.element_setup( atomic_numbers, tags, not_masked, @@ -149,9 +149,9 @@ def modify_fp( ) # Get the number of atomic combinations if self.use_pairs: - fp_len = len(split_indicies_nm) * len(split_indicies) + fp_len = len(split_indices_nm) * len(split_indices) else: - fp_len = len(split_indicies_nm) + fp_len = len(split_indices_nm) # Create the new fingerprint and derivatives fp_new = zeros( (fp_len, self.power), @@ -178,8 +178,8 @@ def modify_fp( nmi=nmi, nmj=nmj, use_include_ncells=use_include_ncells, - split_indicies_nm=split_indicies_nm, - split_indicies=split_indicies, + split_indices_nm=split_indices_nm, + split_indices=split_indices, power=power, ) else: @@ -192,8 +192,8 @@ def modify_fp( nmi=nmi, nmj=nmj, use_include_ncells=use_include_ncells, - split_indicies_nm=split_indicies_nm, - split_indicies=split_indicies, + split_indices_nm=split_indices_nm, + split_indices=split_indices, ) # Reshape fingerprint and derivatives fp_new = fp_new.reshape(-1) @@ -212,8 +212,8 @@ def modify_fp_power1( nmi, nmj, use_include_ncells, - split_indicies_nm, - split_indicies, + split_indices_nm, + split_indices, **kwargs, ): """ @@ -228,8 +228,8 @@ def modify_fp_power1( g=g, not_masked=not_masked, use_include_ncells=use_include_ncells, - split_indicies_nm=split_indicies_nm, - split_indicies=split_indicies, + split_indices_nm=split_indices_nm, + split_indices=split_indices, **kwargs, ) else: @@ -239,7 +239,7 @@ def modify_fp_power1( g=g, not_masked=not_masked, use_include_ncells=use_include_ncells, - split_indicies_nm=split_indicies_nm, + split_indices_nm=split_indices_nm, **kwargs, ) # Add a small number to avoid division by zero @@ -255,8 +255,8 @@ def modify_fp_powers( nmi, nmj, use_include_ncells, - split_indicies_nm, - split_indicies, + split_indices_nm, + split_indices, power, **kwargs, ): @@ -279,8 +279,8 @@ def modify_fp_powers( g=g_new, not_masked=not_masked, use_include_ncells=use_include_ncells, - split_indicies_nm=split_indicies_nm, - split_indicies=split_indicies, + split_indices_nm=split_indices_nm, + split_indices=split_indices, **kwargs, ) else: @@ -290,7 +290,7 @@ def modify_fp_powers( g=g_new, not_masked=not_masked, use_include_ncells=use_include_ncells, - split_indicies_nm=split_indicies_nm, + split_indices_nm=split_indices_nm, **kwargs, ) # Add a small number to avoid division by zero diff --git a/catlearn/regression/gp/objectivefunctions/batch.py b/catlearn/regression/gp/objectivefunctions/batch.py index ab61eefc..36362715 100644 --- a/catlearn/regression/gp/objectivefunctions/batch.py +++ b/catlearn/regression/gp/objectivefunctions/batch.py @@ -99,9 +99,9 @@ def function( self.set_same_prior_mean(model, X, Y) # Calculate the number of batches n_batches = self.get_number_batches(n_data) - indicies = arange(n_data) + indices = arange(n_data) i_batches = self.randomized_batches( - indicies, + indices, n_data, n_batches, **kwargs, @@ -296,25 +296,25 @@ def get_number_batches(self, n_data, **kwargs): n_batches = n_batches + 1 return n_batches - def randomized_batches(self, indicies, n_data, n_batches, **kwargs): - "Randomized indicies used for batches." - # Permute the indicies - i_perm = self.get_permutation(indicies) + def randomized_batches(self, indices, n_data, n_batches, **kwargs): + "Randomized indices used for batches." + # Permute the indices + i_perm = self.get_permutation(indices) # Ensure equal sizes of batches if chosen if self.equal_size: i_perm = self.ensure_equal_sizes(i_perm, n_data, n_batches) i_batches = array_split(i_perm, n_batches) return i_batches - def get_permutation(self, indicies): - "Permute the indicies" - return self.rng.permutation(indicies) + def get_permutation(self, indices): + "Permute the indices" + return self.rng.permutation(indices) def ensure_equal_sizes(self, i_perm, n_data, n_batches, **kwargs): - "Extend the permuted indicies so the clusters have equal sizes." + "Extend the permuted indices so the clusters have equal sizes." # Find the number of points that should be added n_missing = (n_batches * self.batch_size) - n_data - # Extend the permuted indicies + # Extend the permuted indices if n_missing > 0: if n_missing > n_data: i_perm = append( diff --git a/catlearn/regression/gp/objectivefunctions/best_batch.py b/catlearn/regression/gp/objectivefunctions/best_batch.py index f7ed5f4b..f5f1b6b8 100644 --- a/catlearn/regression/gp/objectivefunctions/best_batch.py +++ b/catlearn/regression/gp/objectivefunctions/best_batch.py @@ -46,9 +46,9 @@ def function( self.set_same_prior_mean(model, X, Y) # Calculate the number of batches n_batches = self.get_number_batches(n_data) - indicies = arange(n_data) + indices = arange(n_data) i_batches = self.randomized_batches( - indicies, n_data, n_batches, **kwargs + indices, n_data, n_batches, **kwargs ) # Sum function values together from batches fvalue = inf diff --git a/catlearn/regression/gp/optimizers/linesearcher.py b/catlearn/regression/gp/optimizers/linesearcher.py index fb45a7ab..94e8f710 100644 --- a/catlearn/regression/gp/optimizers/linesearcher.py +++ b/catlearn/regression/gp/optimizers/linesearcher.py @@ -240,7 +240,7 @@ def find_minimas( **kwargs, ): """ - Find all the local minimums and their indicies or just + Find all the local minimums and their indices or just the global minimum and then check convergence. """ # Investigate multiple minimums @@ -264,7 +264,7 @@ def find_multiple_min( **kwargs, ): """ - Find all the local minimums and their indicies and + Find all the local minimums and their indices and then check convergence. """ # Find local minimas for middel part of line @@ -294,7 +294,7 @@ def find_multiple_min( - xvalues[i_minimas - 1, theta_index] ) >= self.xtol * (1.0 + abs(xvalues[i_minimas, theta_index])) i_minimas = i_minimas[i_keep] - # Sort the indicies after function value sizes + # Sort the indices after function value sizes if len(i_minimas) > 1: i_sort = argsort(fvalues[i_minimas]) i_minimas = i_minimas[i_sort] @@ -459,7 +459,7 @@ def prepare_run_golden( # Get the function that evaluate the objective function fun = self.get_fun(func) for i_min in i_minimas: - # Find the indicies of the interval + # Find the indices of the interval x1 = i_min - 1 x4 = i_min + 1 # Get the function values of the endpoints of the interval diff --git a/tests/test_gp_calc.py b/tests/test_gp_calc.py index 652bd095..88cd8306 100644 --- a/tests/test_gp_calc.py +++ b/tests/test_gp_calc.py @@ -62,66 +62,62 @@ def test_predict(self): ( DatabaseDistance, True, - dict(npoints=npoints, initial_indicies=[0]), + dict(npoints=npoints, initial_indices=[0]), ), ( DatabaseDistance, True, - dict(npoints=npoints, initial_indicies=[]), + dict(npoints=npoints, initial_indices=[]), ), ( DatabaseHybrid, True, - dict(npoints=npoints, initial_indicies=[0]), + dict(npoints=npoints, initial_indices=[0]), ), - (DatabaseHybrid, True, dict(npoints=npoints, initial_indicies=[])), - (DatabaseMin, True, dict(npoints=npoints, initial_indicies=[0])), - (DatabaseMin, True, dict(npoints=npoints, initial_indicies=[])), + (DatabaseHybrid, True, dict(npoints=npoints, initial_indices=[])), + (DatabaseMin, True, dict(npoints=npoints, initial_indices=[0])), + (DatabaseMin, True, dict(npoints=npoints, initial_indices=[])), ( DatabaseRandom, True, - dict(npoints=npoints, initial_indicies=[0]), + dict(npoints=npoints, initial_indices=[0]), ), - (DatabaseRandom, True, dict(npoints=npoints, initial_indicies=[])), - (DatabaseLast, True, dict(npoints=npoints, initial_indicies=[0])), - (DatabaseLast, True, dict(npoints=npoints, initial_indicies=[])), + (DatabaseRandom, True, dict(npoints=npoints, initial_indices=[])), + (DatabaseLast, True, dict(npoints=npoints, initial_indices=[0])), + (DatabaseLast, True, dict(npoints=npoints, initial_indices=[])), ( DatabaseRestart, True, - dict(npoints=npoints, initial_indicies=[0]), + dict(npoints=npoints, initial_indices=[0]), ), ( DatabaseRestart, True, - dict(npoints=npoints, initial_indicies=[]), + dict(npoints=npoints, initial_indices=[]), ), ( DatabasePointsInterest, True, dict( - npoints=npoints, initial_indicies=[0], point_interest=x_te + npoints=npoints, initial_indices=[0], point_interest=x_te ), ), ( DatabasePointsInterest, True, - dict( - npoints=npoints, initial_indicies=[], point_interest=x_te - ), + dict(npoints=npoints, initial_indices=[], point_interest=x_te), ), ( DatabasePointsInterestEach, True, dict( - npoints=npoints, initial_indicies=[0], point_interest=x_te + npoints=npoints, initial_indices=[0], point_interest=x_te ), ), ( DatabasePointsInterestEach, True, - dict( - npoints=npoints, initial_indicies=[], point_interest=x_te - ), + dict(npoints=npoints, initial_indices=[], point_interest=x_te), ), ] # Make a list of the error values that the test compares to From 4f59ad99b428fe81119fd9432334e50012165514 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Wed, 23 Jul 2025 12:57:34 +0200 Subject: [PATCH 131/194] Debug baseline for when all atoms are fixed --- catlearn/regression/gp/baseline/repulsive.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/catlearn/regression/gp/baseline/repulsive.py b/catlearn/regression/gp/baseline/repulsive.py index e224a5ea..57ae44bc 100644 --- a/catlearn/regression/gp/baseline/repulsive.py +++ b/catlearn/regression/gp/baseline/repulsive.py @@ -198,12 +198,17 @@ def set_normalization_constant(self, **kwargs): def get_energy_forces(self, atoms, use_forces=True, **kwargs): "Get the energy and forces." - # Get the not fixed (not masked) atom indicies + # Get the not fixed (not masked) atom indices not_masked, _ = get_constraints( atoms, reduce_dimensions=self.reduce_dimensions, ) i_nm = arange(len(not_masked)) + # Check if there are any not masked atoms + if len(not_masked) == 0: + if use_forces: + return 0.0, zeros((len(atoms), 3), dtype=self.dtype) + return 0.0, None # Check what distance method should be used ( use_vector, From 4877f4a7a682522f12a87e1e7d6e592b0c2c848e Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Wed, 23 Jul 2025 12:58:11 +0200 Subject: [PATCH 132/194] Change parameters in baseline --- catlearn/regression/gp/baseline/bornrepulsive.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/catlearn/regression/gp/baseline/bornrepulsive.py b/catlearn/regression/gp/baseline/bornrepulsive.py index 45635166..370a2b6b 100644 --- a/catlearn/regression/gp/baseline/bornrepulsive.py +++ b/catlearn/regression/gp/baseline/bornrepulsive.py @@ -22,9 +22,9 @@ def __init__( mic=False, all_ncells=True, cell_cutoff=2.0, - r_scale=0.8, + r_scale=0.57, power=2, - rs1_cross=0.9, + rs1_cross=0.97, k_scale=1.0, dtype=float, **kwargs, From c9d93e02d243ab33fc9e2a6467dfe6285f6dbee3 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Wed, 23 Jul 2025 12:59:04 +0200 Subject: [PATCH 133/194] Get stored correction --- catlearn/regression/gp/models/model.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/catlearn/regression/gp/models/model.py b/catlearn/regression/gp/models/model.py index e5f558bc..0e7fd598 100644 --- a/catlearn/regression/gp/models/model.py +++ b/catlearn/regression/gp/models/model.py @@ -773,15 +773,22 @@ def inf_to_num(self, value, replacing=1e300): return replacing return value - def get_correction(self, K_diag, **kwargs): + def get_correction(self, K_diag=None, **kwargs): """ Get the noise correction, so that the training covariance matrix is always invertible. + + Parameters: + K_diag: N or N*(D+1) array (optional) + The diagonal elements of the kernel matrix. + If it is not given, the stored noise correction is used. """ - if self.use_correction: + if self.use_correction and K_diag is not None: K_sum = K_diag.sum() n = len(K_diag) corr = (K_sum**2) * (1.0 / ((1.0 / self.eps) - (n**2))) + elif self.use_correction and K_diag is None: + corr = self.corr else: corr = 0.0 return corr From 5930b114389d51d5c1d22d6e26a5f65346ee5b5b Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Wed, 23 Jul 2025 13:00:14 +0200 Subject: [PATCH 134/194] Implement new clustering method --- .../gp/ensemble/clustering/__init__.py | 2 + .../clustering/k_means_enumeration.py | 134 ++++++++++++++++++ tests/test_gp_ensemble.py | 24 +++- 3 files changed, 158 insertions(+), 2 deletions(-) create mode 100644 catlearn/regression/gp/ensemble/clustering/k_means_enumeration.py diff --git a/catlearn/regression/gp/ensemble/clustering/__init__.py b/catlearn/regression/gp/ensemble/clustering/__init__.py index b5f16c84..d00ae539 100644 --- a/catlearn/regression/gp/ensemble/clustering/__init__.py +++ b/catlearn/regression/gp/ensemble/clustering/__init__.py @@ -2,6 +2,7 @@ from .k_means import K_means from .k_means_auto import K_means_auto from .k_means_number import K_means_number +from .k_means_enumeration import K_means_enumeration from .fixed import FixedClustering from .random import RandomClustering from .random_number import RandomClustering_number @@ -11,6 +12,7 @@ "K_means", "K_means_auto", "K_means_number", + "K_means_enumeration", "FixedClustering", "RandomClustering", "RandomClustering_number", diff --git a/catlearn/regression/gp/ensemble/clustering/k_means_enumeration.py b/catlearn/regression/gp/ensemble/clustering/k_means_enumeration.py new file mode 100644 index 00000000..c8fa9aaa --- /dev/null +++ b/catlearn/regression/gp/ensemble/clustering/k_means_enumeration.py @@ -0,0 +1,134 @@ +from numpy import append, arange, array, asarray +from .k_means import K_means + + +class K_means_enumeration(K_means): + """ + Clustering algorithm class for data sets. + It uses the K-means++ algorithm for clustering. + It uses a fixed number of data points in each cluster. + """ + + def __init__( + self, + metric="euclidean", + data_number=25, + seed=None, + dtype=float, + **kwargs, + ): + """ + Initialize the clustering algorithm. + + Parameters: + metric: str + The metric used to calculate the distances of the data. + data_number: int + The number of data point in each cluster. + seed: int (optional) + The random seed. + The seed can be an integer, RandomState, or Generator instance. + If not given, the default random number generator is used. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. + """ + super().__init__( + metric=metric, + data_number=data_number, + seed=seed, + dtype=dtype, + **kwargs, + ) + + def cluster_fit_data(self, X, **kwargs): + # Copy the data + X = array(X, dtype=self.dtype) + # Calculate the number of clusters + self.n_clusters = self.calc_n_clusters(X) + # If only one cluster is used, give the full data + if self.n_clusters == 1: + self.centroids = asarray([X.mean(axis=0)]) + return [arange(len(X))] + # Initiate the centroids + self.centroids, cluster_indices = self.initiate_centroids(X) + # Return the cluster indices + return cluster_indices + + def update_arguments( + self, + metric=None, + data_number=None, + seed=None, + dtype=None, + **kwargs, + ): + """ + Update the class with its arguments. + The existing arguments are used if they are not given. + + Parameters: + metric: str + The metric used to calculate the distances of the data. + data_number: int + The number of data point in each cluster. + seed: int (optional) + The random seed. + The seed can be an integer, RandomState, or Generator instance. + If not given, the default random number generator is used. + dtype: type (optional) + The data type of the arrays. + If None, the default data type is used. + + Returns: + self: The updated object itself. + """ + if data_number is not None: + self.data_number = int(data_number) + # Set the arguments of the parent class + super().update_arguments( + metric=metric, + n_clusters=None, + seed=seed, + dtype=dtype, + ) + return self + + def calc_n_clusters(self, X, **kwargs): + """ + Calculate the number of clusters based on the data. + """ + n_data = len(X) + n_clusters = int(n_data // self.data_number) + if n_data - (n_clusters * self.data_number): + n_clusters += 1 + return n_clusters + + def initiate_centroids(self, X, **kwargs): + "Initial the centroids from the K-mean++ method." + n_data = len(X) + indices = arange(n_data) + if int(self.n_clusters * self.data_number) > n_data: + n_f = int((self.n_clusters - 1) * self.data_number) + n_r = int(n_data - self.data_number) + indices = append(indices[:n_f], indices[n_r:]) + indices = indices.reshape(self.n_clusters, self.data_number) + centroids = asarray( + [X[indices_ki].mean(axis=0) for indices_ki in indices] + ) + return centroids, indices + + def get_arguments(self): + "Get the arguments of the class itself." + # Get the arguments given to the class in the initialization + arg_kwargs = dict( + metric=self.metric, + data_number=self.data_number, + seed=self.seed, + dtype=self.dtype, + ) + # Get the constants made within the class + constant_kwargs = dict(n_clusters=self.n_clusters) + # Get the objects made within the class + object_kwargs = dict(centroids=self.centroids) + return arg_kwargs, constant_kwargs, object_kwargs diff --git a/tests/test_gp_ensemble.py b/tests/test_gp_ensemble.py index c5f1864e..033386fd 100644 --- a/tests/test_gp_ensemble.py +++ b/tests/test_gp_ensemble.py @@ -80,6 +80,7 @@ def test_clustering(self): K_means, K_means_auto, K_means_number, + K_means_enumeration, FixedClustering, RandomClustering, RandomClustering_number, @@ -116,6 +117,7 @@ def test_clustering(self): data_number=12, maxiter=20, ), + K_means_enumeration(data_number=12), FixedClustering( centroids=np.array([[-30.0], [60.0]]), ), @@ -123,7 +125,15 @@ def test_clustering(self): RandomClustering_number(data_number=12), ] # Make a list of the error values that the test compares to - error_list = [0.48256, 0.63066, 0.62649, 0.62650, 0.70163, 0.67975] + error_list = [ + 0.48256, + 0.63066, + 0.62649, + 0.91445, + 0.62650, + 0.70163, + 0.67975, + ] # Test the baseline objects for index, clustering in enumerate(clustering_list): with self.subTest(clustering=clustering): @@ -223,6 +233,7 @@ def test_clustering(self): K_means, K_means_auto, K_means_number, + K_means_enumeration, FixedClustering, RandomClustering, RandomClustering_number, @@ -256,12 +267,21 @@ def test_clustering(self): maxiter=20, ), K_means_number(data_number=12, maxiter=20), + K_means_enumeration(data_number=12), FixedClustering(centroids=np.array([[-30.0], [60.0]])), RandomClustering(n_clusters=4, equal_size=True), RandomClustering_number(data_number=12), ] # Make a list of the error values that the test compares to - error_list = [0.37817, 0.38854, 0.38641, 0.38640, 0.47864, 0.36700] + error_list = [ + 0.37817, + 0.38854, + 0.38641, + 0.52753, + 0.38640, + 0.47864, + 0.36700, + ] # Test the baseline objects for index, clustering in enumerate(clustering_list): with self.subTest(clustering=clustering): From 75fa9c6ccd53789122bf89e6f5bab6d59756cd2f Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Wed, 23 Jul 2025 13:06:36 +0200 Subject: [PATCH 135/194] Update test due to change in baseline parameters --- tests/test_gp_baseline.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_gp_baseline.py b/tests/test_gp_baseline.py index 3aa58655..88f1b777 100644 --- a/tests/test_gp_baseline.py +++ b/tests/test_gp_baseline.py @@ -59,7 +59,7 @@ def test_predict(self): MieCalculator(), ] # Make a list of the error values that the test compares to - error_list = [0.47624, 3.21230, 5.03338, 0.38677] + error_list = [0.47624, 0.47624, 5.03338, 0.38677] # Test the baseline objects for index, baseline in enumerate(baseline_list): with self.subTest(baseline=baseline): From 5ead32344a37170160842fd00048d49b5c69a5b6 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Wed, 23 Jul 2025 13:11:40 +0200 Subject: [PATCH 136/194] Changes to the acquisition classes --- catlearn/activelearning/acquisition.py | 438 +++++++++++++++++++++---- 1 file changed, 371 insertions(+), 67 deletions(-) diff --git a/catlearn/activelearning/acquisition.py b/catlearn/activelearning/acquisition.py index 13ada4a4..ca0d2b2f 100644 --- a/catlearn/activelearning/acquisition.py +++ b/catlearn/activelearning/acquisition.py @@ -1,20 +1,25 @@ -from numpy import argsort, max as max_ +from numpy import argsort, array, max as max_ from numpy.random import default_rng, Generator, RandomState from scipy.stats import norm class Acquisition: + """ + The Acquisition class is used to calculate the acquisition function + values and to sort the candidates. + """ + def __init__(self, objective="min", seed=None, **kwargs): """ - Acquisition function class. + Initialize the Acquisition instance. Parameters: - objective : string + objective: string How to sort a list of acquisition functions Available: - 'min': Sort after the smallest values. - 'max': Sort after the largest values. - - 'random' : Sort randomly + - 'random': Sort randomly seed: int (optional) The random seed. The seed an also be a RandomState or Generator instance. @@ -23,25 +28,41 @@ def __init__(self, objective="min", seed=None, **kwargs): self.update_arguments(objective=objective, seed=seed, **kwargs) def calculate(self, energy, uncertainty=None, **kwargs): - "Calculate the acqusition function value." + "Calculate the acquisition function value." raise NotImplementedError() - def choose(self, candidates): + def choose(self, values): "Sort a list of acquisition function values." if self.objective == "min": - return argsort(candidates) + return argsort(values) elif self.objective == "max": - return argsort(candidates)[::-1] - return self.rng.permutation(list(range(len(candidates)))) + return argsort(values)[::-1] + elif self.objective == "random": + return self.rng.permutation(len(values)) + raise ValueError("The objective should be 'min', 'max' or 'random'.") def objective_value(self, value): - "Return the objective value." + "Return the value by changing the sign dependent on the method." if self.objective == "min": return -value return value def update_arguments(self, objective=None, seed=None, **kwargs): - "Set the parameters of the Acquisition function class." + """ + Set the parameters of the Acquisition function instance. + + Parameters: + objective: string + How to sort a list of acquisition functions + Available: + - 'min': Sort after the smallest values. + - 'max': Sort after the largest values. + - 'random': Sort randomly + seed: int (optional) + The random seed. + The seed an also be a RandomState or Generator instance. + If not given, the default random number generator is used. + """ # Set the seed if seed is not None or not hasattr(self, "seed"): self.set_seed(seed) @@ -98,26 +119,98 @@ def __repr__(self): class AcqEnergy(Acquisition): - def __init__(self, objective="min", seed=None, **kwargs): - "The predicted energy as the acqusition function." - super().__init__(objective=objective, seed=seed, **kwargs) + """ + The Acquisition class is used to calculate the acquisition function + values and to sort the candidates. + The predicted energy is used as the acquisition function. + """ def calculate(self, energy, uncertainty=None, **kwargs): - "Calculate the acqusition function value as the predicted energy." + "Calculate the acquisition function value as the predicted energy." return energy class AcqUncertainty(Acquisition): - def __init__(self, objective="min", seed=None, **kwargs): - "The predicted uncertainty as the acqusition function." + """ + The Acquisition class is used to calculate the acquisition function + values and to sort the candidates. + The predicted uncertainty as the acquisition function. + """ + + def __init__(self, objective="max", seed=None, **kwargs): + """ + Initialize the Acquisition instance. + + Parameters: + objective: string + How to sort a list of acquisition functions + Available: + - 'min': Sort after the smallest values. + - 'max': Sort after the largest values. + - 'random': Sort randomly + - 'draw': Sort by drawing from the uncertainty squared. + seed: int (optional) + The random seed. + The seed an also be a RandomState or Generator instance. + If not given, the default random number generator is used. + """ super().__init__(objective=objective, seed=seed, **kwargs) def calculate(self, energy, uncertainty=None, **kwargs): - "Calculate the acqusition function value as the predicted uncertainty." + "Calculate the acquisition value as the predicted uncertainty." return uncertainty + def choose(self, values): + "Sort a list of acquisition function values." + if self.objective == "min": + return argsort(values) + elif self.objective == "max": + return argsort(values)[::-1] + elif self.objective == "random": + return self.rng.permutation(len(values)) + elif self.objective == "draw": + values = array(values) ** 2 + p = values / values.sum() + return self.rng.choice( + len(values), + size=len(values), + replace=False, + p=p, + ) + raise ValueError( + "The objective should be 'min', 'max', 'random', or 'draw'." + ) + + def update_arguments(self, objective=None, seed=None, **kwargs): + """ + Set the parameters of the Acquisition function instance. + + Parameters: + objective: string + How to sort a list of acquisition functions + Available: + - 'min': Sort after the smallest values. + - 'max': Sort after the largest values. + - 'random': Sort randomly + - 'draw': Sort by drawing from the uncertainty squared. + seed: int (optional) + The random seed. + The seed an also be a RandomState or Generator instance. + If not given, the default random number generator is used. + """ + return super().update_arguments( + objective=objective, + seed=seed, + ) + class AcqUCB(Acquisition): + """ + The Acquisition class is used to calculate the acquisition function + values and to sort the candidates. + The predicted upper confidence interval (ucb) as the acquisition function. + """ + def __init__( self, objective="max", @@ -127,8 +220,26 @@ def __init__( **kwargs, ): """ - The predicted upper confidence interval (ucb) as - the acqusition function. + Initialize the Acquisition instance. + + Parameters: + objective: string + How to sort a list of acquisition functions + Available: + - 'min': Sort after the smallest values. + - 'max': Sort after the largest values. + - 'random': Sort randomly + seed: int (optional) + The random seed. + The seed an also be a RandomState or Generator instance. + If not given, the default random number generator is used. + kappa: float or str + The kappa value for the upper confidence interval. + If a string, a random value between 0 and kappamax is used. + kappamax: float + The maximum kappa value for the upper confidence interval. + If kappa is a string, a random value between 0 and this value + is used. """ self.update_arguments( objective=objective, @@ -139,7 +250,7 @@ def __init__( ) def calculate(self, energy, uncertainty=None, **kwargs): - "Calculate the acqusition function value as the predicted ucb." + "Calculate the acquisition function value as the predicted ucb." kappa = self.get_kappa() return energy + kappa * uncertainty @@ -157,7 +268,28 @@ def update_arguments( kappamax=None, **kwargs, ): - "Set the parameters of the Acquisition function class." + """ + Set the parameters of the Acquisition function instance. + + Parameters: + objective: string + How to sort a list of acquisition functions + Available: + - 'min': Sort after the smallest values. + - 'max': Sort after the largest values. + - 'random': Sort randomly + seed: int (optional) + The random seed. + The seed an also be a RandomState or Generator instance. + If not given, the default random number generator is used. + kappa: float or str + The kappa value for the upper confidence interval. + If a string, a random value between 0 and kappamax is used. + kappamax: float + The maximum kappa value for the upper confidence interval. + If kappa is a string, a random value between 0 and this value + is used. + """ # Set the parameters in the parent class super().update_arguments( objective=objective, @@ -181,7 +313,6 @@ def get_arguments(self): seed=self.seed, kappa=self.kappa, kappamax=self.kappamax, - seed=self.seed, ) # Get the constants made within the class constant_kwargs = dict() @@ -191,6 +322,12 @@ def get_arguments(self): class AcqLCB(AcqUCB): + """ + The Acquisition class is used to calculate the acquisition function + values and to sort the candidates. + The predicted lower confidence interval (lcb) as the acquisition function. + """ + def __init__( self, objective="min", @@ -199,10 +336,6 @@ def __init__( kappamax=3.0, **kwargs, ): - """ - The predicted lower confidence interval (lcb) as - the acqusition function. - """ super().__init__( objective=objective, seed=seed, @@ -212,23 +345,48 @@ def __init__( ) def calculate(self, energy, uncertainty=None, **kwargs): - "Calculate the acqusition function value as the predicted ucb." + "Calculate the acquisition function value as the predicted ucb." kappa = self.get_kappa() return energy - kappa * uncertainty class AcqIter(Acquisition): + """ + The Acquisition class is used to calculate the acquisition function + values and to sort the candidates. + The predicted energy or uncertainty dependent on the iteration as + the acquisition function. + The energy is used every niter iterations, otherwise the uncertainty. + """ + def __init__(self, objective="max", seed=None, niter=2, **kwargs): """ - The predicted energy or uncertainty dependent on - the iteration as the acqusition function. + Initialize the Acquisition instance. + + Parameters: + objective: string + How to sort a list of acquisition functions + Available: + - 'min': Sort after the smallest values. + - 'max': Sort after the largest values. + - 'random': Sort randomly + seed: int (optional) + The random seed. + The seed an also be a RandomState or Generator instance. + If not given, the default random number generator is used. + niter: int + The number of iterations after which the energy is used + as the acquisition function. + If niter is 1, the energy is used every iteration. + If niter is 2, the energy is used every second iteration, + etc. """ super().__init__(objective=objective, seed=seed, niter=niter, **kwargs) self.iter = 0 def calculate(self, energy, uncertainty=None, **kwargs): """ - Calculate the acqusition function value as + Calculate the acquisition function value as the predicted energy or uncertainty. """ self.iter += 1 @@ -243,7 +401,27 @@ def update_arguments( niter=None, **kwargs, ): - "Set the parameters of the Acquisition function class." + """ + Set the parameters of the Acquisition function instance. + + Parameters: + objective: string + How to sort a list of acquisition functions + Available: + - 'min': Sort after the smallest values. + - 'max': Sort after the largest values. + - 'random': Sort randomly + seed: int (optional) + The random seed. + The seed an also be a RandomState or Generator instance. + If not given, the default random number generator is used. + niter: int + The number of iterations after which the energy is used + as the acquisition function. + If niter is 1, the energy is used every iteration. + If niter is 2, the energy is used every second iteration, + etc. + """ # Set the parameters in the parent class super().update_arguments( objective=objective, @@ -270,6 +448,13 @@ def get_arguments(self): class AcqUME(Acquisition): + """ + The Acquisition class is used to calculate the acquisition function + values and to sort the candidates. + The predicted uncertainty when it is larger than unc_convergence + else predicted energy as the acquisition function. + """ + def __init__( self, objective="max", @@ -278,8 +463,23 @@ def __init__( **kwargs, ): """ - The predicted uncertainty when it is larger than unc_convergence - else predicted energy as the acqusition function. + Initialize the Acquisition instance. + + Parameters: + objective: string + How to sort a list of acquisition functions + Available: + - 'min': Sort after the smallest values. + - 'max': Sort after the largest values. + - 'random': Sort randomly + seed: int (optional) + The random seed. + The seed an also be a RandomState or Generator instance. + If not given, the default random number generator is used. + unc_convergence: float + The convergence threshold for the uncertainty. + If the uncertainty is below this value, the predicted energy + is used as the acquisition function. """ super().__init__( objective=objective, @@ -290,7 +490,7 @@ def __init__( def calculate(self, energy, uncertainty=None, **kwargs): """ - Calculate the acqusition function value as the predicted uncertainty + Calculate the acquisition function value as the predicted uncertainty when it is is larger than unc_convergence else predicted energy. """ if max_([uncertainty]) < self.unc_convergence: @@ -304,7 +504,25 @@ def update_arguments( unc_convergence=None, **kwargs, ): - "Set the parameters of the Acquisition function class." + """ + Set the parameters of the Acquisition function instance. + + Parameters: + objective: string + How to sort a list of acquisition functions + Available: + - 'min': Sort after the smallest values. + - 'max': Sort after the largest values. + - 'random': Sort randomly + seed: int (optional) + The random seed. + The seed an also be a RandomState or Generator instance. + If not given, the default random number generator is used. + unc_convergence: float + The convergence threshold for the uncertainty. + If the uncertainty is below this value, the predicted energy + is used as the acquisition function. + """ # Set the parameters in the parent class super().update_arguments( objective=objective, @@ -331,6 +549,13 @@ def get_arguments(self): class AcqUUCB(AcqUCB): + """ + The Acquisition class is used to calculate the acquisition function + values and to sort the candidates. + The predicted uncertainty when it is larger than unc_convergence + else upper confidence interval (ucb) as the acquisition function. + """ + def __init__( self, objective="max", @@ -341,8 +566,30 @@ def __init__( **kwargs, ): """ - The predicted uncertainty when it is larger than unc_convergence - else upper confidence interval (ucb) as the acqusition function. + Initialize the Acquisition instance. + + Parameters: + objective: string + How to sort a list of acquisition functions + Available: + - 'min': Sort after the smallest values. + - 'max': Sort after the largest values. + - 'random': Sort randomly + seed: int (optional) + The random seed. + The seed an also be a RandomState or Generator instance. + If not given, the default random number generator is used. + kappa: float or str + The kappa value for the upper confidence interval. + If a string, a random value between 0 and kappamax is used. + kappamax: float + The maximum kappa value for the upper confidence interval. + If kappa is a string, a random value between 0 and this value + is used. + unc_convergence: float + The convergence threshold for the uncertainty. + If the uncertainty is below this value, the ucb is used + as the acquisition function. """ self.update_arguments( objective=objective, @@ -355,7 +602,7 @@ def __init__( def calculate(self, energy, uncertainty=None, **kwargs): """ - Calculate the acqusition function value as the predicted uncertainty + Calculate the acquisition function value as the predicted uncertainty when it is is larger than unc_convergence else ucb. """ if max_([uncertainty]) < self.unc_convergence: @@ -372,20 +619,39 @@ def update_arguments( unc_convergence=None, **kwargs, ): - "Set the parameters of the Acquisition function class." + """ + Set the parameters of the Acquisition function instance. + + Parameters: + objective: string + How to sort a list of acquisition functions + Available: + - 'min': Sort after the smallest values. + - 'max': Sort after the largest values. + - 'random': Sort randomly + seed: int (optional) + The random seed. + The seed an also be a RandomState or Generator instance. + If not given, the default random number generator is used. + kappa: float or str + The kappa value for the upper confidence interval. + If a string, a random value between 0 and kappamax is used. + kappamax: float + The maximum kappa value for the upper confidence interval. + If kappa is a string, a random value between 0 and this value + is used. + unc_convergence: float + The convergence threshold for the uncertainty. + If the uncertainty is below this value, the ucb is used + as the acquisition function. + """ # Set the parameters in the parent class super().update_arguments( objective=objective, seed=seed, + kappa=kappa, + kappamax=kappamax, ) - # Set the kappa value - if kappa is not None: - if isinstance(kappa, (float, int)): - kappa = abs(kappa) - self.kappa = kappa - # Set the kappamax value - if kappamax is not None: - self.kappamax = abs(kappamax) # Set the unc_convergence value if unc_convergence is not None: self.unc_convergence = abs(unc_convergence) @@ -409,6 +675,13 @@ def get_arguments(self): class AcqULCB(AcqUUCB): + """ + The Acquisition class is used to calculate the acquisition function + values and to sort the candidates. + The predicted uncertainty when it is larger than unc_convergence + else lower confidence interval (lcb) as the acquisition function. + """ + def __init__( self, objective="min", @@ -418,10 +691,6 @@ def __init__( unc_convergence=0.05, **kwargs, ): - """ - The predicted uncertainty when it is larger than unc_convergence - else lower confidence interval (lcb) as the acqusition function. - """ self.update_arguments( objective=objective, seed=seed, @@ -433,7 +702,7 @@ def __init__( def calculate(self, energy, uncertainty=None, **kwargs): """ - Calculate the acqusition function value as the predicted uncertainty + Calculate the acquisition function value as the predicted uncertainty when it is is larger than unc_convergence else lcb. """ if max_([uncertainty]) < self.unc_convergence: @@ -443,9 +712,31 @@ def calculate(self, energy, uncertainty=None, **kwargs): class AcqEI(Acquisition): + """ + The Acquisition class is used to calculate the acquisition function + values and to sort the candidates. + The predicted expected improvement as the acquisition function. + """ + def __init__(self, objective="max", seed=None, ebest=None, **kwargs): """ - The predicted expected improvement as the acqusition function. + Initialize the Acquisition instance. + + Parameters: + objective: string + How to sort a list of acquisition functions + Available: + - 'min': Sort after the smallest values. + - 'max': Sort after the largest values. + - 'random': Sort randomly + seed: int (optional) + The random seed. + The seed an also be a RandomState or Generator instance. + If not given, the default random number generator is used. + ebest: float + The best energy value found so far. + This is used as the reference energy for the expected + improvement. """ self.update_arguments( objective=objective, @@ -456,7 +747,7 @@ def __init__(self, objective="max", seed=None, ebest=None, **kwargs): def calculate(self, energy, uncertainty=None, **kwargs): """ - Calculate the acqusition function value as + Calculate the acquisition function value as the predicted expected improvement. """ z = (energy - self.ebest) / uncertainty @@ -470,7 +761,25 @@ def update_arguments( ebest=None, **kwargs, ): - "Set the parameters of the Acquisition function class." + """ + Set the parameters of the Acquisition function instance. + + Parameters: + objective: string + How to sort a list of acquisition functions + Available: + - 'min': Sort after the smallest values. + - 'max': Sort after the largest values. + - 'random': Sort randomly + seed: int (optional) + The random seed. + The seed an also be a RandomState or Generator instance. + If not given, the default random number generator is used. + ebest: float + The best energy value found so far. + This is used as the reference energy for the expected + improvement. + """ # Set the parameters in the parent class super().update_arguments( objective=objective, @@ -497,20 +806,15 @@ def get_arguments(self): class AcqPI(AcqEI): - def __init__(self, objective="max", seed=None, ebest=None, **kwargs): - """ - The predicted probability of improvement as the acqusition function. - """ - self.update_arguments( - objective=objective, - seed=seed, - ebest=ebest, - **kwargs, - ) + """ + The Acquisition class is used to calculate the acquisition function + values and to sort the candidates. + The predicted probability of improvement as the acquisition function. + """ def calculate(self, energy, uncertainty=None, **kwargs): """ - Calculate the acqusition function value as + Calculate the acquisition function value as the predicted expected improvement. """ z = (energy - self.ebest) / uncertainty From 7060081098e509179d40eedbf76fc8f1c7aeb2f5 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Wed, 23 Jul 2025 13:15:51 +0200 Subject: [PATCH 137/194] Debug of structures and copy of atoms --- catlearn/optimizer/localneb.py | 99 +++++++++--- catlearn/optimizer/method.py | 216 ++++++++++++++++--------- catlearn/optimizer/parallelopt.py | 259 +++++++++++++++++++----------- catlearn/optimizer/sequential.py | 66 +++++--- catlearn/structures/structure.py | 5 +- 5 files changed, 431 insertions(+), 214 deletions(-) diff --git a/catlearn/optimizer/localneb.py b/catlearn/optimizer/localneb.py index e5a848d5..1c477a5c 100644 --- a/catlearn/optimizer/localneb.py +++ b/catlearn/optimizer/localneb.py @@ -2,12 +2,18 @@ from ase.parallel import world, broadcast from ase.optimize import FIRE from numpy import asarray +from ..structures.neb import OriginalNEB class LocalNEB(LocalOptimizer): + """ + The LocalNEB is used to run a local optimization of NEB. + The LocalNEB is applicable to be used with active learning. + """ + def __init__( self, - neb, + optimizable, local_opt=FIRE, local_opt_kwargs={}, parallel_run=False, @@ -17,11 +23,10 @@ def __init__( **kwargs, ): """ - The LocalNEB is used to run a local optimization of NEB. - The LocalNEB is applicable to be used with active learning. + Initialize the OptimizerMethod instance. Parameters: - neb: NEB instance + optimizable: NEB instance The NEB object to be optimized. local_opt: ASE optimizer object The local optimizer object. @@ -41,7 +46,7 @@ def __init__( """ # Set the parameters self.update_arguments( - neb=neb, + optimizable=optimizable, local_opt=local_opt, local_opt_kwargs=local_opt_kwargs, parallel_run=parallel_run, @@ -57,31 +62,89 @@ def update_optimizable(self, structures, **kwargs): positions = asarray(positions).reshape(-1, 3) # Set the positions of the NEB images self.optimizable.set_positions(positions) + # Find the minimum path length if possible and requested + if isinstance(self.optimizable, OriginalNEB): + self.optimizable.permute_images() # Reset the optimization self.reset_optimization() return self - def get_structures(self, get_all=True, **kwargs): + def get_structures( + self, + get_all=True, + properties=[], + allow_calculation=True, + **kwargs, + ): # Get only the first image if not get_all: - return self.copy_atoms(self.optimizable.images[0]) + return self.copy_atoms( + self.optimizable.images[0], + allow_calculation=False, + **kwargs, + ) # Get all the images if self.is_parallel_used(): - return self.get_structures_parallel(**kwargs) + return self.get_structures_parallel( + properties=properties, + allow_calculation=allow_calculation, + **kwargs, + ) structures = [ - self.copy_atoms(image) for image in self.optimizable.images + self.copy_atoms( + self.optimizable.images[0], allow_calculation=False, **kwargs + ) + ] + structures += [ + self.copy_atoms( + image, + properties=properties, + allow_calculation=allow_calculation, + **kwargs, + ) + for image in self.optimizable.images[1:-1] + ] + structures += [ + self.copy_atoms( + self.optimizable.images[-1], allow_calculation=False, **kwargs + ) ] return structures - def get_structures_parallel(self, **kwargs): - # Get the structures in parallel - structures = [self.copy_atoms(self.optimizable.images[0])] + def get_structures_parallel( + self, + properties=[], + allow_calculation=True, + **kwargs, + ): + "Get the structures in parallel." + # Get the initial structure + structures = [ + self.copy_atoms( + self.optimizable.images[0], + allow_calculation=False, + **kwargs, + ) + ] + # Get the moving images in parallel for i, image in enumerate(self.optimizable.images[1:-1]): root = i % self.size if self.rank == root: - image = self.copy_atoms(image) + image = self.copy_atoms( + image, + properties=properties, + allow_calculation=allow_calculation, + **kwargs, + ) structures.append(broadcast(image, root=root, comm=self.comm)) - structures.append(self.copy_atoms(self.optimizable.images[-1])) + # Get the final structure + structures.append( + self.copy_atoms( + self.optimizable.images[-1], + allow_calculation=False, + **kwargs, + ) + ) return structures def get_candidates(self, **kwargs): @@ -118,7 +181,7 @@ def is_parallel_allowed(self): def update_arguments( self, - neb=None, + optimizable=None, local_opt=None, local_opt_kwargs={}, parallel_run=None, @@ -132,7 +195,7 @@ def update_arguments( The existing arguments are used if they are not given. Parameters: - neb: NEB instance + optimizable: NEB instance The NEB object to be optimized. local_opt: ASE optimizer object The local optimizer object. @@ -152,7 +215,7 @@ def update_arguments( """ # Set the parameters in the parent class super().update_arguments( - optimizable=neb, + optimizable=optimizable, local_opt=local_opt, local_opt_kwargs=local_opt_kwargs, parallel_run=parallel_run, @@ -166,7 +229,7 @@ def get_arguments(self): "Get the arguments of the class itself." # Get the arguments given to the class in the initialization arg_kwargs = dict( - neb=self.optimizable, + optimizable=self.optimizable, local_opt=self.local_opt, local_opt_kwargs=self.local_opt_kwargs, parallel_run=self.parallel_run, diff --git a/catlearn/optimizer/method.py b/catlearn/optimizer/method.py index 19901987..a50ff5cb 100644 --- a/catlearn/optimizer/method.py +++ b/catlearn/optimizer/method.py @@ -1,12 +1,22 @@ -from numpy import max as max_ -from numpy.linalg import norm +from numpy import einsum, max as max_, sqrt from numpy.random import default_rng, Generator, RandomState from ase.parallel import world, broadcast -from ..regression.gp.calculator.copy_atoms import copy_atoms +import warnings +from ..regression.gp.calculator.copy_atoms import ( + copy_atoms, + StoredDataCalculator, +) from ..structures.structure import Structure class OptimizerMethod: + """ + The OptimizerMethod class is a base class for all optimization methods. + The OptimizerMethod is used to run an optimization on a given + optimizable. + The OptimizerMethod is applicable to be used with active learning. + """ + def __init__( self, optimizable, @@ -17,10 +27,7 @@ def __init__( **kwargs, ): """ - The OptimizerMethod class is a base class for all optimization methods. - The OptimizerMethod is used to run an optimization on a given - optimizable. - The OptimizerMethod is applicable to be used with active learning. + Initialize the OptimizerMethod instance. Parameters: optimizable: optimizable instance @@ -79,20 +86,36 @@ def get_optimizable(self, **kwargs): """ return self.optimizable - def get_structures(self, get_all=True, **kwargs): + def get_structures( + self, + get_all=True, + properties=[], + allow_calculation=True, + **kwargs, + ): """ Get the structures that optimizable instance is dependent on. Parameters: get_all: bool If True, all structures are returned. - Else, only the first structure is returned + Else, only the first structure is returned. + properties: list of str + The names of the requested properties. + If not given, the properties is not calculated. + allow_calculation: bool + Whether the properties are allowed to be calculated. Returns: structures: Atoms instance or list of Atoms instances The structures that the optimizable instance is dependent on. """ - return self.copy_atoms(self.optimizable) + return self.copy_atoms( + self.optimizable, + properties=properties, + allow_calculation=allow_calculation, + **kwargs, + ) def get_candidates(self, **kwargs): """ @@ -103,7 +126,7 @@ def get_candidates(self, **kwargs): def copy_candidates( self, - properties=["energy", "forces"], + properties=["forces", "energy"], allow_calculation=True, **kwargs, ): @@ -128,19 +151,14 @@ def copy_candidates( # Check the rank of the process atoms_new = None root = i % self.size - if self.rank == root: + if not is_parallel or self.rank == root: # Get the properties of the atoms instance - results = {} - for name in properties: - self.get_atoms_property( - atoms=atoms, - name=name, - allow_calculation=allow_calculation, - **kwargs, - ) - results.update(atoms.calc.results) - # Copy the atoms instance with all the properties - atoms_new = copy_atoms(atoms, results=results) + atoms_new = self.copy_atoms( + atoms=atoms, + properties=properties, + allow_calculation=allow_calculation, + **kwargs, + ) # Broadcast the atoms instance to all processes if is_parallel: atoms_new = broadcast(atoms_new, root=root, comm=self.comm) @@ -297,8 +315,11 @@ def get_fmax(self, per_candidate=False, **kwargs): fmax: float or list The maximum force of the optimizable. """ - force = self.get_forces(per_candidate=per_candidate, **kwargs) - fmax = norm(force, axis=-1).max(axis=-1) + forces = self.get_forces(per_candidate=per_candidate, **kwargs) + if per_candidate: + fmax = sqrt(einsum("ijk,ijk->ij", forces, forces)).max(-1) + else: + fmax = sqrt(einsum("ij,ij->i", forces, forces)).max() return fmax def get_uncertainty(self, per_candidate=False, **kwargs): @@ -391,7 +412,7 @@ def get_property( # Check the rank of the process result = None root = i % self.size - if self.rank == root: + if not is_parallel or self.rank == root: # Get the properties of the atoms instance result = self.get_atoms_property( atoms=atoms, @@ -405,27 +426,12 @@ def get_property( output.append(result) else: # Get the property of the optimizable instance - if name == "energy": - output = self.get_potential_energy( - per_candidate=per_candidate, - **kwargs, - ) - elif name == "forces": - output = self.get_forces(per_candidate=per_candidate, **kwargs) - elif name == "fmax": - output = self.get_fmax(per_candidate=per_candidate, **kwargs) - elif name == "uncertainty": - output = self.get_uncertainty( - per_candidate=per_candidate, - **kwargs, - ) - else: - output = self.optimizable.calc.get_property( - name, - atoms=self.optimizable, - allow_calculation=allow_calculation, - **kwargs, - ) + output = self.get_atoms_property( + atoms=self.optimizable, + name=name, + allow_calculation=allow_calculation, + **kwargs, + ) return output def get_properties( @@ -459,7 +465,7 @@ def get_properties( root = i % self.size for name in properties: result = None - if self.rank == root: + if not is_parallel or self.rank == root: # Get the properties of the atoms instance result = self.get_atoms_property( atoms=atoms, @@ -507,8 +513,8 @@ def get_atoms_property( elif name == "forces": result = atoms.get_forces(**kwargs) elif name == "fmax": - force = atoms.get_forces(**kwargs) - result = norm(force, axis=-1).max() + forces = atoms.get_forces(**kwargs) + result = sqrt(einsum("ij,ij->i", forces, forces)).max() elif name == "uncertainty" and isinstance( atoms, Structure, @@ -621,6 +627,9 @@ def run( coverged: bool Whether the optimization is converged. """ + # Check if the optimization can take any steps + if steps <= 0: + return self._converged raise NotImplementedError("The run method is not implemented") def run_max_unc(self, **kwargs): @@ -675,6 +684,25 @@ def check_convergence( return False return converged + def save_method(self, filename="method.pkl", **kwargs): + """ + Save the method instance to a file. + + Parameters: + filename: str + The name of the file where the instance is saved. + + Returns: + self: The instance itself. + """ + import pickle + + method_copy = self.copy() + method_copy.remove_parallel_setup() + with open(filename, "wb") as file: + pickle.dump(method_copy, file) + return self + def update_arguments( self, optimizable=None, @@ -705,15 +733,18 @@ def update_arguments( The seed can also be a RandomState or Generator instance. If not given, the default random number generator is used. """ + # Set and check the parallelization + if parallel_run is not None: + self.parallel_run = parallel_run + self.check_parallel() # Set the communicator if comm is not None: - self.comm = comm - self.rank = comm.rank - self.size = comm.size + self.parallel_setup(comm=comm) elif not hasattr(self, "comm"): - self.comm = None - self.rank = 0 - self.size = 1 + if self.parallel_run: + self.parallel_setup(comm=None) + else: + self.remove_parallel_setup() # Set the seed if seed is not None or not hasattr(self, "seed"): self.set_seed(seed) @@ -723,10 +754,23 @@ def update_arguments( # Set the optimizable if optimizable is not None: self.setup_optimizable(optimizable) - # Set and check the parallelization - if parallel_run is not None: - self.parallel_run = parallel_run - self.check_parallel() + return self + + def parallel_setup(self, comm, **kwargs): + "Setup the parallelization." + if comm is None: + self.comm = world + else: + self.comm = comm + self.rank = self.comm.rank + self.size = self.comm.size + return self + + def remove_parallel_setup(self): + "Remove the parallelization by removing the communicator." + self.comm = None + self.rank = 0 + self.size = 1 return self def set_seed(self, seed=None, **kwargs): @@ -753,29 +797,51 @@ def set_seed(self, seed=None, **kwargs): self.rng = default_rng() return self - def copy_atoms(self, atoms): + def copy_atoms( + self, + atoms, + properties=[], + allow_calculation=True, + **kwargs, + ): "Copy an atoms instance." - # Enforce the correct results in the calculator - if atoms.calc is not None: - if hasattr(atoms.calc, "results"): - if len(atoms.calc.results): - if "forces" in atoms.calc.results: - atoms.get_forces() - else: - atoms.get_potential_energy() - # Save the structure with saved properties - return copy_atoms(atoms) - - def message(self, message): + # Get the properties of the atoms instance + results = {} + if ( + allow_calculation + and atoms.calc is not None + and ( + atoms.calc is not StoredDataCalculator + or isinstance(atoms, Structure) + ) + ): + for name in properties: + self.get_atoms_property( + atoms=atoms, + name=name, + allow_calculation=allow_calculation, + **kwargs, + ) + results.update(atoms.calc.results) + # Copy the atoms instance with all the properties + return copy_atoms(atoms, results=results) + + def message(self, message, is_warning=False): "Print a message." if self.verbose and self.rank == 0: - print(message) + if is_warning: + warnings.warn(message) + else: + print(message) return self def check_parallel(self): "Check if the parallelization is allowed." if self.parallel_run and not self.is_parallel_allowed(): - self.message("Parallel run is not supported for this method!") + self.message( + "Parallel run is not supported for this method!", + is_warning=True, + ) return self def get_arguments(self): diff --git a/catlearn/optimizer/parallelopt.py b/catlearn/optimizer/parallelopt.py index 5ae81d2f..720dfd35 100644 --- a/catlearn/optimizer/parallelopt.py +++ b/catlearn/optimizer/parallelopt.py @@ -1,9 +1,15 @@ from .method import OptimizerMethod from ase.parallel import world, broadcast -from numpy import argmin, inf, max as max_ +from numpy import argmin, inf class ParallelOptimizer(OptimizerMethod): + """ + The ParallelOptimizer is used to run an optimization in parallel. + The ParallelOptimizer is applicable to be used with + active learning. + """ + def __init__( self, method, @@ -15,15 +21,15 @@ def __init__( **kwargs, ): """ - The ParallelOptimizer is used to run an optimization in parallel. - The ParallelOptimizer is applicable to be used with - active learning. + Initialize the OptimizerMethod instance. Parameters: method: OptimizerMethod instance The optimization method to be used. - chains: int + chains: int (optional) The number of optimization that will be run in parallel. + If not given, the number of chains is set to the number of + processors if parallel_run is True, otherwise it is set to 1. parallel_run: bool If True, the optimization will be run in parallel. comm: ASE communicator instance @@ -35,6 +41,8 @@ def __init__( The random seed for the optimization. The seed an also be a RandomState or Generator instance. If not given, the default random number generator is used. + A different seed is used for each chain if the seed is an + integer. """ # Set the parameters self.update_arguments( @@ -49,27 +57,75 @@ def __init__( def update_optimizable(self, structures, **kwargs): if isinstance(structures, list) and len(structures) == self.chains: + self.method.update_optimizable(structures[0]) for method, structure in zip(self.methods, structures): method.update_optimizable(structure, **kwargs) else: self.method.update_optimizable(structures, **kwargs) - self.methods = [self.method.copy() for _ in range(self.chains)] - self.reset_optimization() + for method in self.methods: + method.update_optimizable(structures, **kwargs) + # Reset the optimization + self.setup_optimizable() return self def get_optimizable(self, **kwargs): return self.method.get_optimizable(**kwargs) - def get_structures(self, get_all=True, **kwargs): - if get_all: - return [ - method.get_structures(get_all=get_all) - for method in self.methods - ] - return self.method.get_structures(get_all=get_all, **kwargs) + def get_structures( + self, + get_all=True, + properties=[], + allow_calculation=True, + **kwargs, + ): + if not get_all: + return self.method.get_structures( + get_all=get_all, + properties=properties, + allow_calculation=allow_calculation, + **kwargs, + ) + structures = [] + for chain, method in enumerate(self.methods): + root = chain % self.size + if self.rank == root: + # Get the structure + structure = method.get_structures( + properties=properties, + allow_calculation=allow_calculation, + **kwargs, + ) + else: + structure = None + # Broadcast the structure + structures.append( + broadcast( + structure, + root=root, + comm=self.comm, + ) + ) + return structures def get_candidates(self, **kwargs): - return self.candidates + candidates = [] + for chain, method in enumerate(self.methods): + root = chain % self.size + if self.rank == root: + # Get the candidate(s) + candidates_tmp = [ + candidate for candidate in method.get_candidates(**kwargs) + ] + else: + candidates_tmp = [] + # Broadcast the candidates + for candidate in broadcast( + candidates_tmp, + root=root, + comm=self.comm, + ): + candidates.append(candidate) + return candidates def run( self, @@ -83,83 +139,80 @@ def run( # Check if the optimization can take any steps if steps <= 0: return self._converged - # Make list of properties - structures = [None] * self.chains - candidates = [[]] * self.chains - converged = [False] * self.chains - used_steps = [self.steps] * self.chains - values = [inf] * self.chains # Run the optimizations - for chain, method in enumerate(self.methods): - root = chain % self.size - if self.rank == root: - # Set the random seed - self.change_seed(chain) - method.set_seed(self.seed) - # Run the optimization - converged[chain] = method.run( + converged_list = [ + ( + method.run( fmax=fmax, steps=steps, max_unc=max_unc, dtrust=dtrust, **kwargs, ) - # Update the number of steps - used_steps[chain] += method.get_number_of_steps() - # Get the structures - structures[chain] = method.get_structures() - # Get the candidates - candidates[chain] = method.get_candidates() - # Get the values + if self.rank == chain % self.size + else False + ) + for chain, method in enumerate(self.methods) + ] + # Save the structures, values, and used steps + structures = [] + values = [] + for chain, method in enumerate(self.methods): + root = chain % self.size + if self.rank == root: + # Get the structure + structure = method.get_structures() + # Get the value if self.method.is_energy_minimized(): - values[chain] = method.get_potential_energy() + value = method.get_potential_energy() else: - values[chain] = method.get_fmax() - # Broadcast the saved instances - for chain in range(self.chains): - root = chain % self.size - structures[chain] = broadcast( - structures[chain], - root=root, - comm=self.comm, - ) - candidates_tmp = broadcast( - [ - self.copy_atoms(candidate) - for candidate in candidates[chain] - ], - root=root, - comm=self.comm, - ) - if self.rank != root: - candidates[chain] = candidates_tmp - converged[chain] = broadcast( - converged[chain], - root=root, - comm=self.comm, - ) - used_steps[chain] = broadcast( - used_steps[chain], - root=root, - comm=self.comm, + value = method.get_fmax() + else: + structure = None + value = inf + # Broadcast the structure + structures.append( + broadcast( + structure, + root=root, + comm=self.comm, + ) ) - values[chain] = broadcast( - values[chain], - root=root, - comm=self.comm, + # Broadcast the values + values.append( + broadcast( + value, + root=root, + comm=self.comm, + ) ) - # Set the candidates - self.candidates = [] - for candidate_inner in candidates: - for candidate in candidate_inner: - self.candidates.append(candidate) - # Check the minimum value - i_min = argmin(values) - self.method = self.method.update_optimizable(structures[i_min]) - self.steps = max_(used_steps) + # Get the number of steps + self.steps += sum( + [ + broadcast( + method.get_number_of_steps(), + root=chain % self.size, + comm=self.comm, + ) + for chain, method in enumerate(self.methods) + ] + ) + # Find the best optimization + chain_min = argmin(values) + root = chain_min % self.size + # Broadcast whether the optimization is converged + converged = broadcast( + converged_list[chain_min], + root=root, + comm=self.comm, + ) + # Get the best structure and update the method + structure = structures[chain_min] + self.method = self.method.update_optimizable(structure) + self.optimizable = self.method.get_optimizable() # Check if the optimization is converged self._converged = self.check_convergence( - converged=converged[i_min], + converged=converged, max_unc=max_unc, dtrust=dtrust, unc_convergence=unc_convergence, @@ -221,40 +274,54 @@ def update_arguments( if chains is not None: self.chains = chains elif not hasattr(self, "chains"): - if self.parallel_run: - chains = comm.size - else: - chains = 1 - self.chains = chains + self.chains = self.size # Set the method if method is not None: self.method = method.copy() self.methods = [method.copy() for _ in range(self.chains)] + self.set_seed(seed=self.seed) + self.setup_optimizable() + # Check if the method is set correctly + if len(self.methods) != self.chains: + self.message( + "The number of chains should be equal to " + "the number of methods!", + is_warning=True, + ) + self.methods = [method.copy() for _ in range(self.chains)] + self.set_seed(seed=self.seed) self.setup_optimizable() # Check if the number of chains is optimal if self.chains % self.size != 0: self.message( "The number of chains should be divisible by " - "the number of processors!" + "the number of processors!", + is_warning=True, ) return self + def set_seed(self, seed=None, **kwargs): + # Set the seed for the class + super().set_seed(seed=seed, **kwargs) + # Set the seed for the method + if hasattr(self, "method"): + self.method.set_seed(seed=seed, **kwargs) + # Set the seed for each method + if isinstance(seed, int): + for method in self.methods: + method.set_seed(seed=seed, **kwargs) + seed += 1 + else: + for chain, method in enumerate(self.methods): + method.set_seed(seed=seed, **kwargs) + method.rng.random(size=chain) + return self + def setup_optimizable(self, **kwargs): self.optimizable = self.method.get_optimizable() - self.structures = self.method.get_structures() - self.candidates = self.method.get_candidates() self.reset_optimization() return self - def change_seed(self, chain, **kwargs): - "Change the random seed for the given chain." - if isinstance(self.seed, int): - seed = self.seed + chain - self.set_seed(seed=seed) - else: - [self.rng.random() for _ in range(chain)] - return self - def get_arguments(self): "Get the arguments of the class itself." # Get the arguments given to the class in the initialization diff --git a/catlearn/optimizer/sequential.py b/catlearn/optimizer/sequential.py index dc05d51e..91a15635 100644 --- a/catlearn/optimizer/sequential.py +++ b/catlearn/optimizer/sequential.py @@ -3,6 +3,13 @@ class SequentialOptimizer(OptimizerMethod): + """ + The SequentialOptimizer is used to run multiple optimizations in + sequence for a given structure. + The SequentialOptimizer is applicable to be used with + active learning. + """ + def __init__( self, methods, @@ -14,10 +21,7 @@ def __init__( **kwargs, ): """ - The SequentialOptimizer is used to run multiple optimizations in - sequence for a given structure. - The SequentialOptimizer is applicable to be used with - active learning. + Initialize the OptimizerMethod instance. Parameters: methods: List of OptimizerMethod objects @@ -50,23 +54,29 @@ def __init__( def update_optimizable(self, structures, **kwargs): # Update optimizable for the first method self.methods[0].update_optimizable(structures, **kwargs) - self.optimizable = self.methods[0].get_optimizable() # Reset the optimization and update the optimizable self.setup_optimizable() return self def get_optimizable(self): - return self.optimizable + return self.method.get_optimizable() - def get_structures(self, get_all=True, **kwargs): - if isinstance(self.structures, list): - if not get_all: - return self.copy_atoms(self.structures[0]) - return [self.copy_atoms(struc) for struc in self.structures] - return self.copy_atoms(self.structures) + def get_structures( + self, + get_all=True, + properties=[], + allow_calculation=True, + **kwargs, + ): + return self.method.get_structures( + get_all=get_all, + properties=properties, + allow_calculation=allow_calculation, + **kwargs, + ) def get_candidates(self, **kwargs): - return self.candidates + return self.method.get_candidates(**kwargs) def run( self, @@ -82,13 +92,14 @@ def run( return self._converged # Get number of methods n_methods = len(self.methods) + structures = None # Run the optimizations - for i, method in enumerate(self.methods): + for i, self.method in enumerate(self.methods): # Update the structures if not the first method if i > 0: - method.update_optimizable(self.structures) + self.method.update_optimizable(structures) # Run the optimization - converged = method.run( + converged = self.method.run( fmax=fmax, steps=steps, max_unc=max_unc, @@ -96,12 +107,11 @@ def run( **kwargs, ) # Get the structures - self.optimizable = method.get_optimizable() - self.structures = method.get_structures() - self.candidates = method.get_candidates() + structures = self.method.get_structures(allow_calculation=False) + self.optimizable = self.method.get_optimizable() # Update the number of steps - self.steps += method.get_number_of_steps() - steps -= method.get_number_of_steps() + self.steps += self.method.get_number_of_steps() + steps -= self.method.get_number_of_steps() # Check if the optimization is converged converged = self.check_convergence( converged=converged, @@ -124,14 +134,14 @@ def run( return self._converged def set_calculator(self, calculator, copy_calc=False, **kwargs): + self.method.set_calculator(calculator, copy_calc=copy_calc, **kwargs) for method in self.methods: method.set_calculator(calculator, copy_calc=copy_calc, **kwargs) return self def setup_optimizable(self, **kwargs): - self.optimizable = self.methods[0].get_optimizable() - self.structures = self.methods[0].get_structures() - self.candidates = self.methods[0].get_candidates() + self.method = self.methods[0] + self.optimizable = self.method.get_optimizable() self.reset_optimization() return self @@ -189,6 +199,14 @@ def update_arguments( ) return self + def set_seed(self, seed=None, **kwargs): + # Set the seed for the class + super().set_seed(seed=seed, **kwargs) + # Set the seed for each method + for method in self.methods: + method.set_seed(seed=seed, **kwargs) + return self + def get_arguments(self): "Get the arguments of the class itself." # Get the arguments given to the class in the initialization diff --git a/catlearn/structures/structure.py b/catlearn/structures/structure.py index 0a3b09b2..d529cbf5 100644 --- a/catlearn/structures/structure.py +++ b/catlearn/structures/structure.py @@ -7,7 +7,10 @@ class Structure(Atoms): def __init__(self, atoms, *args, **kwargs): self.atoms = atoms self.__dict__.update(atoms.__dict__) - self.store_results() + if atoms.calc is not None and len(atoms.calc.results): + self.store_results() + else: + self.reset() def set_positions(self, *args, **kwargs): self.atoms.set_positions(*args, **kwargs) From a2139b95c05410997e2885acf8b6671ae679f7aa Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Wed, 23 Jul 2025 13:23:48 +0200 Subject: [PATCH 138/194] New functions and options in NEB --- catlearn/structures/neb/ewneb.py | 30 +++ catlearn/structures/neb/interpolate_band.py | 276 ++++++++++++++++++-- catlearn/structures/neb/maxewneb.py | 30 +++ catlearn/structures/neb/orgneb.py | 175 ++++++++++++- 4 files changed, 484 insertions(+), 27 deletions(-) diff --git a/catlearn/structures/neb/ewneb.py b/catlearn/structures/neb/ewneb.py index a20878e8..233606e3 100644 --- a/catlearn/structures/neb/ewneb.py +++ b/catlearn/structures/neb/ewneb.py @@ -21,6 +21,7 @@ def __init__( climb=False, remove_rotation_and_translation=False, mic=True, + use_image_permutation=False, save_properties=False, parallel=False, comm=world, @@ -53,6 +54,12 @@ def __init__( mic: bool Minimum Image Convention (Shortest distances when periodic boundary conditions are used). + use_image_permutation: bool + Whether to permute images to minimize the path length. + It assumes a greedy algorithm to find the minimum path length + by selecting the next image that is closest to the previous + image. + It is only used in the initialization of the NEB. save_properties: bool Whether to save the properties by making a copy of the images. parallel: bool @@ -66,6 +73,7 @@ def __init__( climb=climb, remove_rotation_and_translation=remove_rotation_and_translation, mic=mic, + use_image_permutation=use_image_permutation, save_properties=save_properties, parallel=parallel, comm=comm, @@ -92,3 +100,25 @@ def get_spring_constants(self, **kwargs): else: k = k_l return k + + def get_arguments(self): + "Get the arguments of the class itself." + # Get the arguments given to the class in the initialization + arg_kwargs = dict( + images=self.images, + k=self.k, + kl_scale=self.kl_scale, + use_minimum=self.use_minimum, + climb=self.climb, + remove_rotation_and_translation=self.rm_rot_trans, + mic=self.mic, + use_image_permutation=self.use_image_permutation, + save_properties=self.save_properties, + parallel=self.parallel, + comm=self.comm, + ) + # Get the constants made within the class + constant_kwargs = dict() + # Get the objects made within the class + object_kwargs = dict() + return arg_kwargs, constant_kwargs, object_kwargs diff --git a/catlearn/structures/neb/interpolate_band.py b/catlearn/structures/neb/interpolate_band.py index 530db5dd..7038146e 100644 --- a/catlearn/structures/neb/interpolate_band.py +++ b/catlearn/structures/neb/interpolate_band.py @@ -1,5 +1,6 @@ from numpy import ndarray from numpy.linalg import norm +from numpy.random import default_rng from ase.io import read from ase.optimize import FIRE from ase.build import minimize_rotation_and_translation @@ -146,9 +147,54 @@ def make_interpolation( mic=True, **interpolation_kwargs, ): - "Make the NEB interpolation path." + """ + Make the NEB interpolation path. + The method can be one of the following: 'linear', 'idpp', 'rep', + 'born', or 'ends'. If a list of ASE Atoms instances is given, + then the interpolation will be made between the start and end + structure using the images in the list. If a string is given, + then it should be the name of a trajectory file. In that case, + the interpolation will be made using the images in the trajectory + file. The trajectory file should contain the start and end structure. + + Parameters: + start: ASE Atoms instance + The starting structure for the NEB interpolation. + end: ASE Atoms instance + The ending structure for the NEB interpolation. + n_images: int + The number of images in the NEB interpolation. + method: str or list of ASE Atoms instances + The method to use for the NEB interpolation. If a list of + ASE Atoms instances is given, then the interpolation will be + made between the start and end structure using the images in + the list. If a string is given, then it should be one of the + following: 'linear', 'idpp', 'rep', or 'ends'. The string can + also be the name of a trajectory file. In that case, the + interpolation will be made using the images in the trajectory + file. The trajectory file should contain the start and end + structure. + mic: bool + If True, then the minimum-image convention is used for the + interpolation. If False, then the images are not constrained + to the minimum-image convention. + interpolation_kwargs: dict + Additional keyword arguments to pass to the interpolation + methods. + + Returns: + list of ASE Atoms instances + The list of images with the interpolation between + the initial and final state. + """ # Use a premade interpolation path if isinstance(method, (list, ndarray)): + # Check if the number of images in the method is equal to n_images + if len(method) != n_images: + raise ValueError( + "The number of images in the method should be " + "equal to n_images." + ) images = [copy_atoms(image) for image in method[1:-1]] images = [copy_atoms(start)] + images + [copy_atoms(end)] elif isinstance(method, str) and method.lower() not in [ @@ -198,8 +244,41 @@ def make_interpolation( return images -def make_linear_interpolation(images, mic=False, **kwargs): - "Make the linear interpolation from initial to final state." +def make_linear_interpolation( + images, + mic=False, + use_perturbation=False, + d_perturb=0.02, + seed=1, + **kwargs, +): + """ + Make the linear interpolation from initial to final state. + + Parameters: + images: list of ASE Atoms instances + The list of images to interpolate between. + mic: bool + If True, then the minimum-image convention is used for the + interpolation. If False, then the images are not constrained + to the minimum-image convention. + use_perturbation: bool + If True, then the images are perturbed with a Gaussian noise + with a standard deviation of d_perturb. + d_perturb: float + The standard deviation of the Gaussian noise used to perturb + the images if use_perturbation is True. + seed: int (optional) + The random seed used to generate the Gaussian noise if + use_perturbation is True. + If seed is None, then the default random number generator + is used. + + Returns: + list of ASE Atoms instances + The list of images with the interpolation between + the initial and final state. + """ # Get the position of initial state pos0 = images[0].get_positions() # Get the distance to the final state @@ -214,9 +293,18 @@ def make_linear_interpolation(images, mic=False, **kwargs): ) # Calculate the distance moved for each image dist_vec = dist_vec / float(len(images) - 1) + # Make random generator if perturbation is used + if use_perturbation: + rng = default_rng(seed) # Set the positions for i in range(1, len(images) - 1): - images[i].set_positions(pos0 + (i * dist_vec)) + # Get the position of the image + pos = pos0 + (i * dist_vec) + # Add perturbation if requested + if use_perturbation: + pos += rng.normal(0.0, d_perturb, size=pos.shape) + # Set the position of the image + images[i].set_positions(pos) return images @@ -225,6 +313,8 @@ def make_idpp_interpolation( mic=False, fmax=1.0, steps=100, + neb_method=ImprovedTangentNEB, + neb_kwargs={}, local_opt=FIRE, local_kwargs={}, **kwargs, @@ -232,6 +322,33 @@ def make_idpp_interpolation( """ Make the IDPP interpolation from initial to final state from NEB optimization. + + Parameters: + images: list of ASE Atoms instances + The list of images to interpolate between. + mic: bool + If True, then the minimum-image convention is used for the + interpolation. If False, then the images are not constrained + to the minimum-image convention. + fmax: float + The maximum force for the optimization. + steps: int + The number of optimization steps. + neb_method: class + The NEB method to use for the optimization. + The default is ImprovedTangentNEB. + neb_kwargs: dict + The keyword arguments for the NEB method. + local_opt: ASE optimizer object + The local optimizer object to use for the optimization. + The default is FIRE. + local_kwargs: dict + The keyword arguments for the local optimizer. + + Returns: + list of ASE Atoms instances + The list of images with the interpolation between + the initial and final state. """ from ...regression.gp.baseline import IDPP @@ -247,10 +364,10 @@ def make_idpp_interpolation( target = dist0 + (i + 1) * dist image.calc = IDPP(target=target, mic=mic) # Make default NEB - neb = ImprovedTangentNEB(images) + neb = neb_method(images, **neb_kwargs) # Set local optimizer arguments local_kwargs_default = dict(trajectory="idpp.traj", logfile="idpp.log") - if isinstance(local_opt, FIRE): + if issubclass(local_opt, FIRE): local_kwargs_default.update( dict(dt=0.05, a=1.0, astart=1.0, fa=0.999, maxstep=0.2) ) @@ -266,6 +383,8 @@ def make_rep_interpolation( mic=False, fmax=1.0, steps=100, + neb_method=ImprovedTangentNEB, + neb_kwargs={}, local_opt=FIRE, local_kwargs={}, calc_kwargs={}, @@ -275,6 +394,41 @@ def make_rep_interpolation( ): """ Make a repulsive potential to get the interpolation from NEB optimization. + + Parameters: + images: list of ASE Atoms instances + The list of images to interpolate between. + mic: bool + If True, then the minimum-image convention is used for the + interpolation. If False, then the images are not constrained + to the minimum-image convention. + fmax: float + The maximum force for the optimization. + steps: int + The number of optimization steps. + neb_method: class + The NEB method to use for the optimization. + The default is ImprovedTangentNEB. + neb_kwargs: dict + The keyword arguments for the NEB method. + local_opt: ASE optimizer object + The local optimizer object to use for the optimization. + The default is FIRE. + local_kwargs: dict + The keyword arguments for the local optimizer. + calc_kwargs: dict + The keyword arguments for the repulsive potential calculator. + trajectory: str (optional) + The name of the trajectory file to save the optimization path. + If None, then the trajectory is not saved. + logfile: str (optional) + The name of the log file to save the optimization output. + If None, then the log file is not saved. + + Returns: + list of ASE Atoms instances + The list of images with the interpolation between + the initial and final state. """ from ...regression.gp.baseline import RepulsionCalculator @@ -282,10 +436,10 @@ def make_rep_interpolation( for image in images[1:-1]: image.calc = RepulsionCalculator(mic=mic, **calc_kwargs) # Make default NEB - neb = ImprovedTangentNEB(images) + neb = neb_method(images, **neb_kwargs) # Set local optimizer arguments local_kwargs_default = dict(trajectory=trajectory, logfile=logfile) - if isinstance(local_opt, FIRE): + if issubclass(local_opt, FIRE): local_kwargs_default.update( dict(dt=0.05, a=1.0, astart=1.0, fa=0.999, maxstep=0.2) ) @@ -301,6 +455,8 @@ def make_born_interpolation( mic=False, fmax=1.0, steps=100, + neb_method=ImprovedTangentNEB, + neb_kwargs={}, local_opt=FIRE, local_kwargs={}, calc_kwargs={}, @@ -311,6 +467,41 @@ def make_born_interpolation( """ Make a Born repulsive potential to get the interpolation from NEB optimization. + + Parameters: + images: list of ASE Atoms instances + The list of images to interpolate between. + mic: bool + If True, then the minimum-image convention is used for the + interpolation. If False, then the images are not constrained + to the minimum-image convention. + fmax: float + The maximum force for the optimization. + steps: int + The number of optimization steps. + neb_method: class + The NEB method to use for the optimization. + The default is ImprovedTangentNEB. + neb_kwargs: dict + The keyword arguments for the NEB method. + local_opt: ASE optimizer object + The local optimizer object to use for the optimization. + The default is FIRE. + local_kwargs: dict + The keyword arguments for the local optimizer. + calc_kwargs: dict + The keyword arguments for the Born repulsive potential calculator. + trajectory: str (optional) + The name of the trajectory file to save the optimization path. + If None, then the trajectory is not saved. + logfile: str (optional) + The name of the log file to save the optimization output. + If None, then the log file is not saved. + + Returns: + list of ASE Atoms instances + The list of images with the interpolation between + the initial and final state. """ from ...regression.gp.baseline import BornRepulsionCalculator @@ -318,10 +509,10 @@ def make_born_interpolation( for image in images[1:-1]: image.calc = BornRepulsionCalculator(mic=mic, **calc_kwargs) # Make default NEB - neb = ImprovedTangentNEB(images) + neb = neb_method(images, **neb_kwargs) # Set local optimizer arguments local_kwargs_default = dict(trajectory=trajectory, logfile=logfile) - if isinstance(local_opt, FIRE): + if issubclass(local_opt, FIRE): local_kwargs_default.update( dict(dt=0.05, a=1.0, astart=1.0, fa=0.999, maxstep=0.2) ) @@ -332,11 +523,48 @@ def make_born_interpolation( return images -def make_end_interpolations(images, mic=False, trust_dist=0.2, **kwargs): +def make_end_interpolations( + images, + mic=False, + trust_dist=0.2, + use_perturbation=False, + d_perturb=0.02, + seed=1, + **kwargs, +): """ Make the linear interpolation from initial to final state, but place the images at the initial and final states with the maximum distance as trust_dist. + + Parameters: + images: list of ASE Atoms instances + The list of images to interpolate between. + mic: bool + If True, then the minimum-image convention is used for the + interpolation. If False, then the images are not constrained + to the minimum-image convention. + trust_dist: float + The maximum distance between the initial and final state. + If the distance between the initial and final state is smaller + than trust_dist, then the images are placed at the initial and + final states with the maximum distance as trust_dist. + use_perturbation: bool + If True, then the images are perturbed with a Gaussian noise + with a standard deviation of d_perturb. + d_perturb: float + The standard deviation of the Gaussian noise used to perturb + the images if use_perturbation is True. + seed: int (optional) + The random seed used to generate the Gaussian noise if + use_perturbation is True. + If seed is None, then the default random number generator + is used. + + Returns: + list of ASE Atoms instances + The list of images with the interpolation between + the initial and final state. """ # Get the number of images n_images = len(images) @@ -356,23 +584,33 @@ def make_end_interpolations(images, mic=False, trust_dist=0.2, **kwargs): scale_dist = 2.0 * trust_dist / norm(dist_vec) # Check if the distance is within the trust distance if scale_dist >= 1.0: - # Calculate the distance moved for each image - dist_vec = dist_vec / float(n_images - 1) - # Set the positions - for i in range(1, n_images - 1): - images[i].set_positions(pos0 + (i * dist_vec)) - return images + return make_linear_interpolation( + images, + mic=mic, + use_perturbation=use_perturbation, + d_perturb=d_perturb, + seed=seed, + **kwargs, + ) # Calculate the distance moved for each image dist_vec = dist_vec * (scale_dist / float(n_images - 1)) # Get the position of final state posn = images[-1].get_positions() + # Make random generator if perturbation is used + if use_perturbation: + rng = default_rng(seed) # Set the positions nfirst = int(0.5 * (n_images - 1)) for i in range(1, n_images - 1): if i <= nfirst: - images[i].set_positions(pos0 + (i * dist_vec)) + pos = pos0 + (i * dist_vec) else: - images[i].set_positions(posn - ((n_images - 1 - i) * dist_vec)) + pos = posn - ((n_images - 1 - i) * dist_vec) + # Add perturbation if requested + if use_perturbation: + pos += rng.normal(0.0, d_perturb, size=pos.shape) + # Set the position of the image + images[i].set_positions(pos) return images diff --git a/catlearn/structures/neb/maxewneb.py b/catlearn/structures/neb/maxewneb.py index 216e075b..dd31b8ea 100644 --- a/catlearn/structures/neb/maxewneb.py +++ b/catlearn/structures/neb/maxewneb.py @@ -21,6 +21,7 @@ def __init__( climb=False, remove_rotation_and_translation=False, mic=True, + use_image_permutation=False, save_properties=False, parallel=False, comm=world, @@ -52,6 +53,12 @@ def __init__( mic: bool Minimum Image Convention (Shortest distances when periodic boundary conditions are used). + use_image_permutation: bool + Whether to permute images to minimize the path length. + It assumes a greedy algorithm to find the minimum path length + by selecting the next image that is closest to the previous + image. + It is only used in the initialization of the NEB. save_properties: bool Whether to save the properties by making a copy of the images. parallel: bool @@ -65,6 +72,7 @@ def __init__( climb=climb, remove_rotation_and_translation=remove_rotation_and_translation, mic=mic, + use_image_permutation=use_image_permutation, save_properties=save_properties, parallel=parallel, comm=comm, @@ -88,3 +96,25 @@ def get_spring_constants(self, **kwargs): else: k = k_l return k + + def get_arguments(self): + "Get the arguments of the class itself." + # Get the arguments given to the class in the initialization + arg_kwargs = dict( + images=self.images, + k=self.k, + kl_scale=self.kl_scale, + dE=self.dE, + climb=self.climb, + remove_rotation_and_translation=self.rm_rot_trans, + mic=self.mic, + use_image_permutation=self.use_image_permutation, + save_properties=self.save_properties, + parallel=self.parallel, + comm=self.comm, + ) + # Get the constants made within the class + constant_kwargs = dict() + # Get the objects made within the class + object_kwargs = dict() + return arg_kwargs, constant_kwargs, object_kwargs diff --git a/catlearn/structures/neb/orgneb.py b/catlearn/structures/neb/orgneb.py index 15caceda..6e92ee27 100644 --- a/catlearn/structures/neb/orgneb.py +++ b/catlearn/structures/neb/orgneb.py @@ -1,10 +1,13 @@ from numpy import ( + arange, argmax, array, asarray, einsum, + empty, full, nanmax, + ones, sqrt, vdot, zeros, @@ -13,7 +16,6 @@ from ase.build import minimize_rotation_and_translation from ase.parallel import world, broadcast import warnings -from .interpolate_band import interpolate from ..structure import Structure from ...regression.gp.fingerprint.geometry import mic_distance from ...regression.gp.calculator.copy_atoms import compare_atoms @@ -35,10 +37,11 @@ def __init__( climb=False, remove_rotation_and_translation=False, mic=True, + use_image_permutation=False, save_properties=False, parallel=False, comm=world, - **kwargs + **kwargs, ): """ Initialize the NEB instance. @@ -59,6 +62,12 @@ def __init__( mic: bool Minimum Image Convention (Shortest distances when periodic boundary conditions are used). + use_image_permutation: bool + Whether to permute images to minimize the path length. + It assumes a greedy algorithm to find the minimum path length + by selecting the next image that is closest to the previous + image. + It is only used in the initialization of the NEB. save_properties: bool Whether to save the properties by making a copy of the images. parallel: bool @@ -86,6 +95,9 @@ def __init__( self.rm_rot_trans = remove_rotation_and_translation self.mic = mic self.save_properties = save_properties + self.use_image_permutation = use_image_permutation + # Find the minimum path length if requested + self.permute_images() # Set the parallelization self.parallel = parallel if parallel: @@ -136,6 +148,8 @@ def interpolate(self, method="linear", mic=True, **kwargs): Returns: self: The instance itself. """ + from .interpolate_band import interpolate + self.images = interpolate( self.images[0], self.images[-1], @@ -143,7 +157,7 @@ def interpolate(self, method="linear", mic=True, **kwargs): method=method, mic=mic, remove_rotation_and_translation=self.rm_rot_trans, - **kwargs + **kwargs, ) return self @@ -312,14 +326,14 @@ def get_position_diff(self): """ positions = self.get_image_positions() position_diff = positions[1:] - positions[:-1] - pbc = asarray(self.images[0].get_pbc()) + pbc = self.get_pbc() if self.mic and pbc.any(): - cell = asarray(self.images[0].get_cell()) + cell = self.get_cell() _, position_diff = mic_distance( position_diff, - cell, - pbc, - vector=True, + cell=cell, + pbc=pbc, + use_vector=True, ) return position_diff[1:], position_diff[:-1] @@ -344,6 +358,90 @@ def get_spring_constants(self, **kwargs): "Get the spring constants for the images." return self.k + def get_path_length(self, **kwargs): + "Get the path length of the NEB." + # Get the distances between the images + pos_p, pos_m = self.get_position_diff() + # Calculate the path length + path_len = sqrt(einsum("ijk,ijk->i", pos_p, pos_p)).sum() + path_len += sqrt(einsum("ij,ij->", pos_m[0], pos_m[0])) + return path_len + + def permute_images(self, **kwargs): + """ + Set the minimum path length by minimizing the distance between + the images by permuting the images. + """ + # Check if there are enough images to optimize + if self.nimages <= 3 or not self.use_image_permutation: + return self + # Find the minimum path length + selected_indices = self.find_minimum_path_length(**kwargs) + # Set the images to the selected indices + self.images = [self.images[i] for i in selected_indices] + # Reset energies and forces + self.reset() + return self + + def find_minimum_path_length(self, **kwargs): + """ + Find the minimum path length by minimizing the distance between + the images. + """ + # Get the positions of the images + positions = self.get_image_positions() + positions = positions.reshape(self.nimages, -1) + # Get the periodic boundary conditions + pbc = self.get_pbc() + cell = self.get_cell() + use_mic = self.mic and pbc.any() + # Set the indices for the selected images + indices = arange(self.nimages, dtype=int) + selected_indices = empty(self.nimages, dtype=int) + selected_indices[0] = 0 + selected_indices[-1] = self.nimages - 1 + i_f = 1 + i_b = self.nimages - 2 + i_min_f = 0 + i_min_b = self.nimages - 1 + is_forward = True + i_min = i_min_f + # Create a boolean array to keep track of available images + available = ones(self.nimages, dtype=bool) + available[0] = available[-1] = False + # Loop until all images are selected + while available.any(): + candidates = indices[available] + # Find the minimum distance to the current images + dist = positions[candidates] - positions[i_min, None] + if use_mic: + dist, _ = mic_distance( + dist, + cell=cell, + pbc=pbc, + use_vector=False, + ) + else: + dist = sqrt(einsum("ij,ij->i", dist, dist)) + i_min = dist.argmin() + if is_forward: + # Find the minimum distance from the start image + i_min_f = candidates[i_min] + selected_indices[i_f] = i_min_f + available[i_min_f] = False + i_f += 1 + i_min = i_min_b + else: + # Find the minimum distance from the end image + i_min_b = candidates[i_min] + selected_indices[i_b] = i_min_b + available[i_min_b] = False + i_b -= 1 + i_min = i_min_f + # Switch the direction for the next iteration + is_forward = not is_forward + return selected_indices + def reset(self): "Reset the stored properties." self.energies = None @@ -441,3 +539,64 @@ def iterimages(self): forces=self.real_forces[i], ) yield atoms + + def get_pbc(self): + """ + Get the periodic boundary conditions of the images. + + Returns: + (3,) array: The periodic boundary conditions of the images. + """ + return asarray(self.images[0].get_pbc()) + + def get_cell(self): + """ + Get the cell of the images. + + Returns: + (3,3) array: The cell of the images. + """ + return asarray(self.images[0].get_cell()) + + def get_arguments(self): + "Get the arguments of the class itself." + # Get the arguments given to the class in the initialization + arg_kwargs = dict( + images=self.images, + k=self.k, + climb=self.climb, + remove_rotation_and_translation=self.rm_rot_trans, + mic=self.mic, + use_image_permutation=self.use_image_permutation, + save_properties=self.save_properties, + parallel=self.parallel, + comm=self.comm, + ) + # Get the constants made within the class + constant_kwargs = dict() + # Get the objects made within the class + object_kwargs = dict() + return arg_kwargs, constant_kwargs, object_kwargs + + def copy(self): + "Copy the object." + # Get all arguments + arg_kwargs, constant_kwargs, object_kwargs = self.get_arguments() + # Make a clone + clone = self.__class__(**arg_kwargs) + # Check if constants have to be saved + if len(constant_kwargs.keys()): + for key, value in constant_kwargs.items(): + clone.__dict__[key] = value + # Check if objects have to be saved + if len(object_kwargs.keys()): + for key, value in object_kwargs.items(): + clone.__dict__[key] = value.copy() + return clone + + def __repr__(self): + arg_kwargs = self.get_arguments()[0] + str_kwargs = ",".join( + [f"{key}={value}" for key, value in arg_kwargs.items()] + ) + return "{}({})".format(self.__class__.__name__, str_kwargs) From a9acc16b381e6046f19bedf922daf300cc092a57 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Wed, 23 Jul 2025 14:00:56 +0200 Subject: [PATCH 139/194] A lot of new options in active learning. Including logging of time, saving of models at each iteration, and setting max_unc_restart. --- catlearn/activelearning/activelearning.py | 814 +++++++++++++++++----- 1 file changed, 639 insertions(+), 175 deletions(-) diff --git a/catlearn/activelearning/activelearning.py b/catlearn/activelearning/activelearning.py index ae83d431..254f03c9 100644 --- a/catlearn/activelearning/activelearning.py +++ b/catlearn/activelearning/activelearning.py @@ -1,14 +1,31 @@ -from numpy import asarray, max as max_, mean as mean_, nan, nanmax, ndarray +from numpy import ( + asarray, + max as max_, + mean as mean_, + nan, + nanmax, + ndarray, + sqrt, +) from numpy.linalg import norm from numpy.random import default_rng, Generator, RandomState from ase.io import read from ase.parallel import world, broadcast from ase.io.trajectory import TrajectoryWriter import datetime -from ..regression.gp.calculator.copy_atoms import copy_atoms +from time import time +import warnings +from ..regression.gp.calculator import BOCalculator, compare_atoms, copy_atoms +from ..regression.gp.means.max import Prior_max +from ..regression.gp.baseline import BornRepulsionCalculator class ActiveLearning: + """ + An active learner that is used for accelerating quantum mechanincal + simulation methods with an active learning approach. + """ + def __init__( self, method, @@ -16,7 +33,6 @@ def __init__( mlcalc=None, acq=None, is_minimization=True, - use_database_check=True, save_memory=False, parallel_run=False, copy_calc=False, @@ -25,29 +41,37 @@ def __init__( force_consistent=False, scale_fmax=0.8, use_fmax_convergence=True, - unc_convergence=0.05, + unc_convergence=0.02, use_method_unc_conv=True, use_restart=True, check_unc=True, check_energy=True, check_fmax=True, + max_unc_restart=0.05, n_evaluations_each=1, min_data=3, + use_database_check=True, + data_perturb=0.001, + data_tol=1e-8, save_properties_traj=True, + to_save_mlcalc=False, + save_mlcalc_kwargs={}, trajectory="predicted.traj", trainingset="evaluated.traj", + pred_evaluated="predicted_evaluated.traj", converged_trajectory="converged.traj", initial_traj="initial_struc.traj", tabletxt="ml_summary.txt", + timetxt="ml_time.txt", prev_calculations=None, restart=False, - seed=None, + seed=1, + dtype=float, comm=world, **kwargs, ): """ - An active learner that is used for accelerating quantum mechanincal - simulation methods with an active learning approach. + Initialize the ActiveLearning instance. Parameters: method: OptimizationMethod instance @@ -64,9 +88,6 @@ def __init__( is_minimization: bool Whether it is a minimization that is performed. Alternative is a maximization. - use_database_check: bool - Whether to check if the new structure is within the database. - If it is in the database, the structure is rattled. save_memory: bool Whether to only train the ML calculator and store all objects on one CPU. @@ -97,8 +118,8 @@ def __init__( use_fmax_convergence: bool Whether to use the maximum force as an convergence criterion. unc_convergence: float - Maximum uncertainty for convergence in - the active learning (in eV). + Maximum uncertainty for convergence in the active learning + (in eV). use_method_unc_conv: bool Whether to use the unc_convergence as a convergence criterion in the optimization method. @@ -113,13 +134,35 @@ def __init__( check_fmax: bool Check if the maximum force is larger for the restarted result than the initial interpolation and if so then replace it. + max_unc_restart: float (optional) + Maximum uncertainty (in eV) for using the structure(s) as + the restart in the optimization method. + If max_unc_restart is None, then the optimization is performed + without the maximum uncertainty. n_evaluations_each: int Number of evaluations for each iteration. min_data: int The minimum number of data points in the training set before the active learning can converge. + use_database_check: bool + Whether to check if the new structure is within the database. + If it is in the database, the structure is rattled. + Please be aware that the predicted structure will differ from + the structure in the database if the rattling is applied. + data_perturb: float + The perturbation of the data structure if it is in the database + and use_database_check is True. + data_perturb is the standard deviation of the normal + distribution used to rattle the structure. + data_tol: float + The tolerance for the data structure if it is in the database + and use_database_check is True. save_properties_traj: bool Whether to save the calculated properties to the trajectory. + to_save_mlcalc: bool + Whether to save the ML calculator to a file after training. + save_mlcalc_kwargs: dict + Arguments for saving the ML calculator, like the filename. trajectory: str or TrajectoryWriter instance Trajectory filename to store the predicted data. Or the TrajectoryWriter instance to store the predicted data. @@ -127,6 +170,13 @@ def __init__( Trajectory filename to store the evaluated training data. Or the TrajectoryWriter instance to store the evaluated training data. + pred_evaluated: str or TrajectoryWriter instance (optional) + Trajectory filename to store the evaluated training data + with predicted properties. + Or the TrajectoryWriter instance to store the evaluated + training data with predicted properties. + If pred_evaluated is None, then the predicted data is + not saved. converged_trajectory: str or TrajectoryWriter instance Trajectory filename to store the converged structure(s). Or the TrajectoryWriter instance to store the converged @@ -135,9 +185,12 @@ def __init__( Trajectory filename to store the initial structure(s). Or the TrajectoryWriter instance to store the initial structure(s). - tabletxt: str + tabletxt: str (optional) Name of the .txt file where the summary table is printed. It is not saved to the file if tabletxt=None. + timetxt: str (optional) + Name of the .txt file where the time table is printed. + It is not saved to the file if timetxt=None. prev_calculations: Atoms list or ASE Trajectory file. The user can feed previously calculated data for the same hypersurface. @@ -149,6 +202,8 @@ def __init__( The random seed for the optimization. The seed an also be a RandomState or Generator instance. If not given, the default random number generator is used. + dtype: type + The data type of the arrays. comm: MPI communicator. The MPI communicator. """ @@ -174,6 +229,8 @@ def __init__( self.update_arguments( is_minimization=is_minimization, use_database_check=use_database_check, + data_perturb=data_perturb, + data_tol=data_tol, save_memory=save_memory, parallel_run=parallel_run, copy_calc=copy_calc, @@ -188,15 +245,21 @@ def __init__( check_unc=check_unc, check_energy=check_energy, check_fmax=check_fmax, + max_unc_restart=max_unc_restart, n_evaluations_each=n_evaluations_each, min_data=min_data, save_properties_traj=save_properties_traj, + to_save_mlcalc=to_save_mlcalc, + save_mlcalc_kwargs=save_mlcalc_kwargs, trajectory=trajectory, trainingset=trainingset, + pred_evaluated=pred_evaluated, converged_trajectory=converged_trajectory, initial_traj=initial_traj, tabletxt=tabletxt, + timetxt=timetxt, seed=seed, + dtype=dtype, comm=comm, **kwargs, ) @@ -299,6 +362,9 @@ def reset(self, **kwargs): self.energy_pred = nan self.pred_energies = [] self.uncertainties = [] + self.ml_train_time = nan + self.method_time = nan + self.eval_time = nan # Set the header for the summary table self.make_hdr_table() # Set the writing mode @@ -320,16 +386,16 @@ def setup_method(self, method, **kwargs): self.method = method # Set the seed for the method if hasattr(self, "seed"): - self.method.set_seed(self.seed) + self.set_method_seed(self.seed) # Get the structures - self.structures = self.get_structures() + self.structures = self.get_structures(allow_calculation=False) if isinstance(self.structures, list): self.n_structures = len(self.structures) self.natoms = len(self.structures[0]) else: self.n_structures = 1 self.natoms = len(self.structures) - self.best_structures = self.get_structures() + self.best_structures = self.get_structures(allow_calculation=False) self._converged = self.method.converged() # Set the evaluated candidate and its calculator self.candidate = self.get_candidates()[0].copy() @@ -368,7 +434,7 @@ def setup_mlcalc( self.mlcalc = mlcalc # Set the verbose for the ML calculator if verbose is not None: - self.mlcalc.mlmodel.update_arguments(verbose=verbose) + self.set_verbose(verbose=verbose) else: self.mlcalc = self.setup_default_mlcalc( verbose=verbose, @@ -377,7 +443,11 @@ def setup_mlcalc( # Check if the seed is given if hasattr(self, "seed"): # Set the seed for the ML calculator - self.mlcalc.set_seed(self.seed) + self.set_mlcalc_seed(self.seed) + # Check if the dtype is given + if hasattr(self, "dtype"): + # Set the dtype for the ML calculator + self.mlcalc.set_dtype(self.dtype) return self def setup_default_mlcalc( @@ -385,11 +455,12 @@ def setup_default_mlcalc( save_memory=False, fp=None, atoms=None, - prior=None, - baseline=None, + prior=Prior_max(add=1.0), + baseline=BornRepulsionCalculator(), use_derivatives=True, database_reduction=False, calc_forces=True, + round_pred=5, optimize_hp=True, bayesian=True, kappa=2.0, @@ -419,16 +490,19 @@ def setup_default_mlcalc( It is used to setup the fingerprint if it is None. prior: Prior class instance (optional) The prior mean instance used for the ML model. - The default Prior_max instance is used if prior is None. + The default prior is the Prior_max. baseline: Baseline class instance (optional) The baseline instance used for the ML model. - The default is None. - use_derivatives : bool + The default is the BornRepulsionCalculator. + use_derivatives: bool Whether to use derivatives of the targets in the ML model. database_reduction: bool Whether to reduce the database. calc_forces: bool Whether to calculate the forces for all energy predictions. + round_pred: int (optional) + The number of decimals to round the predictions to. + If None, the predictions are not rounded. optimize_hp: bool Whether to optimize the hyperparameters when the model is trained. @@ -450,9 +524,7 @@ def setup_default_mlcalc( """ # Create the ML calculator from ..regression.gp.calculator.mlmodel import get_default_mlmodel - from ..regression.gp.calculator.bocalc import BOCalculator from ..regression.gp.calculator.mlcalc import MLCalculator - from ..regression.gp.means.max import Prior_max from ..regression.gp.fingerprint.invdistances import InvDistances # Check if the save_memory is given @@ -466,7 +538,10 @@ def setup_default_mlcalc( # Check if the Atoms object is given if atoms is None: try: - atoms = self.get_structures(get_all=False) + atoms = self.get_structures( + get_all=False, + allow_calculation=False, + ) except NameError: raise NameError("The Atoms object is not given or stored.") # Can only use distances if there are more than one atom @@ -479,11 +554,8 @@ def setup_default_mlcalc( reduce_dimensions=True, use_derivatives=True, periodic_softmax=periodic_softmax, - wrap=False, + wrap=True, ) - # Setup the prior mean - if prior is None: - prior = Prior_max(add=1.0) # Setup the ML model mlmodel = get_default_mlmodel( model="tp", @@ -508,13 +580,14 @@ def setup_default_mlcalc( mlcalc = BOCalculator( mlmodel=mlmodel, calc_forces=calc_forces, + round_pred=round_pred, kappa=kappa, **calc_kwargs, ) if not use_derivatives and kappa > 0.0: if world.rank == 0: - print( - "Warning: The Bayesian optimization calculator " + warnings.warn( + "The Bayesian optimization calculator " "with a positive kappa value and no derivatives " "is not recommended!" ) @@ -522,6 +595,7 @@ def setup_default_mlcalc( mlcalc = MLCalculator( mlmodel=mlmodel, calc_forces=calc_forces, + round_pred=round_pred, **calc_kwargs, ) # Reuse the data from a previous mlcalc if requested @@ -542,15 +616,15 @@ def setup_acq( Setup the acquisition function. Parameters: - acq : Acquisition class instance. + acq: Acquisition class instance. The Acquisition instance used for calculating the acq. function and choose a candidate to calculate next. The default AcqUME instance is used if acq is None. - is_minimization : bool + is_minimization: bool Whether it is a minimization that is performed. - kappa : float + kappa: float The kappa parameter in the acquisition function. - unc_convergence : float + unc_convergence: float Maximum uncertainty for convergence (in eV). """ # Select an acquisition function @@ -583,25 +657,37 @@ def setup_acq( ) # Set the seed for the acquisition function if hasattr(self, "seed"): - self.acq.set_seed(self.seed) + self.set_acq_seed(self.seed) return self def get_structures( self, get_all=True, + properties=["forces", "energy", "uncertainty"], + allow_calculation=True, **kwargs, ): """ Get the list of ASE Atoms object from the method. Parameters: - get_all : bool + get_all: bool Whether to get all structures or just the first one. + properties: list of str + The names of the requested properties. + If not given, the properties is not calculated. + allow_calculation: bool + Whether the properties are allowed to be calculated. Returns: Atoms object or list of Atoms objects. """ - return self.method.get_structures(get_all=get_all, **kwargs) + return self.method.get_structures( + get_all=get_all, + properties=properties, + allow_calculation=allow_calculation, + **kwargs, + ) def get_candidates(self): """ @@ -613,6 +699,32 @@ def get_candidates(self): """ return self.method.get_candidates() + def copy_candidates( + self, + properties=["fmax", "forces", "energy", "uncertainty"], + allow_calculation=True, + **kwargs, + ): + """ + Get the candidate structure instances with copied properties. + It is used for active learning. + + Parameters: + properties: list of str + The names of the requested properties. + allow_calculation: bool + Whether the properties are allowed to be calculated. + + Returns: + candidates_copy: list of Atoms instances + The candidates with copied properties. + """ + return self.method.copy_candidates( + properties=properties, + allow_calculation=allow_calculation, + **kwargs, + ) + def use_prev_calculations(self, prev_calculations=None, **kwargs): """ Use previous calculations to restart ML calculator. @@ -628,6 +740,8 @@ def use_prev_calculations(self, prev_calculations=None, **kwargs): return self if isinstance(prev_calculations, str): prev_calculations = read(prev_calculations, ":") + if isinstance(prev_calculations, list) and len(prev_calculations) == 0: + return self # Add calculations to the ML model self.add_training(prev_calculations) return self @@ -635,6 +749,7 @@ def use_prev_calculations(self, prev_calculations=None, **kwargs): def update_method(self, structures, **kwargs): """ Update the method with structures. + Add the ML calculator to the structures in the optimization method. Parameters: structures: Atoms instance or list of Atoms instances @@ -649,6 +764,17 @@ def update_method(self, structures, **kwargs): self.set_mlcalc() return self + def reset_method(self, **kwargs): + """ + Reset the stps and convergence of the optimization method. + Add the ML calculator to the structures in the optimization method. + """ + # Reset the optimization method + self.method.reset_optimization() + # Set the ML calculator in the method + self.set_mlcalc() + return self + def set_mlcalc(self, copy_calc=None, **kwargs): """ Set the ML calculator in the method. @@ -676,7 +802,6 @@ def update_arguments( mlcalc=None, acq=None, is_minimization=None, - use_database_check=None, save_memory=None, parallel_run=None, copy_calc=None, @@ -691,15 +816,24 @@ def update_arguments( check_unc=None, check_energy=None, check_fmax=None, + max_unc_restart=None, n_evaluations_each=None, min_data=None, + use_database_check=None, + data_perturb=None, + data_tol=None, save_properties_traj=None, + to_save_mlcalc=None, + save_mlcalc_kwargs=None, trajectory=None, trainingset=None, + pred_evaluated=None, converged_trajectory=None, initial_traj=None, tabletxt=None, + timetxt=None, seed=None, + dtype=None, comm=None, **kwargs, ): @@ -722,9 +856,6 @@ def update_arguments( is_minimization: bool Whether it is a minimization that is performed. Alternative is a maximization. - use_database_check: bool - Whether to check if the new structure is within the database. - If it is in the database, the structure is rattled. save_memory: bool Whether to only train the ML calculator and store all objects on one CPU. @@ -771,13 +902,35 @@ def update_arguments( check_fmax: bool Check if the maximum force is larger for the restarted result than the initial interpolation and if so then replace it. + max_unc_restart: float (optional) + Maximum uncertainty (in eV) for using the structure(s) as + the restart in the optimization method. + If max_unc_restart is None, then the optimization is performed + without the maximum uncertainty. n_evaluations_each: int Number of evaluations for each iteration. min_data: int The minimum number of data points in the training set before the active learning can converge. + use_database_check: bool + Whether to check if the new structure is within the database. + If it is in the database, the structure is rattled. + Please be aware that the predicted structure will differ from + the structure in the database if the rattling is applied. + data_perturb: float + The perturbation of the data structure if it is in the database + and use_database_check is True. + data_perturb is the standard deviation of the normal + distribution used to rattle the structure. + data_tol: float + The tolerance for the data structure if it is in the database + and use_database_check is True. save_properties_traj: bool Whether to save the calculated properties to the trajectory. + to_save_mlcalc: bool + Whether to save the ML calculator to a file after training. + save_mlcalc_kwargs: dict + Arguments for saving the ML calculator, like the filename. trajectory: str or TrajectoryWriter instance Trajectory filename to store the predicted data. Or the TrajectoryWriter instance to store the predicted data. @@ -785,6 +938,13 @@ def update_arguments( Trajectory filename to store the evaluated training data. Or the TrajectoryWriter instance to store the evaluated training data. + pred_evaluated: str or TrajectoryWriter instance (optional) + Trajectory filename to store the evaluated training data + with predicted properties. + Or the TrajectoryWriter instance to store the evaluated + training data with predicted properties. + If pred_evaluated is None, then the predicted data is + not saved. converged_trajectory: str or TrajectoryWriter instance Trajectory filename to store the converged structure(s). Or the TrajectoryWriter instance to store the converged @@ -793,9 +953,12 @@ def update_arguments( Trajectory filename to store the initial structure(s). Or the TrajectoryWriter instance to store the initial structure(s). - tabletxt: str + tabletxt: str (optional) Name of the .txt file where the summary table is printed. It is not saved to the file if tabletxt=None. + timetxt: str (optional) + Name of the .txt file where the time table is printed. + It is not saved to the file if timetxt=None. prev_calculations: Atoms list or ASE Trajectory file. The user can feed previously calculated data for the same hypersurface. @@ -807,17 +970,15 @@ def update_arguments( The random seed for the optimization. The seed an also be a RandomState or Generator instance. If not given, the default random number generator is used. + dtype: type + The data type of the arrays. comm: MPI communicator. The MPI communicator. Returns: self: The updated object itself. """ - # Fixed parameters - if is_minimization is not None: - self.is_minimization = is_minimization - if use_database_check is not None: - self.use_database_check = use_database_check + # Set parallelization if save_memory is not None: self.save_memory = save_memory if comm is not None or not hasattr(self, "comm"): @@ -825,15 +986,35 @@ def update_arguments( self.parallel_setup(comm) if parallel_run is not None: self.parallel_run = parallel_run - if copy_calc is not None: - self.copy_calc = copy_calc + if self.parallel_run and self.save_memory: + raise ValueError( + "The save_memory and parallel_run can not " + "be True at the same time!" + ) + # Set the verbose if verbose is not None: # Whether to have the full output - self.verbose = verbose self.set_verbose(verbose=verbose) elif not hasattr(self, "verbose"): - self.verbose = False self.set_verbose(verbose=False) + # Set parameters + if is_minimization is not None: + self.is_minimization = is_minimization + if use_database_check is not None: + self.use_database_check = use_database_check + if data_perturb is not None: + self.data_perturb = abs(float(data_perturb)) + if data_tol is not None: + self.data_tol = abs(float(data_tol)) + if self.use_database_check: + if self.data_perturb < self.data_tol: + self.message_system( + "It is not recommended that the data_perturb " + "is smaller than the data_tol.", + is_warning=True, + ) + if copy_calc is not None: + self.copy_calc = copy_calc if apply_constraint is not None: self.apply_constraint = apply_constraint elif not hasattr(self, "apply_constraint"): @@ -858,6 +1039,8 @@ def update_arguments( self.check_energy = check_energy if check_fmax is not None: self.check_fmax = check_fmax + if max_unc_restart is not None: + self.max_unc_restart = abs(float(max_unc_restart)) if n_evaluations_each is not None: self.n_evaluations_each = int(abs(n_evaluations_each)) if self.n_evaluations_each < 1: @@ -866,26 +1049,30 @@ def update_arguments( self.min_data = int(abs(min_data)) if save_properties_traj is not None: self.save_properties_traj = save_properties_traj - if trajectory is not None: + if to_save_mlcalc is not None: + self.to_save_mlcalc = to_save_mlcalc + if save_mlcalc_kwargs is not None: + self.save_mlcalc_kwargs = save_mlcalc_kwargs + if trajectory is not None or not hasattr(self, "trajectory"): self.trajectory = trajectory - elif not hasattr(self, "trajectory"): - self.trajectory = None - if trainingset is not None: + if trainingset is not None or not hasattr(self, "trainingset"): self.trainingset = trainingset - elif not hasattr(self, "trainingset"): - self.trainingset = None - if converged_trajectory is not None: + if pred_evaluated is not None or not hasattr(self, "pred_evaluated"): + self.pred_evaluated = pred_evaluated + if converged_trajectory is not None or not hasattr( + self, "converged_trajectory" + ): self.converged_trajectory = converged_trajectory - elif not hasattr(self, "converged_trajectory"): - self.converged_trajectory = None - if initial_traj is not None: + if initial_traj is not None or not hasattr(self, "initial_traj"): self.initial_traj = initial_traj - elif not hasattr(self, "initial_traj"): - self.initial_traj = None if tabletxt is not None: self.tabletxt = str(tabletxt) elif not hasattr(self, "tabletxt"): self.tabletxt = None + if timetxt is not None: + self.timetxt = str(timetxt) + elif not hasattr(self, "timetxt"): + self.timetxt = None # Set ASE calculator if ase_calc is not None: self.ase_calc = ase_calc @@ -907,6 +1094,9 @@ def update_arguments( # Set the seed if seed is not None or not hasattr(self, "seed"): self.set_seed(seed) + # Set the data type + if dtype is not None or not hasattr(self, "dtype"): + self.set_dtype(dtype) # Check if the method and BO is compatible self.check_attributes() return self @@ -953,6 +1143,8 @@ def run_method( unc_convergence = self.unc_convergence else: unc_convergence = None + # Start the method time + self.method_time = time() # Run the method self.method.run( fmax=fmax, @@ -962,14 +1154,14 @@ def run_method( unc_convergence=unc_convergence, **kwargs, ) + # Store the method time + self.method_time = time() - self.method_time # Check if the method converged method_converged = self.method.converged() # Get the atoms from the method run self.structures = self.get_structures() # Write atoms to trajectory self.save_trajectory(self.trajectory, self.structures, mode=self.mode) - # Set the mode to append - self.mode = "a" return method_converged def initiate_structure(self, step=1, **kwargs): @@ -987,7 +1179,7 @@ def initiate_structure(self, step=1, **kwargs): uncmax_tmp, energy_tmp, fmax_tmp = self.get_predictions() # Check uncertainty is low enough if self.check_unc: - if uncmax_tmp > self.unc_convergence: + if uncmax_tmp > self.max_unc_restart: self.message_system( "The uncertainty is too large to " "use the last structure." @@ -1011,10 +1203,10 @@ def initiate_structure(self, step=1, **kwargs): use_tmp = False # Check if the temporary structure passed the tests if use_tmp: - self.copy_best_structures() + self.update_method(self.structures) self.message_system("The last structure is used.") - # Set the best structures as the initial structures for the method - self.update_method(self.best_structures) + else: + self.update_method(self.best_structures) # Store the best structures with the ML calculator self.copy_best_structures() # Save the initial trajectory @@ -1035,22 +1227,28 @@ def get_predictions(self, **kwargs): fmax = max_(self.method.get_fmax()) return uncmax, energy, fmax - def get_candidate_predictions(self, **kwargs): + def get_candidate_predictions(self, candidates, **kwargs): """ Get the energies, uncertainties, and fmaxs with the ML calculator for the candidates. """ - properties = ["fmax", "uncertainty", "energy"] - results = self.method.get_properties( - properties=properties, - allow_calculation=True, - per_candidate=True, - **kwargs, + energies = asarray( + [candidate.get_potential_energy() for candidate in candidates] + ) + uncertainties = asarray( + [candidate.calc.results["uncertainty"] for candidate in candidates] + ) + fmaxs = asarray( + [ + sqrt((candidate.get_forces() ** 2).sum(axis=1).max()) + for candidate in candidates + ] + ) + return ( + energies.reshape(-1), + uncertainties.reshape(-1), + fmaxs.reshape(-1), ) - energies = asarray(results["energy"]).reshape(-1) - uncertainties = asarray(results["uncertainty"]).reshape(-1) - fmaxs = asarray(results["fmax"]).reshape(-1) - return energies, uncertainties, fmaxs def parallel_setup(self, comm, **kwargs): "Setup the parallelization." @@ -1062,6 +1260,13 @@ def parallel_setup(self, comm, **kwargs): self.size = self.comm.size return self + def remove_parallel_setup(self): + "Remove the parallelization by removing the communicator." + self.comm = None + self.rank = 0 + self.size = 1 + return self + def add_training(self, atoms_list, **kwargs): "Add atoms_list data to ML model on rank=0." self.mlcalc.add_training(atoms_list) @@ -1069,6 +1274,9 @@ def add_training(self, atoms_list, **kwargs): def train_mlmodel(self, point_interest=None, **kwargs): "Train the ML model" + # Start the training time + self.ml_train_time = time() + # Check if the model should be trained on all CPUs if self.save_memory: if self.rank != 0: return self.mlcalc @@ -1079,6 +1287,11 @@ def train_mlmodel(self, point_interest=None, **kwargs): self.update_database_arguments(point_interest=self.best_structures) # Train the ML model self.mlcalc.train_model() + # Store the training time + self.ml_train_time = time() - self.ml_train_time + # Save the ML calculator if requested + if self.to_save_mlcalc: + self.save_mlcalc(**self.save_mlcalc_kwargs) return self.mlcalc def save_data(self, **kwargs): @@ -1100,21 +1313,9 @@ def save_trajectory(self, trajectory, structures, mode="w", **kwargs): return self if isinstance(trajectory, str): with TrajectoryWriter(trajectory, mode=mode) as traj: - if not isinstance(structures, list): - structures = [structures] - for struc in structures: - if self.save_properties_traj: - if hasattr(struc.calc, "results"): - struc.info["results"] = struc.calc.results - traj.write(struc) + self.save_traj(traj, structures, **kwargs) elif isinstance(trajectory, TrajectoryWriter): - if not isinstance(structures, list): - structures = [structures] - for struc in structures: - if self.save_properties_traj: - if hasattr(struc.calc, "results"): - struc.info["results"] = struc.calc.results - trajectory.write(struc) + self.save_traj(trajectory, structures, **kwargs) else: self.message_system( "The trajectory type is not supported. " @@ -1122,6 +1323,20 @@ def save_trajectory(self, trajectory, structures, mode="w", **kwargs): ) return self + def save_traj(self, traj, structures, **kwargs): + "Save the trajectory of the data with the TrajectoryWriter." + if not isinstance(structures, list): + structures = [structures] + for struc in structures: + if struc is not None: + if self.save_properties_traj: + if hasattr(struc.calc, "results"): + struc.info["results"] = struc.calc.results + else: + struc.info["results"] = {} + traj.write(struc) + return traj + def evaluate_candidates(self, candidates, **kwargs): "Evaluate the candidates." # Check if the candidates are a list @@ -1129,19 +1344,29 @@ def evaluate_candidates(self, candidates, **kwargs): candidates = [candidates] # Evaluate the candidates for candidate in candidates: + # Ensure that the candidate is not already in the database + if self.use_database_check: + candidate = self.ensure_candidate_not_in_database( + candidate, + show_message=True, + ) # Broadcast the predictions self.broadcast_predictions() # Evaluate the candidate - self.evaluate(candidate) + self.evaluate(candidate, is_predicted=True) + # Set the mode to append + self.mode = "a" return self - def evaluate(self, candidate, **kwargs): + def evaluate(self, candidate, is_predicted=False, **kwargs): "Evaluate the ASE atoms with the ASE calculator." # Ensure that the candidate is not already in the database - if self.use_database_check: - candidate = self.ensure_not_in_database(candidate) + if self.use_database_check and not is_predicted: + candidate, _ = self.ensure_not_in_database(candidate) # Update the evaluated candidate self.update_candidate(candidate) + # Start the evaluation time + self.eval_time = time() # Calculate the energies and forces self.message_system("Performing evaluation.", end="\r") forces = self.candidate.get_forces( @@ -1150,11 +1375,21 @@ def evaluate(self, candidate, **kwargs): self.energy_true = self.candidate.get_potential_energy( force_consistent=self.force_consistent ) + self.message_system("Single-point calculation finished.") + # Store the evaluation time + self.eval_time = time() - self.eval_time + # Save deviation, fmax, and update steps self.e_dev = abs(self.energy_true - self.energy_pred) + self.true_fmax = nanmax(norm(forces, axis=1)) self.steps += 1 - self.message_system("Single-point calculation finished.") # Store the data - self.true_fmax = nanmax(norm(forces, axis=1)) + if is_predicted: + # Store the candidate with predicted properties + self.save_trajectory( + self.pred_evaluated, + candidate, + mode=self.mode, + ) self.add_training([self.candidate]) self.save_data() # Make a reference energy @@ -1240,7 +1475,9 @@ def extra_initial_data(self, **kwargs): if self.get_training_set_size() >= 1: return self # Calculate the initial structure - self.evaluate(self.get_structures(get_all=False)) + self.evaluate( + self.get_structures(get_all=False, allow_calculation=False) + ) # Print summary table self.print_statement() return self @@ -1253,26 +1490,65 @@ def update_database_arguments(self, point_interest=None, **kwargs): ) return self - def ensure_not_in_database(self, atoms, perturb=0.01, **kwargs): - "Ensure the ASE Atoms object is not in database by perturb it." + def ensure_not_in_database( + self, + atoms, + show_message=True, + **kwargs, + ): + "Ensure the ASE Atoms instance is not in database by perturb it." # Return atoms if it does not exist if atoms is None: return atoms - # Check if atoms object is in the database - if self.is_in_database(atoms, **kwargs): - # Get positions - pos = atoms.get_positions() + # Get positions + pos = atoms.get_positions() + # Boolean for checking if the atoms instance was in database + was_in_database = False + # Check if atoms instance is in the database + while self.is_in_database(atoms, dtol=self.data_tol, **kwargs): + # Atoms instance was in database + was_in_database = True # Rattle the positions - pos += self.rng.uniform( - low=-perturb, - high=perturb, + pos_new = pos + self.rng.normal( + loc=0.0, + scale=self.data_perturb, size=pos.shape, ) - atoms.set_positions(pos) - self.message_system( - "The system is rattled, since it is already in the database." + atoms.set_positions(pos_new) + # Print message if requested + if show_message: + self.message_system( + "The system is rattled, since it is already in " + "the database." + ) + return atoms, was_in_database + + def ensure_candidate_not_in_database( + self, + candidate, + show_message=True, + **kwargs, + ): + "Ensure the candidate is not in database by perturb it." + # If memeory is saved the method is only performed on one CPU + if not self.parallel_run and self.rank != 0: + return None + # Ensure that the candidate is not already in the database + candidate, was_in_database = self.ensure_not_in_database( + candidate, + show_message=show_message, + ) + # Calculate the properties if it was in the database + if was_in_database: + candidate.calc = self.mlcalc + candidate = self.method.copy_atoms( + candidate, + properties=["fmax", "uncertainty", "energy"], + allow_calculation=True, ) - return atoms + self.pred_energies[0] = candidate.get_potential_energy() + self.uncertainties[0] = candidate.calc.results["uncertainty"] + return candidate def store_best_data(self, atoms, **kwargs): "Store the best candidate." @@ -1296,8 +1572,12 @@ def get_training_set_size(self): def choose_candidates(self, **kwargs): "Use acquisition functions to chose the next training points" + # Get the candidates + candidates = self.copy_candidates() # Get the energies and uncertainties - energies, uncertainties, fmaxs = self.get_candidate_predictions() + energies, uncertainties, fmaxs = self.get_candidate_predictions( + candidates + ) # Store the uncertainty predictions self.umax = max_(uncertainties) self.umean = mean_(uncertainties) @@ -1314,8 +1594,7 @@ def choose_candidates(self, **kwargs): if self.n_evaluations_each > 1: i_cand = i_cand[::-1] # The next training points - candidates = self.get_candidates() - candidates = [candidates[i].copy() for i in i_cand] + candidates = [candidates[i] for i in i_cand] self.pred_energies = energies[i_cand] self.uncertainties = uncertainties[i_cand] return candidates @@ -1348,23 +1627,45 @@ def check_convergence(self, fmax, method_converged, **kwargs): converged = False # Check the convergence if converged: - self.message_system("Optimization is converged.") self.copy_best_structures() # Broadcast convergence statement if MPI is used converged = broadcast(converged, root=0, comm=self.comm) return converged - def copy_best_structures(self): - "Copy the best atoms." - self.best_structures = self.get_structures() + def copy_best_structures( + self, + get_all=True, + properties=["forces", "energy", "uncertainty"], + allow_calculation=True, + **kwargs, + ): + """ + Copy the best structures. + + Parameters: + properties: list of str + The names of the requested properties. + If not given, the properties is not calculated. + allow_calculation: bool + Whether the properties are allowed to be calculated. + + Returns: + list of ASE Atoms objects: The best structures. + """ + self.best_structures = self.get_structures( + get_all=get_all, + properties=properties, + allow_calculation=allow_calculation, + **kwargs, + ) return self.best_structures def get_best_structures(self): - "Get the best atoms." + "Get the best structures." return self.best_structures def broadcast_best_structures(self): - "Broadcast the best atoms." + "Broadcast the best structures." self.best_structures = broadcast( self.best_structures, root=0, @@ -1376,6 +1677,26 @@ def copy_atoms(self, atoms): "Copy the ASE Atoms instance with calculator." return copy_atoms(atoms) + def compare_atoms( + self, + atoms0, + atoms1, + tol=1e-8, + properties_to_check=["atoms", "positions", "cell", "pbc"], + **kwargs, + ): + """ + Compare two ASE Atoms instances. + """ + is_same = compare_atoms( + atoms0, + atoms1, + tol=tol, + properties_to_check=properties_to_check, + **kwargs, + ) + return is_same + def get_objective_str(self, **kwargs): "Get what the objective is for the active learning." if not self.is_minimization: @@ -1384,6 +1705,7 @@ def get_objective_str(self, **kwargs): def set_verbose(self, verbose, **kwargs): "Set verbose of MLModel." + self.verbose = verbose self.mlcalc.mlmodel.update_arguments(verbose=verbose) return self @@ -1396,7 +1718,7 @@ def save_mlcalc(self, filename="mlcalc.pkl", **kwargs): Save the ML calculator object to a file. Parameters: - filename : str + filename: str The name of the file where the object is saved. Returns: @@ -1410,7 +1732,7 @@ def get_mlcalc(self, copy_mlcalc=True, **kwargs): Get the ML calculator instance. Parameters: - copy_mlcalc : bool + copy_mlcalc: bool Whether to copy the instance. Returns: @@ -1432,8 +1754,19 @@ def check_attributes(self, **kwargs): ) return self - def set_seed(self, seed=None): - "Set the random seed." + def set_seed(self, seed=None, **kwargs): + """ + Set the random seed. + + Parameters: + seed: int (optional) + The random seed. + The seed can be an integer, RandomState, or Generator instance. + If not given, the default random number generator is used. + + Returns: + self: The instance itself. + """ if seed is not None: self.seed = seed if isinstance(seed, int): @@ -1444,17 +1777,106 @@ def set_seed(self, seed=None): self.seed = None self.rng = default_rng() # Set the random seed for the optimization method - self.method.set_seed(self.seed) + self.set_method_seed(self.seed) # Set the random seed for the acquisition function - self.acq.set_seed(self.seed) + self.set_acq_seed(self.seed) # Set the random seed for the ML calculator - self.mlcalc.set_seed(self.seed) + self.set_mlcalc_seed(self.seed) + return self + + def set_method_seed(self, seed=None, **kwargs): + """ + Set the random seed for the optimization method. + + Parameters: + seed: int (optional) + The random seed. + The seed can be an integer, RandomState, or Generator instance. + If not given, the default random number generator is used. + + Returns: + self: The instance itself. + """ + self.method.set_seed(seed) + return self + + def set_acq_seed(self, seed=None, **kwargs): + """ + Set the random seed for the acquisition function. + + Parameters: + seed: int (optional) + The random seed. + The seed can be an integer, RandomState, or Generator instance. + If not given, the default random number generator is used. + + Returns: + self: The instance itself. + """ + self.acq.set_seed(seed) return self - def message_system(self, message, obj=None, end="\n"): + def set_mlcalc_seed(self, seed=None, **kwargs): + """ + Set the random seed for the ML calculator. + + Parameters: + seed: int (optional) + The random seed. + The seed can be an integer, RandomState, or Generator instance. + If not given, the default random number generator is used. + + Returns: + self: The instance itself. + """ + self.mlcalc.set_seed(seed) + return self + + def set_dtype(self, dtype, **kwargs): + """ + Set the data type of the arrays. + + Parameters: + dtype: type + The data type of the arrays. + + Returns: + self: The updated object itself. + """ + # Set the data type + self.dtype = dtype + # Set the data type of the mlcalc + self.mlcalc.set_dtype(dtype) + return self + + def set_kappa(self, kappa, **kwargs): + """ + Set the kappa value for the acquisition function. + Kappa is used to scale the uncertainty in the acquisition function. + Furthermore, set the kappa value for the ML calculator + if it is a BOCalculator. + + Parameters: + kappa: float + The kappa value to set for the acquisition function and + the ML calculator. + + Returns: + self: The instance itself. + """ + # Set the kappa value for the acquisition function + self.acq.set_kappa() + # Set the kappa value for the ML calculator if it is a BOCalculator + if isinstance(self.mlcalc, BOCalculator): + self.mlcalc.set_kappa(kappa) + return self + + def message_system(self, message, obj=None, end="\n", is_warning=False): "Print output once." - if self.verbose is True: - if self.rank == 0: + if self.verbose is True and self.rank == 0: + if is_warning: + warnings.warn(message) + else: if obj is None: print(message, end=end) else: @@ -1462,7 +1884,8 @@ def message_system(self, message, obj=None, end="\n"): return def make_hdr_table(self, **kwargs): - "Make the header of the summary table for the optimization process." + "Make the header of the summary tables for the optimization process." + # Make the header to the summary table hdr_list = [ " {:<6} ".format("Step"), " {:<11s} ".format("Date"), @@ -1474,12 +1897,25 @@ def make_hdr_table(self, **kwargs): # Write the header hdr = "|" + "|".join(hdr_list) + "|" self.print_list = [hdr] + # Make the header to the time summary table + hdr_list = [ + " {:<6} ".format("Step"), + " {:<11s} ".format("Date"), + " {:<16s} ".format("ML training/[s]"), + " {:<16s} ".format("ML run/[s]"), + " {:<16s} ".format("Evaluation/[s]"), + ] + # Write the header to the time summary table + hdr_time = "|" + "|".join(hdr_list) + "|" + self.print_list_time = [hdr_time] return hdr def make_summary_table(self, **kwargs): "Make the summary of the optimization process as table." + if self.rank != 0: + return None, None now = datetime.datetime.now().strftime("%d %H:%M:%S") - # Make the row + # Make the row for the summary table msg = [ " {:<6d} ".format(self.steps), " {:<11s} ".format(now), @@ -1491,7 +1927,18 @@ def make_summary_table(self, **kwargs): msg = "|" + "|".join(msg) + "|" self.print_list.append(msg) msg = "\n".join(self.print_list) - return msg + # Make the row for the time summary table + msg_time = [ + " {:<6d} ".format(self.steps), + " {:<11s} ".format(now), + " {:16.4f} ".format(self.ml_train_time), + " {:16.4f} ".format(self.method_time), + " {:16.4f} ".format(self.eval_time), + ] + msg_time = "|" + "|".join(msg_time) + "|" + self.print_list_time.append(msg_time) + msg_time = "\n".join(self.print_list_time) + return msg, msg_time def save_summary_table(self, msg=None, **kwargs): "Save the summary table in the .txt file." @@ -1500,12 +1947,16 @@ def save_summary_table(self, msg=None, **kwargs): if msg is None: msg = "\n".join(self.print_list) thefile.writelines(msg) + if self.timetxt is not None: + with open(self.timetxt, "w") as thefile: + msg = "\n".join(self.print_list_time) + thefile.writelines(msg) return def print_statement(self, **kwargs): - "Print the Global optimization process as a table" + "Print the active learning process as a table." msg = "" - if not self.save_memory or self.rank == 0: + if self.rank == 0: msg = "\n".join(self.print_list) self.save_summary_table(msg) self.message_system(msg) @@ -1522,41 +1973,46 @@ def restart_optimization( if not restart: return prev_calculations # Load the previous calculations from trajectory - try: - # Test if the restart is possible - structure = read(self.trajectory, "0") - assert len(structure) == self.natoms - # Load the predicted structures - if self.n_structures == 1: - index = "-1" - else: - index = f"-{self.n_structures}:" - self.structures = read( - self.trajectory, - index, - ) - # Load the previous training data - prev_calculations = read(self.trainingset, ":") - # Update the method with the structures - self.update_method(self.structures) - # Set the writing mode - self.mode = "a" - # Load the summary table - if self.tabletxt is not None: - with open(self.tabletxt, "r") as thefile: - self.print_list = [ - line.replace("\n", "") for line in thefile - ] - # Update the total steps - self.steps = len(self.print_list) - 1 - # Make a reference energy - atoms_ref = copy_atoms(prev_calculations[0]) - self.e_ref = atoms_ref.get_potential_energy() - except (AssertionError, FileNotFoundError, IndexError, StopIteration): - self.message_system( - "Warning: Restart is not possible! " - "Reinitalizing active learning." + # Test if the restart is possible + structure = read(self.trajectory, "0") + if len(structure) != self.natoms: + raise ValueError( + "The number of atoms in the trajectory does not match " + "the number of atoms in given." ) + # Load the predicted structures + if self.n_structures == 1: + index = "-1" + else: + index = f"-{self.n_structures}:" + self.structures = read( + self.trajectory, + index, + ) + # Load the previous training data + prev_calculations = read(self.trainingset, ":") + # Update the method with the structures + self.update_method(self.structures) + # Set the writing mode + self.mode = "a" + # Load the summary table + if self.tabletxt is not None: + with open(self.tabletxt, "r") as thefile: + self.print_list = [line.replace("\n", "") for line in thefile] + # Update the total steps + self.steps = len(self.print_list) - 1 + # Make a reference energy + atoms_ref = self.copy_atoms(prev_calculations[0]) + self.e_ref = atoms_ref.get_potential_energy() + # Load the time summary table + if self.timetxt is not None: + with open(self.timetxt, "r") as thefile: + self.print_list_time = [ + line.replace("\n", "") for line in thefile + ] + # Update the total steps + if self.tabletxt is None: + self.steps = len(self.print_list_time) - 1 return prev_calculations def get_arguments(self): @@ -1568,7 +2024,6 @@ def get_arguments(self): mlcalc=self.mlcalc, acq=self.acq, is_minimization=self.is_minimization, - use_database_check=self.use_database_check, save_memory=self.save_memory, parallel_run=self.parallel_run, copy_calc=self.copy_calc, @@ -1583,15 +2038,24 @@ def get_arguments(self): check_unc=self.check_unc, check_energy=self.check_energy, check_fmax=self.check_fmax, + max_unc_restart=self.max_unc_restart, n_evaluations_each=self.n_evaluations_each, min_data=self.min_data, + use_database_check=self.use_database_check, + data_perturb=self.data_perturb, + data_tol=self.data_tol, save_properties_traj=self.save_properties_traj, + to_save_mlcalc=self.to_save_mlcalc, + save_mlcalc_kwargs=self.save_mlcalc_kwargs, trajectory=self.trajectory, trainingset=self.trainingset, + pred_evaluated=self.pred_evaluated, converged_trajectory=self.converged_trajectory, initial_traj=self.initial_traj, tabletxt=self.tabletxt, + timetxt=self.timetxt, seed=self.seed, + dtype=self.dtype, comm=self.comm, ) # Get the constants made within the class From a5363968e6be2ae3f780651c186344659aa455f1 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Wed, 23 Jul 2025 14:04:48 +0200 Subject: [PATCH 140/194] Option to start with or without CI. --- catlearn/optimizer/localcineb.py | 148 +++++++++++++++++++++---------- 1 file changed, 103 insertions(+), 45 deletions(-) diff --git a/catlearn/optimizer/localcineb.py b/catlearn/optimizer/localcineb.py index bfc167c8..6607e4b2 100644 --- a/catlearn/optimizer/localcineb.py +++ b/catlearn/optimizer/localcineb.py @@ -3,10 +3,23 @@ from ase.optimize import FIRE from .localneb import LocalNEB from .sequential import SequentialOptimizer -from ..structures.neb import EWNEB, ImprovedTangentNEB, make_interpolation +from ..structures.neb import ( + EWNEB, + ImprovedTangentNEB, + OriginalNEB, + make_interpolation, +) class LocalCINEB(SequentialOptimizer): + """ + The LocalCINEB is used to run a local optimization of NEB. + First, the NEB is run without climbing image. + Then, the climbing image is started from the converged + non-climbing images if clim=True. + The LocalCINEB is applicable to be used with active learning. + """ + def __init__( self, start, @@ -17,6 +30,7 @@ def __init__( climb=True, neb_interpolation="linear", neb_interpolation_kwargs={}, + start_without_ci=True, reuse_ci_path=False, local_opt=FIRE, local_opt_kwargs={}, @@ -27,46 +41,53 @@ def __init__( **kwargs, ): """ - The LocalNEB is used to run a local optimization of NEB. - The LocalNEB is applicable to be used with active learning. + Initialize the OptimizerMethod instance. Parameters: - start : Atoms instance or ASE Trajectory file. + start: Atoms instance or ASE Trajectory file. The Atoms must have the calculator attached with energy. Initial end-point of the NEB path. - end : Atoms instance or ASE Trajectory file. + end: Atoms instance or ASE Trajectory file. The Atoms must have the calculator attached with energy. Final end-point of the NEB path. - neb_method : NEB class object or str + neb_method: NEB class object or str The NEB implemented class object used for the ML-NEB. A string can be used to select: - 'improvedtangentneb' (default) - 'ewneb' - neb_kwargs : dict + neb_kwargs: dict A dictionary with the arguments used in the NEB object to create the instance. - Climb must not be included. - n_images : int + Climb and images must not be included. + n_images: int Number of images of the path (if not included a path before). The number of images include the 2 end-points of the NEB path. - climb : bool + climb: bool Whether to use the climbing image in the NEB. It is strongly recommended to have climb=True. - neb_interpolation : str or list of ASE Atoms or ASE Trajectory file + neb_interpolation: str or list of ASE Atoms or ASE Trajectory file The interpolation method used to create the NEB path. The string can be: - 'linear' (default) - 'idpp' - 'rep' + - 'born' - 'ends' Otherwise, the premade images can be given as a list of ASE Atoms. A string of the ASE Trajectory file that contains the images can also be given. - neb_interpolation_kwargs : dict + neb_interpolation_kwargs: dict The keyword arguments for the interpolation method. It is only used when the interpolation method is a string. - reuse_ci_path : bool + start_without_ci: bool + Whether to start the NEB without the climbing image. + If True, the NEB path will be optimized without + the climbing image and afterwards climbing image is used + if climb=True as well. + If False, the NEB path will be optimized with the climbing + image if climb=True as well. + reuse_ci_path: bool Whether to remove the non-climbing image method when the NEB without climbing image is converged. local_opt: ASE optimizer object @@ -85,6 +106,8 @@ def __init__( The seed an also be a RandomState or Generator instance. If not given, the default random number generator is used. """ + # Set the verbose + self.verbose = verbose # Save the end points for creating the NEB self.setup_endpoints(start, end) # Build the optimizer methods and NEB within @@ -95,12 +118,14 @@ def __init__( n_images=n_images, neb_interpolation=neb_interpolation, neb_interpolation_kwargs=neb_interpolation_kwargs, + start_without_ci=start_without_ci, reuse_ci_path=reuse_ci_path, local_opt=local_opt, local_opt_kwargs=local_opt_kwargs, parallel_run=parallel_run, comm=comm, verbose=verbose, + seed=seed, **kwargs, ) # Set the parameters @@ -125,10 +150,20 @@ def setup_endpoints(self, start, end, **kwargs): end = read(end) # Save the start point with calculators start.get_forces() - self.start = self.copy_atoms(start) + self.start = self.copy_atoms( + start, + properties=["forces", "energy"], + allow_calculation=True, + **kwargs, + ) # Save the end point with calculators end.get_forces() - self.end = self.copy_atoms(end) + self.end = self.copy_atoms( + end, + properties=["forces", "energy"], + allow_calculation=True, + **kwargs, + ) return self def setup_neb( @@ -144,6 +179,7 @@ def setup_neb( neb_interpolation_kwargs={}, parallel=False, comm=None, + seed=None, **kwargs, ): """ @@ -166,11 +202,19 @@ def setup_neb( self.neb_kwargs = dict( k=k, remove_rotation_and_translation=remove_rotation_and_translation, - mic=mic, - save_properties=False, parallel=parallel, - world=comm, ) + if isinstance(neb_method, str) or issubclass(neb_method, OriginalNEB): + self.neb_kwargs.update( + dict( + use_image_permutation=True, + save_properties=True, + mic=mic, + comm=comm, + ) + ) + else: + self.neb_kwargs.update(dict(world=comm)) # Save the dictionary for creating the NEB self.neb_kwargs.update(neb_kwargs) # Save the number of images @@ -181,6 +225,7 @@ def setup_neb( self.neb_interpolation_kwargs = dict( mic=mic, remove_rotation_and_translation=remove_rotation_and_translation, + seed=seed, ) # Save the dictionary for creating the NEB interpolation self.neb_interpolation_kwargs.update(neb_interpolation_kwargs) @@ -190,6 +235,8 @@ def setup_neb( end=self.end, n_images=self.n_images, method=self.neb_interpolation, + neb_method=neb_method, + neb_kwargs=self.neb_kwargs, **self.neb_interpolation_kwargs, ) # Create the NEB @@ -207,11 +254,13 @@ def build_method( mic=True, neb_interpolation="linear", neb_interpolation_kwargs={}, + start_without_ci=True, local_opt=FIRE, local_opt_kwargs={}, parallel_run=False, comm=world, verbose=False, + seed=None, **kwargs, ): "Build the optimization method." @@ -220,11 +269,19 @@ def build_method( self.local_opt_kwargs = local_opt_kwargs # Save the instances for creating the NEB self.climb = climb - # Setup NEB without climbing image - neb_noclimb = self.setup_neb( + self.start_without_ci = start_without_ci + # Check if climb and start_without_ci are compatible + if not start_without_ci and not climb: + self.message( + "If start_without_ci is False, climb must be True!" + "start_without_ci is set to True.", + is_warning=True, + ) + self.start_without_ci = True + # Set the kwargs for setting up the NEB + setup_neb_kwargs = dict( neb_method=neb_method, neb_kwargs=neb_kwargs, - climb=False, n_images=n_images, k=k, remove_rotation_and_translation=remove_rotation_and_translation, @@ -233,35 +290,35 @@ def build_method( neb_interpolation_kwargs=neb_interpolation_kwargs, parallel=parallel_run, comm=comm, + seed=seed, **kwargs, ) - # Build the optimizer method without climbing image - method_noclimb = LocalNEB( - neb_noclimb, - local_opt=local_opt, - local_opt_kwargs=local_opt_kwargs, - parallel_run=parallel_run, - comm=comm, - verbose=verbose, - ) - # Return the method without climbing image - methods = [method_noclimb] - if not climb: - return methods + # Check if the non-climbing image method should be used + if self.start_without_ci: + # Setup NEB without climbing image + neb_noclimb = self.setup_neb( + climb=False, + **setup_neb_kwargs, + ) + # Build the optimizer method without climbing image + method_noclimb = LocalNEB( + neb_noclimb, + local_opt=local_opt, + local_opt_kwargs=local_opt_kwargs, + parallel_run=parallel_run, + comm=comm, + verbose=verbose, + ) + # Return the method without climbing image + methods = [method_noclimb] + if not climb: + return methods + else: + methods = [] # Setup NEB with climbing image neb_climb = self.setup_neb( - neb_method=neb_method, - neb_kwargs=neb_kwargs, climb=True, - n_images=n_images, - k=k, - remove_rotation_and_translation=remove_rotation_and_translation, - mic=mic, - neb_interpolation=neb_interpolation, - neb_interpolation_kwargs=neb_interpolation_kwargs, - parallel=parallel_run, - comm=comm, - **kwargs, + **setup_neb_kwargs, ) # Build the optimizer method with climbing image method_climb = LocalNEB( @@ -294,6 +351,7 @@ def get_arguments(self): climb=self.climb, neb_interpolation=self.neb_interpolation, neb_interpolation_kwargs=self.neb_interpolation_kwargs, + start_without_ci=self.start_without_ci, reuse_ci_path=self.remove_methods, local_opt=self.local_opt, local_opt_kwargs=self.local_opt_kwargs, From de8a33ede9c815ad659a1b27ed964910507aa4d2 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Wed, 23 Jul 2025 15:32:14 +0200 Subject: [PATCH 141/194] Log the true predicted energy --- catlearn/activelearning/activelearning.py | 39 +++--- catlearn/tools/plot.py | 143 +++++++++++----------- 2 files changed, 95 insertions(+), 87 deletions(-) diff --git a/catlearn/activelearning/activelearning.py b/catlearn/activelearning/activelearning.py index 254f03c9..d692a9e5 100644 --- a/catlearn/activelearning/activelearning.py +++ b/catlearn/activelearning/activelearning.py @@ -1232,22 +1232,17 @@ def get_candidate_predictions(self, candidates, **kwargs): Get the energies, uncertainties, and fmaxs with the ML calculator for the candidates. """ - energies = asarray( - [candidate.get_potential_energy() for candidate in candidates] - ) - uncertainties = asarray( - [candidate.calc.results["uncertainty"] for candidate in candidates] - ) - fmaxs = asarray( - [ - sqrt((candidate.get_forces() ** 2).sum(axis=1).max()) - for candidate in candidates - ] - ) + energies = [] + uncertainties = [] + fmaxs = [] + for candidate in candidates: + energies.append(self.get_true_predicted_energy(candidate)) + uncertainties.append(candidate.calc.results["uncertainty"]) + fmaxs.append(sqrt((candidate.get_forces() ** 2).sum(axis=1).max())) return ( - energies.reshape(-1), - uncertainties.reshape(-1), - fmaxs.reshape(-1), + asarray(energies).reshape(-1), + asarray(uncertainties).reshape(-1), + asarray(fmaxs).reshape(-1), ) def parallel_setup(self, comm, **kwargs): @@ -1546,7 +1541,7 @@ def ensure_candidate_not_in_database( properties=["fmax", "uncertainty", "energy"], allow_calculation=True, ) - self.pred_energies[0] = candidate.get_potential_energy() + self.pred_energies[0] = self.get_true_predicted_energy(candidate) self.uncertainties[0] = candidate.calc.results["uncertainty"] return candidate @@ -1713,6 +1708,18 @@ def is_in_database(self, atoms, **kwargs): "Check if the ASE Atoms is in the database." return self.mlcalc.is_in_database(atoms, **kwargs) + def get_true_predicted_energy(self, atoms, **kwargs): + """ + Get the true predicted energy of the atoms. + Since the BOCalculator will return the predicted energy and + the uncertainty times the kappa value, this should be avoided. + """ + energy = atoms.get_potential_energy() + if hasattr(atoms.calc, "results"): + if "predicted energy" in atoms.calc.results: + energy = atoms.calc.results["predicted energy"] + return energy + def save_mlcalc(self, filename="mlcalc.pkl", **kwargs): """ Save the ML calculator object to a file. diff --git a/catlearn/tools/plot.py b/catlearn/tools/plot.py index 482dccc2..e87eb61c 100644 --- a/catlearn/tools/plot.py +++ b/catlearn/tools/plot.py @@ -41,45 +41,26 @@ def plot_minimize( # Get the energies of the predicted atoms if isinstance(pred_atoms, str): pred_atoms = read(pred_atoms, ":") - pred_energies = [atoms.get_potential_energy() for atoms in pred_atoms] - if ( - "results" in pred_atoms[0].info - and "predicted energy" in pred_atoms[0].info["results"] - ): - for i, atoms in enumerate(pred_atoms): - pred_energies[i] = atoms.info["results"]["predicted energy"] - elif hasattr(pred_atoms[0].calc, "results"): - if "predicted energy" in pred_atoms[0].calc.results: - for i, atoms in enumerate(pred_atoms): - atoms.get_potential_energy() - pred_atoms[i] = atoms.calc.results["predicted energy"] + pred_energies = [get_true_predicted_energy(atoms) for atoms in pred_atoms] + # Get the uncertainties of the atoms if requested + uncertainties = None + if use_uncertainty: + uncertainties = np.array( + [get_uncertainty(atoms) for atoms in pred_atoms] + ) # Get the energies of the evaluated atoms if isinstance(eval_atoms, str): eval_atoms = read(eval_atoms, ":") eval_energies = [atoms.get_potential_energy() for atoms in eval_atoms] # Get the reference energy e_ref = eval_energies[0] - # Get the uncertainties of the atoms if requested - uncertainties = None - if use_uncertainty: - if ( - "results" in pred_atoms[0].info - and "uncertainty" in pred_atoms[0].info["results"] - ): - uncertainties = [ - atoms.info["results"]["uncertainty"] for atoms in pred_atoms - ] - else: - uncertainties = [ - atoms.calc.get_uncertainty(atoms) for atoms in pred_atoms - ] - uncertainties = np.array(uncertainties) # Make the energies relative to the first energy pred_energies = np.array(pred_energies) - e_ref eval_energies = np.array(eval_energies) - e_ref # Make x values x_values = np.arange(1, len(eval_energies) + 1) - x_pred = x_values[-len(pred_energies) :] + x_trunc = -len(pred_energies) + x_pred = x_values[x_trunc:] # Plot the energies of the atoms ax.plot(x_pred, pred_energies, "o-", color="red", label="Predicted") ax.plot(x_values, eval_energies, "o-", color="black", label="Evaluated") @@ -142,34 +123,12 @@ def get_neb_data( # Initialize the NEB method neb = neb_method(images, climb=climb, **used_neb_kwargs) # Get the energies of the images - energies = [image.get_potential_energy() for image in images] - if ( - "results" in images[1].info - and "predicted energy" in images[1].info["results"] - ): - for i, image in enumerate(images[1:-1]): - energies[i + 1] = image.info["results"]["predicted energy"] - elif hasattr(images[1].calc, "results"): - if "predicted energy" in images[1].calc.results: - for i, image in enumerate(images[1:-1]): - image.get_potential_energy() - energies[i + 1] = image.calc.results["predicted energy"] + energies = [get_true_predicted_energy(image) for image in images] energies = np.array(energies) - energies[0] # Get the uncertainties of the images if requested uncertainties = None if use_uncertainty: - if ( - "results" in images[1].info - and "uncertainty" in images[1].info["results"] - ): - uncertainties = [ - image.info["results"]["uncertainty"] for image in images[1:-1] - ] - - else: - uncertainties = [ - image.calc.get_uncertainty(image) for image in images[1:-1] - ] + uncertainties = [get_uncertainty(image) for image in images[1:-1]] uncertainties = np.concatenate([[0.0], uncertainties, [0.0]]) # Get the distances between the images pos_p, pos_m = neb.get_position_diff() @@ -179,21 +138,12 @@ def get_neb_data( # Use projection of the derivatives on the tangent if use_projection: # Get the forces - forces = [image.get_forces() for image in images] + forces = [images[0].get_forces()] + forces = forces + [ + get_true_predicted_forces(image) for image in images[1:-1] + ] + forces = forces + [images[-1].get_forces()] forces = np.array(forces) - if ( - "results" in images[1].info - and "predicted forces" in images[1].info["results"] - ): - for i, image in enumerate(images[1:-1]): - forces[i + 1] = image.info["results"][ - "predicted forces" - ].copy() - elif hasattr(images[1].calc, "results"): - if "predicted forces" in images[1].calc.results: - for i, image in enumerate(images[1:-1]): - image.get_forces() - forces[i + 1] = image.calc.results["predicted forces"] # Get the tangent tangent = neb.get_tangent(pos_p, pos_m) tangent = np.concatenate([[pos_m[0]], tangent, [pos_p[0]]], axis=0) @@ -356,6 +306,7 @@ def plot_neb_fit_mlcalc( mlcalc = mlcalc.update_mlmodel_arguments(include_noise=include_noise) # Get the first image image = images[0].copy() + image.info["results"] = {} image.calc = mlcalc pos0 = image.get_positions() # Get the distances between the images @@ -379,10 +330,7 @@ def plot_neb_fit_mlcalc( pred_distance.append(cum_distance + scaling * dist) image.set_positions(pred_pos) # Get the curve energy - energy = image.get_potential_energy() - if hasattr(image.calc, "results"): - if "predicted energy" in image.calc.results: - energy = image.calc.results["predicted energy"] + energy = get_true_predicted_energy(image) pred_energies.append(energy) # Get the curve uncertainty if use_uncertainty: @@ -469,7 +417,9 @@ def plot_all_neb( # Plot all NEB bands for i in range(n_neb): # Get the images of the NEB band - images = neb_traj[i * n_images : (i + 1) * n_images] + ni = i * n_images + ni1 = (i + 1) * n_images + images = neb_traj[ni:ni1] # Get data from NEB band _, distances, energies, _, _ = get_neb_data( images, @@ -500,3 +450,54 @@ def plot_all_neb( ax.set_xlabel("Distance / [Å]") ax.set_ylabel("Potential energy / [eV]") return ax + + +def get_true_predicted_energy(atoms, **kwargs): + """ + Get the true predicted energy of the atoms. + Since the BOCalculator will return the predicted energy and + the uncertainty times the kappa value, this should be avoided. + """ + energy = atoms.get_potential_energy() + if ( + hasattr(atoms.calc, "results") + and "predicted energy" in atoms.calc.results + ): + energy = atoms.calc.results["predicted energy"] + elif ( + "results" in atoms.info and "predicted energy" in atoms.info["results"] + ): + energy = atoms.info["results"]["predicted energy"] + return energy + + +def get_uncertainty(atoms, **kwargs): + """ + Get the uncertainty of the atoms. + """ + if hasattr(atoms.calc, "results") and "uncertainty" in atoms.calc.results: + uncertainty = atoms.calc.results["uncertainty"] + elif "results" in atoms.info and "uncertainty" in atoms.info["results"]: + uncertainty = atoms.info["results"]["uncertainty"] + else: + uncertainty = atoms.calc.get_uncertainty(atoms) + return uncertainty + + +def get_true_predicted_forces(atoms, **kwargs): + """ + Get the true predicted forces of the atoms. + Since the BOCalculator will return the predicted forces and + the uncertainty times the kappa value, this should be avoided. + """ + forces = atoms.get_forces() + if ( + hasattr(atoms.calc, "results") + and "predicted forces" in atoms.calc.results + ): + forces = atoms.calc.results["predicted forces"] + elif ( + "results" in atoms.info and "predicted forces" in atoms.info["results"] + ): + forces = atoms.info["results"]["predicted forces"] + return forces From c7bc2e575a7ebe2fe04c6a307d9b794de8c5df33 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Wed, 23 Jul 2025 15:35:42 +0200 Subject: [PATCH 142/194] Bug fix to local optimizer --- catlearn/optimizer/local.py | 98 +++++++++++++++++++++++++++---------- 1 file changed, 73 insertions(+), 25 deletions(-) diff --git a/catlearn/optimizer/local.py b/catlearn/optimizer/local.py index 5e6534e9..cd10aab8 100644 --- a/catlearn/optimizer/local.py +++ b/catlearn/optimizer/local.py @@ -6,9 +6,15 @@ class LocalOptimizer(OptimizerMethod): + """ + The LocalOptimizer is used to run a local optimization on + a given structure. + The LocalOptimizer is applicable to be used with active learning. + """ + def __init__( self, - atoms, + optimizable, local_opt=FIRE, local_opt_kwargs={}, parallel_run=False, @@ -18,12 +24,10 @@ def __init__( **kwargs, ): """ - The LocalOptimizer is used to run a local optimization on - a given structure. - The LocalOptimizer is applicable to be used with active learning. + Initialize the OptimizerMethod instance. Parameters: - atoms: Atoms instance + optimizable: Atoms instance The instance to be optimized. local_opt: ASE optimizer object The local optimizer object. @@ -43,7 +47,7 @@ def __init__( """ # Set the parameters self.update_arguments( - atoms=atoms, + optimizable=optimizable, local_opt=local_opt, local_opt_kwargs=local_opt_kwargs, parallel_run=parallel_run, @@ -66,12 +70,63 @@ def run( if steps <= 0: return self._converged # Run the local optimization - with self.local_opt( - self.optimizable, **self.local_opt_kwargs - ) as optimizer: + converged, _ = self.local_optimize( + atoms=self.optimizable, + fmax=fmax, + steps=steps, + max_unc=max_unc, + dtrust=dtrust, + **kwargs, + ) + # Check if the optimization is converged + self._converged = self.check_convergence( + converged=converged, + max_unc=max_unc, + dtrust=dtrust, + unc_convergence=unc_convergence, + ) + # Return whether the optimization is converged + return self._converged + + def local_optimize( + self, + atoms, + fmax=0.05, + steps=1000, + max_unc=None, + dtrust=None, + **kwargs, + ): + """ + Run the local optimization on the given atoms. + + Parameters: + atoms: Atoms instance + The atoms to be optimized. + fmax: float + The maximum force allowed on an atom. + steps: int + The maximum number of steps allowed. + max_unc: float + The maximum uncertainty allowed on a structure. + dtrust: float + The distance trust criterion. + + Returns: + converged: bool + Whether the optimization is converged. + used_steps: int + The number of steps used in the optimization. + """ + # Set the initialization parameters + converged = False + used_steps = 0 + # Run the local optimization + with self.local_opt(atoms, **self.local_opt_kwargs) as optimizer: if max_unc is None and dtrust is None: optimizer.run(fmax=fmax, steps=steps) converged = optimizer.converged() + self.steps += optimizer.get_number_of_steps() else: converged = self.run_max_unc( optimizer=optimizer, @@ -81,15 +136,9 @@ def run( dtrust=dtrust, **kwargs, ) - # Check if the optimization is converged - self._converged = self.check_convergence( - converged=converged, - max_unc=max_unc, - dtrust=dtrust, - unc_convergence=unc_convergence, - ) - # Return whether the optimization is converged - return self._converged + # Get the number of steps used in the optimization + used_steps = optimizer.get_number_of_steps() + return converged, used_steps def run_max_unc( self, @@ -153,7 +202,7 @@ def run_max_unc( break return converged - def setup_local_optimizer(self, local_opt=None, local_opt_kwargs={}): + def setup_local_optimizer(self, local_opt=FIRE, local_opt_kwargs={}): """ Setup the local optimizer. @@ -166,8 +215,7 @@ def setup_local_optimizer(self, local_opt=None, local_opt_kwargs={}): self.local_opt_kwargs = dict() if not self.verbose: self.local_opt_kwargs["logfile"] = None - if local_opt is None: - local_opt = FIRE + if issubclass(local_opt, FIRE): self.local_opt_kwargs.update( dict(dt=0.05, maxstep=0.2, a=1.0, astart=1.0, fa=0.999) ) @@ -183,7 +231,7 @@ def is_parallel_allowed(self): def update_arguments( self, - atoms=None, + optimizable=None, local_opt=None, local_opt_kwargs={}, parallel_run=None, @@ -197,7 +245,7 @@ def update_arguments( The existing arguments are used if they are not given. Parameters: - atoms: Atoms instance + optimizable: Atoms instance The instance to be optimized. local_opt: ASE optimizer object The local optimizer object. @@ -217,7 +265,7 @@ def update_arguments( """ # Set the parameters in the parent class super().update_arguments( - optimizable=atoms, + optimizable=optimizable, parallel_run=parallel_run, comm=comm, verbose=verbose, @@ -248,7 +296,7 @@ def get_arguments(self): "Get the arguments of the class itself." # Get the arguments given to the class in the initialization arg_kwargs = dict( - atoms=self.optimizable, + optimizable=self.optimizable, local_opt=self.local_opt, local_opt_kwargs=self.local_opt_kwargs, parallel_run=self.parallel_run, From fb19c283d88a0df40c52a2f2442c280660a44b0c Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Wed, 23 Jul 2025 15:42:24 +0200 Subject: [PATCH 143/194] Minor changes to adsorption --- catlearn/optimizer/adsorption.py | 79 +++++++++++++++++++++++--------- 1 file changed, 57 insertions(+), 22 deletions(-) diff --git a/catlearn/optimizer/adsorption.py b/catlearn/optimizer/adsorption.py index 5a5ca5f6..1147ba6c 100644 --- a/catlearn/optimizer/adsorption.py +++ b/catlearn/optimizer/adsorption.py @@ -9,6 +9,17 @@ class AdsorptionOptimizer(OptimizerMethod): + """ + The AdsorptionOptimizer is used to run a global optimization of + an adsorption on a surface. + A single structure will be created and optimized. + Simulated annealing will be used to global optimize the structure. + The adsorbate is optimized on a surface, where the bond-lengths of the + adsorbate atoms are fixed and the slab atoms are fixed. + The AdsorptionOptimizer is applicable to be used with + active learning. + """ + def __init__( self, slab, @@ -24,12 +35,7 @@ def __init__( **kwargs, ): """ - The AdsorptionOptimizer is used to run an global optimization of - an adsorption on a surface. - A single structure will be created and optimized. - Simulated annealing will be used to global optimize the structure. - The AdsorptionOptimizer is applicable to be used with - active learning. + Initialize the OptimizerMethod instance. Parameters: slab: Atoms instance @@ -38,7 +44,7 @@ def __init__( The adsorbate structure. adsorbate2: Atoms instance (optional) The second adsorbate structure. - bounds : (6,2) or (12,2) ndarray (optional). + bounds: (6,2) or (12,2) ndarray (optional). The boundary conditions used for the global optimization in form of the simulated annealing. The boundary conditions are the x, y, and z coordinates of @@ -61,6 +67,8 @@ def __init__( The seed an also be a RandomState or Generator instance. If not given, the default random number generator is used. """ + # Set the verbose + self.verbose = verbose # Create the atoms object from the slab and adsorbate self.create_slab_ads(slab, adsorbate, adsorbate2, bond_tol=bond_tol) # Create the boundary conditions @@ -75,8 +83,19 @@ def __init__( **kwargs, ) - def get_structures(self, get_all=True, **kwargs): - structures = self.copy_atoms(self.optimizable) + def get_structures( + self, + get_all=True, + properties=[], + allow_calculation=True, + **kwargs, + ): + structures = self.copy_atoms( + self.optimizable, + properties=properties, + allow_calculation=allow_calculation, + **kwargs, + ) structures.set_constraint(self.constraints_org) return structures @@ -107,7 +126,7 @@ def create_slab_ads( """ # Check the slab and adsorbate are given if slab is None or adsorbate is None: - raise Exception("The slab and adsorbate must be given!") + raise ValueError("The slab and adsorbate must be given!") # Save the bond length tolerance self.bond_tol = float(bond_tol) # Setup the slab @@ -199,7 +218,7 @@ def setup_bounds(self, bounds=None): Setup the boundary conditions for the global optimization. Parameters: - bounds : (6,2) or (12,2) ndarray (optional). + bounds: (6,2) or (12,2) ndarray (optional). The boundary conditions used for the global optimization in form of the simulated annealing. The boundary conditions are the x, y, and z coordinates of @@ -227,8 +246,12 @@ def setup_bounds(self, bounds=None): else: self.bounds = bounds.copy() # Check the bounds have the correct shape - if self.bounds.shape != (6, 2) and self.bounds.shape != (12, 2): - raise Exception("The bounds must have shape (6,2) or (12,2)!") + if self.n_ads2 == 0 and self.bounds.shape != (6, 2): + raise ValueError("The bounds must have shape (6,2)!") + elif self.n_ads2 > 0 and not ( + self.bounds.shape == (6, 2) or self.bounds.shape == (12, 2) + ): + raise ValueError("The bounds must have shape (6,2) or (12,2)!") # Check if the bounds are for two adsorbates if self.n_ads2 > 0 and self.bounds.shape[0] == 6: self.bounds = concatenate([self.bounds, self.bounds], axis=0) @@ -299,7 +322,7 @@ def update_arguments( The adsorbate structure. adsorbate2: Atoms instance (optional) The second adsorbate structure. - bounds : (6,2) or (12,2) ndarray (optional). + bounds: (6,2) or (12,2) ndarray (optional). The boundary conditions used for the global optimization in form of the simulated annealing. The boundary conditions are the x, y, and z coordinates of @@ -322,6 +345,11 @@ def update_arguments( The seed an also be a RandomState or Generator instance. If not given, the default random number generator is used. """ + # Set the optimizer kwargs + if opt_kwargs is not None: + self.opt_kwargs = opt_kwargs.copy() + if bond_tol is not None: + self.bond_tol = float(bond_tol) # Set the parameters in the parent class super().update_arguments( optimizable=None, @@ -330,13 +358,14 @@ def update_arguments( verbose=verbose, seed=seed, ) - # Set the optimizer kwargs - if opt_kwargs is not None: - self.opt_kwargs = opt_kwargs.copy() - if bond_tol is not None: - self.bond_tol = float(bond_tol) # Create the atoms object from the slab and adsorbate - if slab is not None and adsorbate is not None: + if slab is not None or adsorbate is not None or adsorbate2 is not None: + if slab is None: + slab = self.slab.copy() + if adsorbate is None: + adsorbate = self.adsorbate.copy() + if adsorbate2 is None and self.adsorbate2 is not None: + adsorbate2 = self.adsorbate2.copy() self.create_slab_ads( slab, adsorbate, @@ -388,8 +417,8 @@ def rotation_matrix(self, angles, positions): positions = matmul(positions, R) return positions - def evaluate_value(self, x, **kwargs): - "Evaluate the value of the adsorption." + def get_new_positions(self, x, **kwargs): + "Get the new positions of the adsorbate." # Get the positions pos = self.positions0.copy() # Calculate the positions of the adsorbate @@ -405,6 +434,12 @@ def evaluate_value(self, x, **kwargs): pos_ads2 = self.rotation_matrix(x[9:12], pos_ads2) pos_ads2 += (self.cell * x[6:9].reshape(-1, 1)).sum(axis=0) pos[n_all:] = pos_ads2 + return pos + + def evaluate_value(self, x, **kwargs): + "Evaluate the value of the adsorption." + # Get the new positions of the adsorption + pos = self.get_new_positions(x, **kwargs) # Set the positions self.optimizable.set_positions(pos) # Get the potential energy From 216adfcf0b369d4ece1a0a51aa7f365e51ca4471 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Wed, 23 Jul 2025 15:42:41 +0200 Subject: [PATCH 144/194] Implement option argument in active learning methods --- catlearn/activelearning/adsorption.py | 134 +++++++++----- catlearn/activelearning/local.py | 97 ++++++++-- catlearn/activelearning/mlgo.py | 247 ++++++++++++++++++++------ catlearn/activelearning/mlneb.py | 161 ++++++++++++++--- 4 files changed, 497 insertions(+), 142 deletions(-) diff --git a/catlearn/activelearning/adsorption.py b/catlearn/activelearning/adsorption.py index 49cd38c4..a3cf0f6a 100644 --- a/catlearn/activelearning/adsorption.py +++ b/catlearn/activelearning/adsorption.py @@ -2,10 +2,17 @@ from .activelearning import ActiveLearning from ..optimizer import AdsorptionOptimizer from ..optimizer import ParallelOptimizer -from ..regression.gp.baseline import RepulsionCalculator, MieCalculator +from ..regression.gp.baseline import BornRepulsionCalculator, MieCalculator class AdsorptionAL(ActiveLearning): + """ + An active learner that is used for accelerating global adsorption search + using simulated annealing with an active learning approach. + The adsorbate is optimized on a surface, where the bond-lengths of the + adsorbate atoms are fixed and the slab atoms are fixed. + """ + def __init__( self, slab, @@ -18,7 +25,6 @@ def __init__( bond_tol=1e-8, chains=None, acq=None, - use_database_check=True, save_memory=False, parallel_run=False, copy_calc=False, @@ -27,28 +33,32 @@ def __init__( force_consistent=False, scale_fmax=0.8, use_fmax_convergence=True, - unc_convergence=0.05, + unc_convergence=0.02, use_method_unc_conv=True, - check_unc=True, - check_energy=True, - check_fmax=True, n_evaluations_each=1, - min_data=3, + min_data=5, + use_database_check=True, + data_perturb=0.001, + data_tol=1e-8, save_properties_traj=True, + to_save_mlcalc=False, + save_mlcalc_kwargs={}, trajectory="predicted.traj", trainingset="evaluated.traj", + pred_evaluated="predicted_evaluated.traj", converged_trajectory="converged.traj", initial_traj="initial_struc.traj", tabletxt="ml_summary.txt", + timetxt="ml_time.txt", prev_calculations=None, restart=False, - seed=None, + seed=1, + dtype=float, comm=world, **kwargs, ): """ - An active learner that is used for accelerating local optimization - of an atomic structure with an active learning approach. + Initialize the ActiveLearning instance. Parameters: slab: Atoms instance @@ -83,9 +93,6 @@ def __init__( The Acquisition instance used for calculating the acq. function and choose a candidate to calculate next. The default AcqUME instance is used if acq is None. - use_database_check: bool - Whether to check if the new structure is within the database. - If it is in the database, the structure is rattled. save_memory: bool Whether to only train the ML calculator and store all objects on one CPU. @@ -121,22 +128,30 @@ def __init__( use_method_unc_conv: bool Whether to use the unc_convergence as a convergence criterion in the optimization method. - check_unc: bool - Check if the uncertainty is large for the restarted result and - if it is then use the previous initial. - check_energy: bool - Check if the energy is larger for the restarted result than - the previous. - check_fmax: bool - Check if the maximum force is larger for the restarted result - than the initial interpolation and if so then replace it. n_evaluations_each: int Number of evaluations for each candidate. min_data: int The minimum number of data points in the training set before the active learning can converge. + use_database_check: bool + Whether to check if the new structure is within the database. + If it is in the database, the structure is rattled. + Please be aware that the predicted structure will differ from + the structure in the database if the rattling is applied. + data_perturb: float + The perturbation of the data structure if it is in the database + and use_database_check is True. + data_perturb is the standard deviation of the normal + distribution used to rattle the structure. + data_tol: float + The tolerance for the data structure if it is in the database + and use_database_check is True. save_properties_traj: bool Whether to save the calculated properties to the trajectory. + to_save_mlcalc: bool + Whether to save the ML calculator to a file after training. + save_mlcalc_kwargs: dict + Arguments for saving the ML calculator, like the filename. trajectory: str or TrajectoryWriter instance Trajectory filename to store the predicted data. Or the TrajectoryWriter instance to store the predicted data. @@ -144,6 +159,13 @@ def __init__( Trajectory filename to store the evaluated training data. Or the TrajectoryWriter instance to store the evaluated training data. + pred_evaluated: str or TrajectoryWriter instance (optional) + Trajectory filename to store the evaluated training data + with predicted properties. + Or the TrajectoryWriter instance to store the evaluated + training data with predicted properties. + If pred_evaluated is None, then the predicted data is + not saved. converged_trajectory: str or TrajectoryWriter instance Trajectory filename to store the converged structure(s). Or the TrajectoryWriter instance to store the converged @@ -155,6 +177,9 @@ def __init__( tabletxt: str Name of the .txt file where the summary table is printed. It is not saved to the file if tabletxt=None. + timetxt: str (optional) + Name of the .txt file where the time table is printed. + It is not saved to the file if timetxt=None. prev_calculations: Atoms list or ASE Trajectory file. The user can feed previously calculated data for the same hypersurface. @@ -166,6 +191,8 @@ def __init__( The random seed for the optimization. The seed an also be a RandomState or Generator instance. If not given, the default random number generator is used. + dtype: type + The data type of the arrays. comm: MPI communicator. The MPI communicator. """ @@ -189,7 +216,6 @@ def __init__( mlcalc=mlcalc, acq=acq, is_minimization=True, - use_database_check=use_database_check, save_memory=save_memory, parallel_run=parallel_run, copy_calc=copy_calc, @@ -201,20 +227,25 @@ def __init__( unc_convergence=unc_convergence, use_method_unc_conv=use_method_unc_conv, use_restart=False, - check_unc=check_unc, - check_energy=check_energy, - check_fmax=check_fmax, n_evaluations_each=n_evaluations_each, min_data=min_data, + use_database_check=use_database_check, + data_perturb=data_perturb, + data_tol=data_tol, save_properties_traj=save_properties_traj, + to_save_mlcalc=to_save_mlcalc, + save_mlcalc_kwargs=save_mlcalc_kwargs, trajectory=trajectory, trainingset=trainingset, + pred_evaluated=pred_evaluated, converged_trajectory=converged_trajectory, initial_traj=initial_traj, tabletxt=tabletxt, + timetxt=timetxt, prev_calculations=prev_calculations, restart=restart, seed=seed, + dtype=dtype, comm=comm, **kwargs, ) @@ -257,6 +288,7 @@ def build_method( comm=comm, verbose=verbose, ) + # Run the method in parallel if requested if parallel_run: method = ParallelOptimizer( method, @@ -275,11 +307,9 @@ def extra_initial_data(self, **kwargs): return self # Get the initial structures from baseline potentials if n_data == 0: - self.method.set_calculator(RepulsionCalculator(r_scale=0.7)) + self.method.set_calculator(BornRepulsionCalculator(r_scale=1.0)) else: - self.method.set_calculator( - MieCalculator(r_scale=1.1, denergy=1.0, power_r=10, power_a=6) - ) + self.method.set_calculator(MieCalculator(r_scale=1.2, denergy=0.2)) self.method.run(fmax=0.05, steps=1000) atoms = self.method.get_candidates()[0] # Calculate the initial structure @@ -295,35 +325,37 @@ def setup_default_mlcalc( self, fp=None, atoms=None, - baseline=RepulsionCalculator(), + baseline=BornRepulsionCalculator(), use_derivatives=True, calc_forces=False, - kappa=-2.0, + kappa=-3.0, **kwargs, ): - from ..regression.gp.fingerprint.sorteddistances import ( - SortedDistances, - ) + from ..regression.gp.fingerprint import SortedInvDistances # Setup the fingerprint if fp is None: # Check if the Atoms object is given if atoms is None: try: - atoms = self.get_structures(get_all=False) - except Exception: - raise Exception("The Atoms object is not given or stored.") + atoms = self.get_structures( + get_all=False, + allow_calculation=False, + ) + except NameError: + raise NameError("The Atoms object is not given or stored.") # Can only use distances if there are more than one atom if len(atoms) > 1: if atoms.pbc.any(): periodic_softmax = True else: periodic_softmax = False - fp = SortedDistances( + fp = SortedInvDistances( reduce_dimensions=True, use_derivatives=True, periodic_softmax=periodic_softmax, - wrap=False, + wrap=True, + use_tags=True, ) return super().setup_default_mlcalc( fp=fp, @@ -335,6 +367,16 @@ def setup_default_mlcalc( **kwargs, ) + def get_constraints(self, structure, **kwargs): + "Get the constraints of the structures in the method." + constraints = [c.copy() for c in structure.constraints] + return constraints + + def get_constraints_indices(self, structure, **kwargs): + "Get the indices of the constraints of the structures in the method." + indices = [i for c in structure.constraints for i in c.get_indices()] + return indices + def get_arguments(self): "Get the arguments of the class itself." # Get the arguments given to the class in the initialization @@ -349,7 +391,6 @@ def get_arguments(self): bond_tol=self.bond_tol, chains=self.chains, acq=self.acq, - use_database_check=self.use_database_check, save_memory=self.save_memory, parallel_run=self.parallel_run, copy_calc=self.copy_calc, @@ -360,18 +401,23 @@ def get_arguments(self): use_fmax_convergence=self.use_fmax_convergence, unc_convergence=self.unc_convergence, use_method_unc_conv=self.use_method_unc_conv, - check_unc=self.check_unc, - check_energy=self.check_energy, - check_fmax=self.check_fmax, n_evaluations_each=self.n_evaluations_each, min_data=self.min_data, + use_database_check=self.use_database_check, + data_perturb=self.data_perturb, + data_tol=self.data_tol, save_properties_traj=self.save_properties_traj, + to_save_mlcalc=self.to_save_mlcalc, + save_mlcalc_kwargs=self.save_mlcalc_kwargs, trajectory=self.trajectory, trainingset=self.trainingset, + pred_evaluated=self.pred_evaluated, converged_trajectory=self.converged_trajectory, initial_traj=self.initial_traj, tabletxt=self.tabletxt, + timetxt=self.timetxt, seed=self.seed, + dtype=self.dtype, comm=self.comm, ) # Get the constants made within the class diff --git a/catlearn/activelearning/local.py b/catlearn/activelearning/local.py index 9939386f..a26b9e30 100644 --- a/catlearn/activelearning/local.py +++ b/catlearn/activelearning/local.py @@ -1,11 +1,15 @@ from ase.optimize import FIRE from ase.parallel import world -from numpy.linalg import norm from .activelearning import ActiveLearning from ..optimizer import LocalOptimizer class LocalAL(ActiveLearning): + """ + An active learner that is used for accelerating local optimization + of an atomic structure with an active learning approach. + """ + def __init__( self, atoms, @@ -15,7 +19,6 @@ def __init__( local_opt_kwargs={}, acq=None, is_minimization=True, - use_database_check=True, save_memory=False, parallel_run=False, copy_calc=False, @@ -24,29 +27,37 @@ def __init__( force_consistent=False, scale_fmax=0.8, use_fmax_convergence=True, - unc_convergence=0.05, + unc_convergence=0.02, use_method_unc_conv=True, use_restart=True, check_unc=True, check_energy=True, check_fmax=True, + max_unc_restart=0.05, n_evaluations_each=1, min_data=3, + use_database_check=True, + data_perturb=0.001, + data_tol=1e-8, save_properties_traj=True, + to_save_mlcalc=False, + save_mlcalc_kwargs={}, trajectory="predicted.traj", trainingset="evaluated.traj", + pred_evaluated="predicted_evaluated.traj", converged_trajectory="converged.traj", initial_traj="initial_struc.traj", tabletxt="ml_summary.txt", + timetxt="ml_time.txt", prev_calculations=None, restart=False, - seed=None, + seed=1, + dtype=float, comm=world, **kwargs, ): """ - An active learner that is used for accelerating local optimization - of an atomic structure with an active learning approach. + Initialize the ActiveLearning instance. Parameters: atoms: Atoms instance @@ -67,9 +78,6 @@ def __init__( is_minimization: bool Whether it is a minimization that is performed. Alternative is a maximization. - use_database_check: bool - Whether to check if the new structure is within the database. - If it is in the database, the structure is rattled. save_memory: bool Whether to only train the ML calculator and store all objects on one CPU. @@ -116,13 +124,35 @@ def __init__( check_fmax: bool Check if the maximum force is larger for the restarted result than the initial interpolation and if so then replace it. + max_unc_restart: float (optional) + Maximum uncertainty (in eV) for using the structure(s) as + the restart in the optimization method. + If max_unc_restart is None, then the optimization is performed + without the maximum uncertainty. n_evaluations_each: int Number of evaluations for each structure. min_data: int The minimum number of data points in the training set before the active learning can converge. + use_database_check: bool + Whether to check if the new structure is within the database. + If it is in the database, the structure is rattled. + Please be aware that the predicted structure will differ from + the structure in the database if the rattling is applied. + data_perturb: float + The perturbation of the data structure if it is in the database + and use_database_check is True. + data_perturb is the standard deviation of the normal + distribution used to rattle the structure. + data_tol: float + The tolerance for the data structure if it is in the database + and use_database_check is True. save_properties_traj: bool Whether to save the calculated properties to the trajectory. + to_save_mlcalc: bool + Whether to save the ML calculator to a file after training. + save_mlcalc_kwargs: dict + Arguments for saving the ML calculator, like the filename. trajectory: str or TrajectoryWriter instance Trajectory filename to store the predicted data. Or the TrajectoryWriter instance to store the predicted data. @@ -130,6 +160,13 @@ def __init__( Trajectory filename to store the evaluated training data. Or the TrajectoryWriter instance to store the evaluated training data. + pred_evaluated: str or TrajectoryWriter instance (optional) + Trajectory filename to store the evaluated training data + with predicted properties. + Or the TrajectoryWriter instance to store the evaluated + training data with predicted properties. + If pred_evaluated is None, then the predicted data is + not saved. converged_trajectory: str or TrajectoryWriter instance Trajectory filename to store the converged structure(s). Or the TrajectoryWriter instance to store the converged @@ -141,6 +178,9 @@ def __init__( tabletxt: str Name of the .txt file where the summary table is printed. It is not saved to the file if tabletxt=None. + timetxt: str (optional) + Name of the .txt file where the time table is printed. + It is not saved to the file if timetxt=None. prev_calculations: Atoms list or ASE Trajectory file. The user can feed previously calculated data for the same hypersurface. @@ -152,6 +192,8 @@ def __init__( The random seed for the optimization. The seed an also be a RandomState or Generator instance. If not given, the default random number generator is used. + dtype: type + The data type of the arrays. comm: MPI communicator. The MPI communicator. """ @@ -171,7 +213,6 @@ def __init__( mlcalc=mlcalc, acq=acq, is_minimization=is_minimization, - use_database_check=use_database_check, save_memory=save_memory, parallel_run=parallel_run, copy_calc=copy_calc, @@ -186,17 +227,26 @@ def __init__( check_unc=check_unc, check_energy=check_energy, check_fmax=check_fmax, + max_unc_restart=max_unc_restart, n_evaluations_each=n_evaluations_each, min_data=min_data, + use_database_check=use_database_check, + data_perturb=data_perturb, + data_tol=data_tol, save_properties_traj=save_properties_traj, + to_save_mlcalc=to_save_mlcalc, + save_mlcalc_kwargs=save_mlcalc_kwargs, trajectory=trajectory, trainingset=trainingset, + pred_evaluated=pred_evaluated, converged_trajectory=converged_trajectory, initial_traj=initial_traj, tabletxt=tabletxt, + timetxt=timetxt, prev_calculations=prev_calculations, restart=restart, seed=seed, + dtype=dtype, comm=comm, **kwargs, ) @@ -235,13 +285,18 @@ def extra_initial_data(self, **kwargs): if self.atoms.calc is not None: results = self.atoms.calc.results if "energy" in results and "forces" in results: - pos0 = self.atoms.get_positions() - pos1 = self.atoms.calc.atoms.get_positions() - if norm(pos0 - pos1) < 1e-8: - self.use_prev_calculations([self.atoms]) - return self + if self.atoms.calc.atoms is not None: + is_same = self.compare_atoms( + self.atoms, + self.atoms.calc.atoms, + ) + if is_same: + self.use_prev_calculations([self.atoms]) + return self # Calculate the initial structure - self.evaluate(self.get_structures(get_all=False)) + self.evaluate( + self.get_structures(get_all=False, allow_calculation=False) + ) # Print summary table self.print_statement() return self @@ -257,7 +312,6 @@ def get_arguments(self): local_opt_kwargs=self.local_opt_kwargs, acq=self.acq, is_minimization=self.is_minimization, - use_database_check=self.use_database_check, save_memory=self.save_memory, parallel_run=self.parallel_run, copy_calc=self.copy_calc, @@ -272,15 +326,24 @@ def get_arguments(self): check_unc=self.check_unc, check_energy=self.check_energy, check_fmax=self.check_fmax, + max_unc_restart=self.max_unc_restart, n_evaluations_each=self.n_evaluations_each, min_data=self.min_data, + use_database_check=self.use_database_check, + data_perturb=self.data_perturb, + data_tol=self.data_tol, save_properties_traj=self.save_properties_traj, + to_save_mlcalc=self.to_save_mlcalc, + save_mlcalc_kwargs=self.save_mlcalc_kwargs, trajectory=self.trajectory, trainingset=self.trainingset, + pred_evaluated=self.pred_evaluated, converged_trajectory=self.converged_trajectory, initial_traj=self.initial_traj, tabletxt=self.tabletxt, + timetxt=self.timetxt, seed=self.seed, + dtype=self.dtype, comm=self.comm, ) # Get the constants made within the class diff --git a/catlearn/activelearning/mlgo.py b/catlearn/activelearning/mlgo.py index c12f1f5c..7454e483 100644 --- a/catlearn/activelearning/mlgo.py +++ b/catlearn/activelearning/mlgo.py @@ -1,3 +1,4 @@ +from ase.io import read from ase.parallel import world from ase.optimize import FIRE from .adsorption import AdsorptionAL @@ -5,6 +6,16 @@ class MLGO(AdsorptionAL): + """ + An active learner that is used for accelerating global adsorption search + using simulated annealing and local optimization with an active learning + approach. + The adsorbate is optimized on a surface, where the bond-lengths of the + adsorbate atoms are fixed and the slab atoms are fixed. + Afterwards, the structure is local optimized with the initial constraints + applied to the adsorbate atoms and the surface atoms. + """ + def __init__( self, slab, @@ -21,7 +32,6 @@ def __init__( local_opt_kwargs={}, reuse_data_local=True, acq=None, - use_database_check=True, save_memory=False, parallel_run=False, copy_calc=False, @@ -30,23 +40,32 @@ def __init__( force_consistent=False, scale_fmax=0.8, use_fmax_convergence=True, - unc_convergence=0.05, + unc_convergence=0.02, use_method_unc_conv=True, use_restart=True, check_unc=True, check_energy=True, check_fmax=True, + max_unc_restart=0.05, n_evaluations_each=1, min_data=3, + use_database_check=True, + data_perturb=0.001, + data_tol=1e-8, save_properties_traj=True, + to_save_mlcalc=False, + save_mlcalc_kwargs={}, trajectory="predicted.traj", trainingset="evaluated.traj", + pred_evaluated="predicted_evaluated.traj", converged_trajectory="converged.traj", initial_traj="initial_struc.traj", tabletxt="ml_summary.txt", + timetxt="ml_time.txt", prev_calculations=None, restart=False, - seed=None, + seed=1, + dtype=float, comm=world, **kwargs, ): @@ -55,7 +74,7 @@ def __init__( of an atomic structure with an active learning approach. Parameters: - slab: Atoms instance + slab: Atoms instance The slab structure. Can either be a surface or a nanoparticle. adsorbate: Atoms instance @@ -101,9 +120,6 @@ def __init__( is_minimization: bool Whether it is a minimization that is performed. Alternative is a maximization. - use_database_check: bool - Whether to check if the new structure is within the database. - If it is in the database, the structure is rattled. save_memory: bool Whether to only train the ML calculator and store all objects on one CPU. @@ -151,13 +167,35 @@ def __init__( check_fmax: bool Check if the maximum force is larger for the restarted result than the initial interpolation and if so then replace it. + max_unc_restart: float (optional) + Maximum uncertainty (in eV) for using the structure(s) as + the restart in the optimization method. + If max_unc_restart is None, then the optimization is performed + without the maximum uncertainty. n_evaluations_each: int The number of evaluations for each structure. min_data: int The minimum number of data points in the training set before the active learning can converge. + use_database_check: bool + Whether to check if the new structure is within the database. + If it is in the database, the structure is rattled. + Please be aware that the predicted structure will differ from + the structure in the database if the rattling is applied. + data_perturb: float + The perturbation of the data structure if it is in the database + and use_database_check is True. + data_perturb is the standard deviation of the normal + distribution used to rattle the structure. + data_tol: float + The tolerance for the data structure if it is in the database + and use_database_check is True. save_properties_traj: bool Whether to save the calculated properties to the trajectory. + to_save_mlcalc: bool + Whether to save the ML calculator to a file after training. + save_mlcalc_kwargs: dict + Arguments for saving the ML calculator, like the filename. trajectory: str or TrajectoryWriter instance Trajectory filename to store the predicted data. Or the TrajectoryWriter instance to store the predicted data. @@ -165,6 +203,13 @@ def __init__( Trajectory filename to store the evaluated training data. Or the TrajectoryWriter instance to store the evaluated training data. + pred_evaluated: str or TrajectoryWriter instance (optional) + Trajectory filename to store the evaluated training data + with predicted properties. + Or the TrajectoryWriter instance to store the evaluated + training data with predicted properties. + If pred_evaluated is None, then the predicted data is + not saved. converged_trajectory: str or TrajectoryWriter instance Trajectory filename to store the converged structure(s). Or the TrajectoryWriter instance to store the converged @@ -176,6 +221,9 @@ def __init__( tabletxt: str Name of the .txt file where the summary table is printed. It is not saved to the file if tabletxt=None. + timetxt: str (optional) + Name of the .txt file where the time table is printed. + It is not saved to the file if timetxt=None. prev_calculations: Atoms list or ASE Trajectory file. The user can feed previously calculated data for the same hypersurface. @@ -187,9 +235,15 @@ def __init__( The random seed for the optimization. The seed an also be a RandomState or Generator instance. If not given, the default random number generator is used. + dtype: type + The data type of the arrays. comm: MPI communicator. The MPI communicator. """ + # Save bool for reusing data in the mlcalc_local + self.reuse_data_local = reuse_data_local + # Save the local ML-calculator + self.mlcalc_local = mlcalc_local # Initialize the AdsorptionBO super().__init__( slab=slab, @@ -202,7 +256,6 @@ def __init__( bond_tol=bond_tol, chains=chains, acq=acq, - use_database_check=use_database_check, save_memory=save_memory, parallel_run=parallel_run, copy_calc=copy_calc, @@ -216,40 +269,51 @@ def __init__( check_unc=check_unc, check_energy=check_energy, check_fmax=check_fmax, + max_unc_restart=max_unc_restart, n_evaluations_each=n_evaluations_each, min_data=min_data, + use_database_check=use_database_check, + data_perturb=data_perturb, + data_tol=data_tol, save_properties_traj=save_properties_traj, + to_save_mlcalc=to_save_mlcalc, + save_mlcalc_kwargs=save_mlcalc_kwargs, trajectory=trajectory, trainingset=trainingset, + pred_evaluated=pred_evaluated, converged_trajectory=converged_trajectory, initial_traj=initial_traj, tabletxt=tabletxt, - prev_calculations=prev_calculations, - restart=restart, + timetxt=timetxt, + prev_calculations=None, + restart=False, seed=seed, + dtype=dtype, comm=comm, **kwargs, ) # Get the atomic structure - atoms = self.get_structures(get_all=False) + atoms = self.get_structures(get_all=False, allow_calculation=False) # Build the local method self.build_local_method( atoms=atoms, - mlcalc_local=mlcalc_local, local_opt=local_opt, local_opt_kwargs=local_opt_kwargs, - reuse_data_local=reuse_data_local, use_restart=use_restart, ) - # Save the local ML-calculator - self.mlcalc_local = mlcalc_local + # Restart the active learning + prev_calculations = self.restart_optimization( + restart, + prev_calculations, + ) + # Use previous calculations to train ML calculator + self.use_prev_calculations(prev_calculations) def build_local_method( self, atoms, local_opt=FIRE, local_opt_kwargs={}, - reuse_data_local=True, use_restart=True, **kwargs, ): @@ -258,8 +322,8 @@ def build_local_method( self.atoms = self.copy_atoms(atoms) self.local_opt = local_opt self.local_opt_kwargs = local_opt_kwargs - # Save bool for reusing data in the mlcalc_local - self.reuse_data_local = reuse_data_local + # Set whether to use the restart in the local optimization + self.use_local_restart = use_restart # Build the local optimizer method self.local_method = LocalOptimizer( atoms, @@ -315,24 +379,28 @@ def run( converged: bool Whether the active learning is converged. """ - # Run the active learning - super().run( - fmax=fmax, - steps=steps, - ml_steps=ml_steps, - max_unc=max_unc, - dtrust=dtrust, - **kwargs, - ) - # Check if the active learning is converged - if not self.converged(): - return self.converged() - # Switch to the local optimization - self.switch_to_local() - # Adjust the number of steps - steps = steps - self.get_number_of_steps() - if steps <= 0: - return self.converged() + # Check if the global optimization is used + if self.is_global: + # Run the active learning + super().run( + fmax=fmax, + steps=steps, + ml_steps=ml_steps, + max_unc=max_unc, + dtrust=dtrust, + **kwargs, + ) + # Check if the adsorption active learning is converged + if not self.converged(): + return self.converged() + # Get the data from the active learning + data = self.get_data_atoms() + # Switch to the local optimization + self.switch_to_local(data) + # Adjust the number of steps + steps = steps - self.get_number_of_steps() + if steps <= 0: + return self.converged() # Run the local active learning super().run( fmax=fmax, @@ -344,17 +412,16 @@ def run( ) return self.converged() - def switch_mlcalcs(self, **kwargs): + def switch_mlcalcs(self, data, **kwargs): """ Switch the ML calculator used for the local optimization. The data is reused, but without the constraints from Adsorption. """ - # Get the data from the active learning - data = self.get_data_atoms() - if not self.reuse_data_local: - data = data[-1:] # Get the structures - structures = self.get_structures(get_all=False) + structures = self.get_structures( + get_all=False, + allow_calculation=False, + ) # Setup the ML-calculator for the local optimization self.setup_mlcalc_local( mlcalc_local=self.mlcalc_local, @@ -364,25 +431,93 @@ def switch_mlcalcs(self, **kwargs): verbose=self.verbose, **kwargs, ) - # Remove adsorption constraints - constraints = [c.copy() for c in structures.constraints] - for atoms in data: - atoms.set_constraint(constraints) + # Add the training data to the local ML-calculator self.use_prev_calculations(data) return self - def switch_to_local(self, **kwargs): + def switch_to_local(self, data, **kwargs): "Switch to the local optimization." # Reset convergence self._converged = False + # Set the global optimization flag + self.is_global = False # Switch to the local ML-calculator - self.switch_mlcalcs() + self.switch_mlcalcs(data) # Store the last structures - self.structures = self.get_structures() + self.structures = self.get_structures( + get_all=False, + allow_calculation=False, + ) # Use the last structures for the local optimization self.local_method.update_optimizable(self.structures) # Switch to the local optimization self.setup_method(self.local_method) + # Set whether to use the restart + self.use_restart = self.use_local_restart + return self + + def rm_constraints(self, structure, data, **kwargs): + """ + Remove the constraints from the atoms in the database. + This is used for the local optimization. + """ + # Get the constraints from the structures + constraints = self.get_constraints(structure) + # Remove the constraints + for atoms in data: + atoms.set_constraint(constraints) + return data + + def build_method(self, *args, **kwargs): + # Set the global flag to True + self.is_global = True + # Build the method for the global optimization + return super().build_method(*args, **kwargs) + + def use_prev_calculations(self, prev_calculations=None, **kwargs): + if prev_calculations is None: + return self + if isinstance(prev_calculations, str): + prev_calculations = read(prev_calculations, ":") + if isinstance(prev_calculations, list) and len(prev_calculations) == 0: + return self + # Get the constraints indices if necessary + if self.is_global or not self.reuse_data_local: + # Get the constraints of the first calculation + constraints0 = self.get_constraints_indices(prev_calculations[0]) + # Compare the constraints of the previous calculations + bool_constraints = [ + self.get_constraints_indices(atoms) == constraints0 + for atoms in prev_calculations[1:] + ] + # Check if the prev calculations has the same constraints + if self.is_global: + # Check if all constraints are the same + if not all(bool_constraints): + self.message_system( + "The previous calculations have different constraints. " + "Local optimization will be performed." + ) + # Switch to the local optimization + self.switch_to_local(prev_calculations) + return self + else: + # Check whether to truncate the previous calculations + if not self.reuse_data_local: + # Check if the constraints are different + if False in bool_constraints: + index_local = bool_constraints.index(False) + prev_calculations = prev_calculations[index_local:] + else: + # Use only the last calculation + prev_calculations = prev_calculations[-1:] + # Remove the constraints from the previous calculations + prev_calculations = self.rm_constraints( + self.get_structures(get_all=False, allow_calculation=False), + prev_calculations, + ) + # Add calculations to the ML model + self.add_training(prev_calculations) return self def get_arguments(self): @@ -403,7 +538,6 @@ def get_arguments(self): local_opt_kwargs=self.local_opt_kwargs, reuse_data_local=self.reuse_data_local, acq=self.acq, - use_database_check=self.use_database_check, save_memory=self.save_memory, parallel_run=self.parallel_run, copy_calc=self.copy_calc, @@ -414,23 +548,32 @@ def get_arguments(self): use_fmax_convergence=self.use_fmax_convergence, unc_convergence=self.unc_convergence, use_method_unc_conv=self.use_method_unc_conv, - use_restart=self.use_restart, + use_restart=self.use_local_restart, check_unc=self.check_unc, check_energy=self.check_energy, check_fmax=self.check_fmax, + max_unc_restart=self.max_unc_restart, n_evaluations_each=self.n_evaluations_each, min_data=self.min_data, + use_database_check=self.use_database_check, + data_perturb=self.data_perturb, + data_tol=self.data_tol, save_properties_traj=self.save_properties_traj, + to_save_mlcalc=self.to_save_mlcalc, + save_mlcalc_kwargs=self.save_mlcalc_kwargs, trajectory=self.trajectory, trainingset=self.trainingset, + pred_evaluated=self.pred_evaluated, converged_trajectory=self.converged_trajectory, initial_traj=self.initial_traj, tabletxt=self.tabletxt, + timetxt=self.timetxt, seed=self.seed, + dtype=self.dtype, comm=self.comm, ) # Get the constants made within the class - constant_kwargs = dict() + constant_kwargs = dict(is_global=self.is_global) # Get the objects made within the class object_kwargs = dict() return arg_kwargs, constant_kwargs, object_kwargs diff --git a/catlearn/activelearning/mlneb.py b/catlearn/activelearning/mlneb.py index 908e003d..84379ebf 100644 --- a/catlearn/activelearning/mlneb.py +++ b/catlearn/activelearning/mlneb.py @@ -1,13 +1,17 @@ from ase.optimize import FIRE from ase.parallel import world from ase.io import read -from numpy.linalg import norm from .activelearning import ActiveLearning from ..optimizer import LocalCINEB -from ..structures.neb import ImprovedTangentNEB +from ..structures.neb import ImprovedTangentNEB, OriginalNEB class MLNEB(ActiveLearning): + """ + An active learner that is used for accelerating nudged elastic band + (NEB) optimization with an active learning approach. + """ + def __init__( self, start, @@ -20,11 +24,11 @@ def __init__( climb=True, neb_interpolation="linear", neb_interpolation_kwargs={}, + start_without_ci=True, reuse_ci_path=True, local_opt=FIRE, local_opt_kwargs={}, acq=None, - use_database_check=True, save_memory=False, parallel_run=False, copy_calc=False, @@ -32,35 +36,43 @@ def __init__( apply_constraint=True, force_consistent=False, scale_fmax=0.8, - unc_convergence=0.05, + unc_convergence=0.02, use_method_unc_conv=True, use_restart=True, check_unc=True, check_energy=False, check_fmax=True, + max_unc_restart=0.05, n_evaluations_each=1, min_data=3, + use_database_check=True, + data_perturb=0.001, + data_tol=1e-8, save_properties_traj=True, + to_save_mlcalc=True, + save_mlcalc_kwargs={}, trajectory="predicted.traj", trainingset="evaluated.traj", + pred_evaluated="predicted_evaluated.traj", converged_trajectory="converged.traj", initial_traj="initial_struc.traj", tabletxt="ml_summary.txt", + timetxt="ml_time.txt", prev_calculations=None, restart=False, - seed=None, + seed=1, + dtype=float, comm=world, **kwargs, ): """ - An active learner that is used for accelerating nudged elastic band - (NEB) optimization with an active learning approach. + Initialize the ActiveLearning instance. Parameters: - start : Atoms instance or ASE Trajectory file. + start: Atoms instance or ASE Trajectory file. The Atoms must have the calculator attached with energy. Initial end-point of the NEB path. - end : Atoms instance or ASE Trajectory file. + end: Atoms instance or ASE Trajectory file. The Atoms must have the calculator attached with energy. Final end-point of the NEB path. ase_calc: ASE calculator instance. @@ -68,36 +80,44 @@ def __init__( mlcalc: ML-calculator instance. The ML-calculator instance used as surrogate surface. The default BOCalculator instance is used if mlcalc is None. - neb_method : NEB class object or str + neb_method: NEB class object or str The NEB implemented class object used for the ML-NEB. A string can be used to select: - 'improvedtangentneb' (default) - 'ewneb' - neb_kwargs : dict + neb_kwargs: dict A dictionary with the arguments used in the NEB object to create the instance. Climb must not be included. - n_images : int + n_images: int Number of images of the path (if not included a path before). The number of images include the 2 end-points of the NEB path. - climb : bool + climb: bool Whether to use the climbing image in the NEB. It is strongly recommended to have climb=True. - neb_interpolation : str or list of ASE Atoms or ASE Trajectory file + neb_interpolation: str or list of ASE Atoms or ASE Trajectory file The interpolation method used to create the NEB path. The string can be: - 'linear' (default) - 'idpp' - 'rep' + - 'born - 'ends' Otherwise, the premade images can be given as a list of ASE Atoms. A string of the ASE Trajectory file that contains the images can also be given. - neb_interpolation_kwargs : dict + neb_interpolation_kwargs: dict The keyword arguments for the interpolation method. It is only used when the interpolation method is a string. - reuse_ci_path : bool + start_without_ci: bool + Whether to start the NEB without the climbing image. + If True, the NEB path will be optimized without + the climbing image and afterwards climbing image is used + if climb=True as well. + If False, the NEB path will be optimized with the climbing + image if climb=True as well. + reuse_ci_path: bool Whether to restart from the climbing image path when the NEB without climbing image is converged. local_opt: ASE optimizer object @@ -155,13 +175,35 @@ def __init__( check_fmax: bool Check if the maximum force is larger for the restarted result than the initial interpolation and if so then replace it. + max_unc_restart: float (optional) + Maximum uncertainty (in eV) for using the structure(s) as + the restart in the optimization method. + If max_unc_restart is None, then the optimization is performed + without the maximum uncertainty. n_evaluations_each: int Number of evaluations for each iteration. min_data: int The minimum number of data points in the training set before the active learning can converge. + use_database_check: bool + Whether to check if the new structure is within the database. + If it is in the database, the structure is rattled. + Please be aware that the predicted structure will differ from + the structure in the database if the rattling is applied. + data_perturb: float + The perturbation of the data structure if it is in the database + and use_database_check is True. + data_perturb is the standard deviation of the normal + distribution used to rattle the structure. + data_tol: float + The tolerance for the data structure if it is in the database + and use_database_check is True. save_properties_traj: bool Whether to save the calculated properties to the trajectory. + to_save_mlcalc: bool + Whether to save the ML calculator to a file after training. + save_mlcalc_kwargs: dict + Arguments for saving the ML calculator, like the filename. trajectory: str or TrajectoryWriter instance Trajectory filename to store the predicted data. Or the TrajectoryWriter instance to store the predicted data. @@ -169,6 +211,13 @@ def __init__( Trajectory filename to store the evaluated training data. Or the TrajectoryWriter instance to store the evaluated training data. + pred_evaluated: str or TrajectoryWriter instance (optional) + Trajectory filename to store the evaluated training data + with predicted properties. + Or the TrajectoryWriter instance to store the evaluated + training data with predicted properties. + If pred_evaluated is None, then the predicted data is + not saved. converged_trajectory: str or TrajectoryWriter instance Trajectory filename to store the converged structure(s). Or the TrajectoryWriter instance to store the converged @@ -180,6 +229,9 @@ def __init__( tabletxt: str Name of the .txt file where the summary table is printed. It is not saved to the file if tabletxt=None. + timetxt: str (optional) + Name of the .txt file where the time table is printed. + It is not saved to the file if timetxt=None. prev_calculations: Atoms list or ASE Trajectory file. The user can feed previously calculated data for the same hypersurface. @@ -191,6 +243,8 @@ def __init__( The random seed for the optimization. The seed an also be a RandomState or Generator instance. If not given, the default random number generator is used. + dtype: type + The data type of the arrays. comm: MPI communicator. The MPI communicator. """ @@ -204,6 +258,7 @@ def __init__( n_images=n_images, neb_interpolation=neb_interpolation, neb_interpolation_kwargs=neb_interpolation_kwargs, + start_without_ci=start_without_ci, reuse_ci_path=reuse_ci_path, local_opt=local_opt, local_opt_kwargs=local_opt_kwargs, @@ -219,7 +274,6 @@ def __init__( mlcalc=mlcalc, acq=acq, is_minimization=False, - use_database_check=use_database_check, save_memory=save_memory, parallel_run=parallel_run, copy_calc=copy_calc, @@ -234,17 +288,26 @@ def __init__( check_unc=check_unc, check_energy=check_energy, check_fmax=check_fmax, + max_unc_restart=max_unc_restart, n_evaluations_each=n_evaluations_each, min_data=min_data, + use_database_check=use_database_check, + data_perturb=data_perturb, + data_tol=data_tol, save_properties_traj=save_properties_traj, + to_save_mlcalc=to_save_mlcalc, + save_mlcalc_kwargs=save_mlcalc_kwargs, trajectory=trajectory, trainingset=trainingset, + pred_evaluated=pred_evaluated, converged_trajectory=converged_trajectory, initial_traj=initial_traj, tabletxt=tabletxt, + timetxt=timetxt, prev_calculations=self.prev_calculations, restart=restart, seed=seed, + dtype=dtype, comm=comm, **kwargs, ) @@ -254,7 +317,7 @@ def setup_endpoints( start, end, prev_calculations, - eps=1e-6, + tol=1e-8, **kwargs, ): """ @@ -266,10 +329,22 @@ def setup_endpoints( if isinstance(end, str): end = read(end) # Save the start point with calculators - start.get_forces() + try: + start.get_forces() + except RuntimeError: + raise RuntimeError( + "The start point must have a calculator attached with " + "energy and forces!" + ) self.start = self.copy_atoms(start) # Save the end point with calculators - end.get_forces() + try: + end.get_forces() + except RuntimeError: + raise RuntimeError( + "The end point must have a calculator attached with " + "energy and forces!" + ) self.end = self.copy_atoms(end) # Save in previous calculations self.prev_calculations = [self.start, self.end] @@ -278,12 +353,20 @@ def setup_endpoints( prev_calculations = read(prev_calculations, ":") # Check if end points are in the previous calculations if len(prev_calculations): - pos = prev_calculations[0].get_positions() - if norm(pos - self.start.get_positions()) < eps: + is_same = self.compare_atoms( + self.start, + prev_calculations[0], + tol=tol, + ) + if is_same: prev_calculations = prev_calculations[1:] if len(prev_calculations): - pos = prev_calculations[0].get_positions() - if norm(pos - self.end.get_positions()) < eps: + is_same = self.compare_atoms( + self.end, + prev_calculations[0], + tol=tol, + ) + if is_same: prev_calculations = prev_calculations[1:] # Save the previous calculations self.prev_calculations += list(prev_calculations) @@ -300,6 +383,7 @@ def build_method( mic=True, neb_interpolation="linear", neb_interpolation_kwargs={}, + start_without_ci=True, reuse_ci_path=True, local_opt=FIRE, local_opt_kwargs={}, @@ -317,11 +401,19 @@ def build_method( self.neb_kwargs = dict( k=k, remove_rotation_and_translation=remove_rotation_and_translation, - mic=mic, - save_properties=True, parallel=parallel_run, - world=comm, ) + if isinstance(neb_method, str) or issubclass(neb_method, OriginalNEB): + self.neb_kwargs.update( + dict( + use_image_permutation=True, + save_properties=True, + mic=mic, + comm=comm, + ) + ) + else: + self.neb_kwargs.update(dict(world=comm)) self.neb_kwargs.update(neb_kwargs) self.n_images = n_images self.neb_interpolation = neb_interpolation @@ -330,6 +422,7 @@ def build_method( remove_rotation_and_translation=remove_rotation_and_translation, ) self.neb_interpolation_kwargs.update(neb_interpolation_kwargs) + self.start_without_ci = start_without_ci self.climb = climb self.reuse_ci_path = reuse_ci_path # Build the sequential neb optimizer @@ -342,6 +435,7 @@ def build_method( climb=self.climb, neb_interpolation=self.neb_interpolation, neb_interpolation_kwargs=self.neb_interpolation_kwargs, + start_without_ci=self.start_without_ci, reuse_ci_path=self.reuse_ci_path, local_opt=self.local_opt, local_opt_kwargs=self.local_opt_kwargs, @@ -356,7 +450,7 @@ def extra_initial_data(self, **kwargs): if self.get_training_set_size() >= 3: return self # Get the images - images = self.get_structures(get_all=True) + images = self.get_structures(get_all=True, allow_calculation=False) # Calculate energies of end points e_start = self.start.get_potential_energy() e_end = self.end.get_potential_energy() @@ -386,12 +480,12 @@ def get_arguments(self): climb=self.climb, neb_interpolation=self.neb_interpolation, neb_interpolation_kwargs=self.neb_interpolation_kwargs, + start_without_ci=self.start_without_ci, reuse_ci_path=self.reuse_ci_path, local_opt=self.local_opt, local_opt_kwargs=self.local_opt_kwargs, acq=self.acq, is_minimization=self.is_minimization, - use_database_check=self.use_database_check, save_memory=self.save_memory, parallel_run=self.parallel_run, copy_calc=self.copy_calc, @@ -405,15 +499,24 @@ def get_arguments(self): check_unc=self.check_unc, check_energy=self.check_energy, check_fmax=self.check_fmax, + max_unc_restart=self.max_unc_restart, n_evaluations_each=self.n_evaluations_each, min_data=self.min_data, + use_database_check=self.use_database_check, + data_perturb=self.data_perturb, + data_tol=self.data_tol, save_properties_traj=self.save_properties_traj, + to_save_mlcalc=self.to_save_mlcalc, + save_mlcalc_kwargs=self.save_mlcalc_kwargs, trajectory=self.trajectory, trainingset=self.trainingset, + pred_evaluated=self.pred_evaluated, converged_trajectory=self.converged_trajectory, initial_traj=self.initial_traj, tabletxt=self.tabletxt, + timetxt=self.timetxt, seed=self.seed, + dtype=self.dtype, comm=self.comm, ) # Get the constants made within the class From 57118ee1d5831536bbe46a84912f17954ae91f39 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Wed, 23 Jul 2025 15:47:45 +0200 Subject: [PATCH 145/194] Implementation of new global optimization method --- catlearn/activelearning/__init__.py | 2 + catlearn/activelearning/randomadsorption.py | 460 ++++++++++++++++ catlearn/optimizer/__init__.py | 2 + catlearn/optimizer/randomadsorption.py | 561 ++++++++++++++++++++ 4 files changed, 1025 insertions(+) create mode 100644 catlearn/activelearning/randomadsorption.py create mode 100644 catlearn/optimizer/randomadsorption.py diff --git a/catlearn/activelearning/__init__.py b/catlearn/activelearning/__init__.py index 70312252..de8ec0e6 100644 --- a/catlearn/activelearning/__init__.py +++ b/catlearn/activelearning/__init__.py @@ -16,6 +16,7 @@ from .mlneb import MLNEB from .adsorption import AdsorptionAL from .mlgo import MLGO +from .randomadsorption import RandomAdsorptionAL __all__ = [ "Acquisition", @@ -34,4 +35,5 @@ "MLNEB", "AdsorptionAL", "MLGO", + "RandomAdsorptionAL", ] diff --git a/catlearn/activelearning/randomadsorption.py b/catlearn/activelearning/randomadsorption.py new file mode 100644 index 00000000..1e33c6e0 --- /dev/null +++ b/catlearn/activelearning/randomadsorption.py @@ -0,0 +1,460 @@ +from ase.parallel import world +from ase.optimize import FIRE +from .activelearning import ActiveLearning +from ..optimizer import RandomAdsorptionOptimizer +from ..optimizer import ParallelOptimizer +from ..regression.gp.baseline import BornRepulsionCalculator, MieCalculator + + +class RandomAdsorptionAL(ActiveLearning): + """ + An active learner that is used for accelerating global adsorption search + using random sampling and local optimization with an active learning + approach. + The adsorbate is random sampled in space and the most stable structure is + local optimized. + """ + + def __init__( + self, + slab, + adsorbate, + ase_calc, + mlcalc=None, + adsorbate2=None, + bounds=None, + n_random_draws=50, + use_initial_opt=True, + initial_fmax=0.2, + use_repulsive_check=True, + repulsive_tol=0.1, + repulsive_calculator=BornRepulsionCalculator(), + local_opt=FIRE, + local_opt_kwargs={}, + chains=None, + acq=None, + save_memory=False, + parallel_run=False, + copy_calc=False, + verbose=True, + apply_constraint=True, + force_consistent=False, + scale_fmax=0.8, + use_fmax_convergence=True, + unc_convergence=0.02, + use_method_unc_conv=True, + check_unc=True, + check_energy=True, + check_fmax=True, + max_unc_restart=0.05, + n_evaluations_each=1, + min_data=5, + use_database_check=True, + data_perturb=0.001, + data_tol=1e-8, + save_properties_traj=True, + to_save_mlcalc=False, + save_mlcalc_kwargs={}, + trajectory="predicted.traj", + trainingset="evaluated.traj", + pred_evaluated="predicted_evaluated.traj", + converged_trajectory="converged.traj", + initial_traj="initial_struc.traj", + tabletxt="ml_summary.txt", + timetxt="ml_time.txt", + prev_calculations=None, + restart=False, + seed=1, + dtype=float, + comm=world, + **kwargs, + ): + """ + Initialize the ActiveLearning instance. + + Parameters: + slab: Atoms instance + The slab structure. + Can either be a surface or a nanoparticle. + adsorbate: Atoms instance + The adsorbate structure. + ase_calc: ASE calculator instance. + ASE calculator as implemented in ASE. + mlcalc: ML-calculator instance. + The ML-calculator instance used as surrogate surface. + The default BOCalculator instance is used if mlcalc is None. + adsorbate2: Atoms instance (optional) + The second adsorbate structure. + Optimize both adsorbates simultaneously. + The two adsorbates will have different tags. + bounds: (6,2) array or (12,2) array (optional) + The bounds for the optimization. + The first 3 rows are the x, y, z scaled coordinates for + the center of the adsorbate. + The next 3 rows are the three rotation angles in radians. + If two adsorbates are optimized, the next 6 rows are for + the second adsorbate. + n_random_draws: int + The number of random structures to be drawn. + If chains is not None, then the number of random + structures is n_random_draws * chains. + use_initial_opt: bool + If True, the initial structures, drawn from the random + sampling, will be local optimized before the structure + with lowest energy are local optimized. + initial_fmax: float + The maximum force for the initial local optimizations. + use_repulsive_check: bool + If True, a energy will be calculated for each randomly + drawn structure to check if the energy is not too large. + repulsive_tol: float + The tolerance for the repulsive energy check. + repulsive_calculator: ASE calculator instance + The calculator used for the repulsive energy check. + local_opt: ASE optimizer object + The local optimizer object. + local_opt_kwargs: dict + The keyword arguments for the local optimizer. + chains: int (optional) + The number of optimization that will be run in parallel. + It is only used if parallel_run=True. + acq: Acquisition class instance. + The Acquisition instance used for calculating the + acq. function and choose a candidate to calculate next. + The default AcqUME instance is used if acq is None. + save_memory: bool + Whether to only train the ML calculator and store all objects + on one CPU. + If save_memory==True then parallel optimization of + the hyperparameters can not be achived. + If save_memory==False no MPI object is used. + parallel_run: bool + Whether to run method in parallel on multiple CPUs (True) or + in sequence on 1 CPU (False). + copy_calc: bool + Whether to copy the calculator for each candidate + in the method. + verbose: bool + Whether to print on screen the full output (True) or + not (False). + apply_constraint: bool + Whether to apply the constrains of the ASE Atoms instance + to the calculated forces. + By default (apply_constraint=True) forces are 0 for + constrained atoms and directions. + force_consistent: bool or None. + Use force-consistent energy calls (as opposed to the energy + extrapolated to 0 K). + By default force_consistent=False. + scale_fmax: float + The scaling of the fmax for the ML-NEB runs. + It makes the path converge tighter on surrogate surface. + use_fmax_convergence: bool + Whether to use the maximum force as an convergence criterion. + unc_convergence: float + Maximum uncertainty for convergence in + the active learning (in eV). + use_method_unc_conv: bool + Whether to use the unc_convergence as a convergence criterion + in the optimization method. + check_unc: bool + Check if the uncertainty is large for the restarted result and + if it is then use the previous initial. + check_energy: bool + Check if the energy is larger for the restarted result than + the previous. + check_fmax: bool + Check if the maximum force is larger for the restarted result + than the initial interpolation and if so then replace it. + max_unc_restart: float (optional) + Maximum uncertainty (in eV) for using the structure(s) as + the restart in the optimization method. + If max_unc_restart is None, then the optimization is performed + without the maximum uncertainty. + n_evaluations_each: int + Number of evaluations for each candidate. + min_data: int + The minimum number of data points in the training set before + the active learning can converge. + use_database_check: bool + Whether to check if the new structure is within the database. + If it is in the database, the structure is rattled. + Please be aware that the predicted structure will differ from + the structure in the database if the rattling is applied. + data_perturb: float + The perturbation of the data structure if it is in the database + and use_database_check is True. + data_perturb is the standard deviation of the normal + distribution used to rattle the structure. + data_tol: float + The tolerance for the data structure if it is in the database + and use_database_check is True. + save_properties_traj: bool + Whether to save the calculated properties to the trajectory. + to_save_mlcalc: bool + Whether to save the ML calculator to a file after training. + save_mlcalc_kwargs: dict + Arguments for saving the ML calculator, like the filename. + trajectory: str or TrajectoryWriter instance + Trajectory filename to store the predicted data. + Or the TrajectoryWriter instance to store the predicted data. + trainingset: str or TrajectoryWriter instance + Trajectory filename to store the evaluated training data. + Or the TrajectoryWriter instance to store the evaluated + training data. + pred_evaluated: str or TrajectoryWriter instance (optional) + Trajectory filename to store the evaluated training data + with predicted properties. + Or the TrajectoryWriter instance to store the evaluated + training data with predicted properties. + If pred_evaluated is None, then the predicted data is + not saved. + converged_trajectory: str or TrajectoryWriter instance + Trajectory filename to store the converged structure(s). + Or the TrajectoryWriter instance to store the converged + structure(s). + initial_traj: str or TrajectoryWriter instance + Trajectory filename to store the initial structure(s). + Or the TrajectoryWriter instance to store the initial + structure(s). + tabletxt: str + Name of the .txt file where the summary table is printed. + It is not saved to the file if tabletxt=None. + timetxt: str (optional) + Name of the .txt file where the time table is printed. + It is not saved to the file if timetxt=None. + prev_calculations: Atoms list or ASE Trajectory file. + The user can feed previously calculated data + for the same hypersurface. + The previous calculations must be fed as an Atoms list + or Trajectory filename. + restart: bool + Whether to restart the active learning. + seed: int (optional) + The random seed for the optimization. + The seed an also be a RandomState or Generator instance. + If not given, the default random number generator is used. + dtype: type + The data type of the arrays. + comm: MPI communicator. + The MPI communicator. + """ + # Build the optimizer method + method = self.build_method( + slab=slab, + adsorbate=adsorbate, + adsorbate2=adsorbate2, + bounds=bounds, + n_random_draws=n_random_draws, + use_initial_opt=use_initial_opt, + initial_fmax=initial_fmax, + use_repulsive_check=use_repulsive_check, + repulsive_tol=repulsive_tol, + repulsive_calculator=repulsive_calculator, + local_opt=local_opt, + local_opt_kwargs=local_opt_kwargs, + chains=chains, + parallel_run=parallel_run, + comm=comm, + verbose=verbose, + ) + # Initialize the BayesianOptimizer + super().__init__( + method=method, + ase_calc=ase_calc, + mlcalc=mlcalc, + acq=acq, + is_minimization=True, + save_memory=save_memory, + parallel_run=parallel_run, + copy_calc=copy_calc, + verbose=verbose, + apply_constraint=apply_constraint, + force_consistent=force_consistent, + scale_fmax=scale_fmax, + use_fmax_convergence=use_fmax_convergence, + unc_convergence=unc_convergence, + use_method_unc_conv=use_method_unc_conv, + use_restart=False, + check_unc=check_unc, + check_energy=check_energy, + check_fmax=check_fmax, + max_unc_restart=max_unc_restart, + n_evaluations_each=n_evaluations_each, + min_data=min_data, + use_database_check=use_database_check, + data_perturb=data_perturb, + data_tol=data_tol, + save_properties_traj=save_properties_traj, + to_save_mlcalc=to_save_mlcalc, + save_mlcalc_kwargs=save_mlcalc_kwargs, + trajectory=trajectory, + trainingset=trainingset, + pred_evaluated=pred_evaluated, + converged_trajectory=converged_trajectory, + initial_traj=initial_traj, + tabletxt=tabletxt, + timetxt=timetxt, + prev_calculations=prev_calculations, + restart=restart, + seed=seed, + dtype=dtype, + comm=comm, + **kwargs, + ) + + def build_method( + self, + slab, + adsorbate, + adsorbate2=None, + bounds=None, + n_random_draws=20, + use_initial_opt=True, + initial_fmax=0.2, + use_repulsive_check=True, + repulsive_tol=0.1, + repulsive_calculator=BornRepulsionCalculator(), + local_opt=FIRE, + local_opt_kwargs={}, + chains=None, + parallel_run=False, + comm=world, + verbose=False, + **kwargs, + ): + "Build the optimization method." + # Save the instances for creating the adsorption optimizer + self.slab = self.copy_atoms(slab) + self.adsorbate = self.copy_atoms(adsorbate) + if adsorbate2 is not None: + self.adsorbate2 = self.copy_atoms(adsorbate2) + else: + self.adsorbate2 = None + self.bounds = bounds + self.n_random_draws = n_random_draws + self.use_initial_opt = use_initial_opt + self.initial_fmax = initial_fmax + self.use_repulsive_check = use_repulsive_check + self.repulsive_tol = repulsive_tol + self.repulsive_calculator = repulsive_calculator + self.local_opt = local_opt + self.local_opt_kwargs = local_opt_kwargs + self.chains = chains + # Build the optimizer method + method = RandomAdsorptionOptimizer( + slab=slab, + adsorbate=adsorbate, + adsorbate2=adsorbate2, + bounds=bounds, + n_random_draws=n_random_draws, + use_initial_opt=use_initial_opt, + initial_fmax=initial_fmax, + use_repulsive_check=use_repulsive_check, + repulsive_tol=repulsive_tol, + repulsive_calculator=repulsive_calculator, + local_opt=local_opt, + local_opt_kwargs=local_opt_kwargs, + parallel_run=False, + comm=comm, + verbose=verbose, + ) + # Run the method in parallel if requested + if parallel_run: + method = ParallelOptimizer( + method, + chains=chains, + parallel_run=parallel_run, + comm=comm, + verbose=verbose, + ) + return method + + def extra_initial_data(self, **kwargs): + # Get the number of training data + n_data = self.get_training_set_size() + # Check if the training set is empty + if n_data >= 2: + return self + # Get the initial structures from baseline potentials + method_extra = self.method.copy() + method_extra.update_arguments( + n_random_draws=20, + use_initial_opt=False, + use_repulsive_check=True, + ) + if n_data == 0: + method_extra.set_calculator(BornRepulsionCalculator(r_scale=1.0)) + else: + method_extra.set_calculator( + MieCalculator(r_scale=1.2, denergy=0.2) + ) + method_extra.run(fmax=0.1, steps=21) + atoms = method_extra.get_candidates()[0] + # Calculate the initial structure + self.evaluate(atoms) + # Print summary table + if n_data == 1: + self.print_statement() + else: + self.extra_initial_data(**kwargs) + return self + + def get_arguments(self): + "Get the arguments of the class itself." + # Get the arguments given to the class in the initialization + arg_kwargs = dict( + slab=self.slab, + adsorbate=self.adsorbate, + ase_calc=self.ase_calc, + mlcalc=self.mlcalc, + adsorbate2=self.adsorbate2, + bounds=self.bounds, + n_random_draws=self.n_random_draws, + use_initial_opt=self.use_initial_opt, + initial_fmax=self.initial_fmax, + use_repulsive_check=self.use_repulsive_check, + repulsive_tol=self.repulsive_tol, + repulsive_calculator=self.repulsive_calculator, + local_opt=self.local_opt, + local_opt_kwargs=self.local_opt_kwargs, + chains=self.chains, + acq=self.acq, + save_memory=self.save_memory, + parallel_run=self.parallel_run, + copy_calc=self.copy_calc, + verbose=self.verbose, + apply_constraint=self.apply_constraint, + force_consistent=self.force_consistent, + scale_fmax=self.scale_fmax, + use_fmax_convergence=self.use_fmax_convergence, + unc_convergence=self.unc_convergence, + use_method_unc_conv=self.use_method_unc_conv, + check_unc=self.check_unc, + check_energy=self.check_energy, + check_fmax=self.check_fmax, + max_unc_restart=self.max_unc_restart, + n_evaluations_each=self.n_evaluations_each, + min_data=self.min_data, + use_database_check=self.use_database_check, + data_perturb=self.data_perturb, + data_tol=self.data_tol, + save_properties_traj=self.save_properties_traj, + to_save_mlcalc=self.to_save_mlcalc, + save_mlcalc_kwargs=self.save_mlcalc_kwargs, + trajectory=self.trajectory, + trainingset=self.trainingset, + pred_evaluated=self.pred_evaluated, + converged_trajectory=self.converged_trajectory, + initial_traj=self.initial_traj, + tabletxt=self.tabletxt, + timetxt=self.timetxt, + seed=self.seed, + dtype=self.dtype, + comm=self.comm, + ) + # Get the constants made within the class + constant_kwargs = dict() + # Get the objects made within the class + object_kwargs = dict() + return arg_kwargs, constant_kwargs, object_kwargs diff --git a/catlearn/optimizer/__init__.py b/catlearn/optimizer/__init__.py index 5a658251..33e4f527 100644 --- a/catlearn/optimizer/__init__.py +++ b/catlearn/optimizer/__init__.py @@ -3,6 +3,7 @@ from .localneb import LocalNEB from .localcineb import LocalCINEB from .adsorption import AdsorptionOptimizer +from .randomadsorption import RandomAdsorptionOptimizer from .sequential import SequentialOptimizer from .parallelopt import ParallelOptimizer @@ -13,6 +14,7 @@ "LocalNEB", "LocalCINEB", "AdsorptionOptimizer", + "RandomAdsorptionOptimizer", "SequentialOptimizer", "ParallelOptimizer", ] diff --git a/catlearn/optimizer/randomadsorption.py b/catlearn/optimizer/randomadsorption.py new file mode 100644 index 00000000..c029b8c7 --- /dev/null +++ b/catlearn/optimizer/randomadsorption.py @@ -0,0 +1,561 @@ +from .local import LocalOptimizer +from ase.parallel import world +from ase.optimize import FIRE +from numpy import array, asarray, concatenate, cos, inf, matmul, pi, sin +from ..regression.gp.baseline import BornRepulsionCalculator + + +class RandomAdsorptionOptimizer(LocalOptimizer): + """ + The RandomAdsorptionOptimizer is used to run a global optimization of + an adsorption on a surface. + A single structure will be created and optimized. + Random structures will be sampled and the most stable structure is local + optimized. + The RandomAdsorptionOptimizer is applicable to be used with + active learning. + """ + + def __init__( + self, + slab, + adsorbate, + adsorbate2=None, + bounds=None, + n_random_draws=50, + use_initial_opt=True, + initial_fmax=0.2, + use_repulsive_check=True, + repulsive_tol=0.1, + repulsive_calculator=BornRepulsionCalculator(), + local_opt=FIRE, + local_opt_kwargs={}, + parallel_run=False, + comm=world, + verbose=False, + seed=None, + **kwargs, + ): + """ + Initialize the OptimizerMethod instance. + + Parameters: + slab: Atoms instance + The slab structure. + adsorbate: Atoms instance + The adsorbate structure. + adsorbate2: Atoms instance (optional) + The second adsorbate structure. + bounds: (6,2) or (12,2) ndarray (optional). + The boundary conditions used for drawing the positions + for the adsorbate(s). + The boundary conditions are the x, y, and z coordinates of + the center of the adsorbate and 3 rotations. + Same boundary conditions can be set for the second adsorbate + if chosen. + n_random_draws: int + The number of random structures to be drawn. + use_initial_opt: bool + If True, the initial structures, drawn from the random + sampling, will be local optimized before the structure + with lowest energy are local optimized. + initial_fmax: float + The maximum force for the initial local optimizations. + use_repulsive_check: bool + If True, a energy will be calculated for each randomly + drawn structure to check if the energy is not too large. + repulsive_tol: float + The tolerance for the repulsive energy check. + repulsive_calculator: ASE calculator instance + The calculator used for the repulsive energy check. + local_opt: ASE optimizer object + The local optimizer object. + local_opt_kwargs: dict + The keyword arguments for the local optimizer. + parallel_run: bool + If True, the optimization will be run in parallel. + comm: ASE communicator instance + The communicator instance for parallelization. + verbose: bool + Whether to print the full output (True) or + not (False). + seed: int (optional) + The random seed for the optimization. + The seed an also be a RandomState or Generator instance. + If not given, the default random number generator is used. + """ + # Set the verbose + self.verbose = verbose + # Create the atoms object from the slab and adsorbate + self.create_slab_ads(slab, adsorbate, adsorbate2) + # Create the boundary conditions + self.setup_bounds(bounds) + # Set the parameters + self.update_arguments( + n_random_draws=n_random_draws, + use_initial_opt=use_initial_opt, + initial_fmax=initial_fmax, + use_repulsive_check=use_repulsive_check, + repulsive_tol=repulsive_tol, + repulsive_calculator=repulsive_calculator, + local_opt=local_opt, + local_opt_kwargs=local_opt_kwargs, + parallel_run=parallel_run, + comm=comm, + verbose=verbose, + seed=seed, + **kwargs, + ) + + def create_slab_ads( + self, + slab, + adsorbate, + adsorbate2=None, + **kwargs, + ): + """ + Create the structure for the adsorption optimization. + + Parameters: + slab: Atoms object + The slab structure. + adsorbate: Atoms object + The adsorbate structure. + adsorbate2: Atoms object (optional) + The second adsorbate structure. + + Returns: + self: object + The object itself. + """ + # Check the slab and adsorbate are given + if slab is None or adsorbate is None: + raise ValueError("The slab and adsorbate must be given!") + # Setup the slab + self.n_slab = len(slab) + self.slab = slab.copy() + self.slab.set_tags(0) + optimizable = self.slab.copy() + # Setup the adsorbate + self.n_ads = len(adsorbate) + self.adsorbate = adsorbate.copy() + self.adsorbate.set_tags(1) + self.adsorbate.cell = optimizable.cell.copy() + self.adsorbate.pbc = optimizable.pbc.copy() + pos_ads = self.adsorbate.get_positions() + pos_ads -= pos_ads.mean(axis=0) + self.adsorbate.set_positions(pos_ads) + optimizable.extend(self.adsorbate.copy()) + # Setup the adsorbate2 + if adsorbate2 is not None: + self.n_ads2 = len(adsorbate2) + self.adsorbate2 = adsorbate2.copy() + self.adsorbate2.set_tags(2) + self.adsorbate2.cell = optimizable.cell.copy() + self.adsorbate2.pbc = optimizable.pbc.copy() + pos_ads2 = self.adsorbate2.get_positions() + pos_ads2 -= pos_ads2.mean(axis=0) + self.adsorbate2.set_positions(pos_ads2) + optimizable.extend(self.adsorbate2.copy()) + else: + self.n_ads2 = 0 + self.adsorbate2 = None + # Get the full number of atoms + self.natoms = len(optimizable) + # Store the positions and cell + self.positions0 = optimizable.get_positions().copy() + self.cell = array(optimizable.get_cell()) + # Setup the optimizable structure + self.setup_optimizable(optimizable) + return self + + def setup_bounds(self, bounds=None): + """ + Setup the boundary conditions for the global optimization. + + Parameters: + bounds: (6,2) or (12,2) ndarray (optional). + The boundary conditions used for drawing the positions + for the adsorbate(s). + The boundary conditions are the x, y, and z coordinates of + the center of the adsorbate and 3 rotations. + Same boundary conditions can be set for the second adsorbate + if chosen. + + Returns: + self: object + The object itself. + """ + # Check the bounds are given + if bounds is None: + # Make default bounds + self.bounds = asarray( + [ + [0.0, 1.0], + [0.0, 1.0], + [0.0, 1.0], + [0.0, 2.0 * pi], + [0.0, 2.0 * pi], + [0.0, 2.0 * pi], + ] + ) + else: + self.bounds = bounds.copy() + # Check the bounds have the correct shape + if self.n_ads2 == 0 and self.bounds.shape != (6, 2): + raise ValueError("The bounds must have shape (6,2)!") + elif self.n_ads2 > 0 and not ( + self.bounds.shape == (6, 2) or self.bounds.shape == (12, 2) + ): + raise ValueError("The bounds must have shape (6,2) or (12,2)!") + # Check if the bounds are for two adsorbates + if self.n_ads2 > 0 and self.bounds.shape[0] == 6: + self.bounds = concatenate([self.bounds, self.bounds], axis=0) + return self + + def run( + self, + fmax=0.05, + steps=1000000, + max_unc=None, + dtrust=None, + unc_convergence=None, + **kwargs, + ): + # Check if the optimization can take any steps + if steps <= 0: + return self._converged + # Draw random structures + x_drawn = self.draw_random_structures() + # Get the best drawn structure + best_pos, steps = self.get_best_drawn_structure( + x_drawn, + steps=steps, + max_unc=max_unc, + dtrust=dtrust, + **kwargs, + ) + # Set the positions + self.optimizable.set_positions(best_pos) + # Check if the optimization can take any steps + if steps <= 0: + self.message( + "No steps left after drawing random structures.", + is_warning=True, + ) + return self._converged + # Run the local optimization + converged, _ = self.local_optimize( + atoms=self.optimizable, + fmax=fmax, + steps=steps, + max_unc=max_unc, + dtrust=dtrust, + **kwargs, + ) + # Check if the optimization is converged + self._converged = self.check_convergence( + converged=converged, + max_unc=max_unc, + dtrust=dtrust, + unc_convergence=unc_convergence, + ) + # Return whether the optimization is converged + return self._converged + + def is_parallel_allowed(self): + return False + + def update_arguments( + self, + slab=None, + adsorbate=None, + adsorbate2=None, + bounds=None, + n_random_draws=None, + use_initial_opt=None, + initial_fmax=None, + use_repulsive_check=None, + repulsive_tol=None, + repulsive_calculator=None, + local_opt=None, + local_opt_kwargs=None, + parallel_run=None, + comm=None, + verbose=None, + seed=None, + **kwargs, + ): + """ + Update the instance with its arguments. + The existing arguments are used if they are not given. + + Parameters: + slab: Atoms instance + The slab structure. + adsorbate: Atoms instance + The adsorbate structure. + adsorbate2: Atoms instance (optional) + The second adsorbate structure. + bounds: (6,2) or (12,2) ndarray (optional). + The boundary conditions used for drawing the positions + for the adsorbate(s). + The boundary conditions are the x, y, and z coordinates of + the center of the adsorbate and 3 rotations. + Same boundary conditions can be set for the second adsorbate + if chosen. + n_random_draws: int + The number of random structures to be drawn. + use_initial_opt: bool + If True, the initial structures, drawn from the random + sampling, will be local optimized before the structure + with lowest energy are local optimized. + initial_fmax: float + The maximum force for the initial local optimizations. + use_repulsive_check: bool + If True, a energy will be calculated for each randomly + drawn structure to check if the energy is not too large. + repulsive_tol: float + The tolerance for the repulsive energy check. + repulsive_calculator: ASE calculator instance + The calculator used for the repulsive energy check. + local_opt: ASE optimizer object + The local optimizer object. + local_opt_kwargs: dict + The keyword arguments for the local optimizer. + parallel_run: bool + If True, the optimization will be run in parallel. + comm: ASE communicator instance + The communicator instance for parallelization. + verbose: bool + Whether to print the full output (True) or + not (False). + seed: int (optional) + The random seed for the optimization. + The seed an also be a RandomState or Generator instance. + If not given, the default random number generator is used. + """ + # Set the parameters in the parent class + super().update_arguments( + optimizable=None, + local_opt=local_opt, + local_opt_kwargs=local_opt_kwargs, + parallel_run=parallel_run, + comm=comm, + verbose=verbose, + seed=seed, + ) + # Create the atoms object from the slab and adsorbate + if slab is not None or adsorbate is not None or adsorbate2 is not None: + if slab is None: + slab = self.slab.copy() + if adsorbate is None: + adsorbate = self.adsorbate.copy() + if adsorbate2 is None and self.adsorbate2 is not None: + adsorbate2 = self.adsorbate2.copy() + self.create_slab_ads( + slab, + adsorbate, + adsorbate2, + ) + # Create the boundary conditions + if bounds is not None: + self.setup_bounds(bounds) + # Set the rest of the parameters + if n_random_draws is not None: + self.n_random_draws = int(n_random_draws) + if use_initial_opt is not None: + self.use_initial_opt = use_initial_opt + if initial_fmax is not None: + self.initial_fmax = float(initial_fmax) + if use_repulsive_check is not None: + self.use_repulsive_check = use_repulsive_check + if repulsive_tol is not None: + self.repulsive_tol = float(repulsive_tol) + if repulsive_calculator is not None or not hasattr( + self, "repulsive_calculator" + ): + self.repulsive_calculator = repulsive_calculator + return self + + def draw_random_structures(self, **kwargs): + "Draw random structures for the adsorption optimization." + # Get reference energy + self.e_ref = self.get_reference_energy() + # Initialize the drawn structures + failed_steps = 0 + n_drawn = 0 + x_drawn = [] + # Set a dummy structure for the repulsive check + if self.use_repulsive_check: + dummy_optimizable = self.optimizable.copy() + dummy_optimizable.calc = self.repulsive_calculator + # Draw random structures + while n_drawn < self.n_random_draws: + # Draw a random structure + x = self.rng.uniform(low=self.bounds[:, 0], high=self.bounds[:, 1]) + # Evaluate the value of the structure + if self.use_repulsive_check: + e = self.evaluate_value(x, atoms=dummy_optimizable) + # Check if the value is not too large + if e - self.e_ref > self.repulsive_tol: + failed_steps += 1 + if failed_steps > 100.0 * self.n_random_draws: + self.message( + f"{failed_steps} failed drawns. " + "Stopping is recommended!", + is_warning=True, + ) + continue + # Add the structure to the list of drawn structures + x_drawn.append(x) + n_drawn += 1 + return x_drawn + + def get_best_drawn_structure( + self, + x_drawn, + steps=1000, + max_unc=None, + dtrust=None, + **kwargs, + ): + "Get the best drawn structure from the random sampling." + # Initialize the best energy and position + best_energy = inf + best_pos = None + for x in x_drawn: + # Get the new positions of the adsorbate + pos = self.get_new_positions(x, **kwargs) + # Set the positions + self.optimizable.set_positions(pos) + # Check if the initial optimization is used + if self.use_initial_opt: + # Run the local optimization + _, used_steps = self.local_optimize( + atoms=self.optimizable, + fmax=self.initial_fmax, + steps=steps, + max_unc=max_unc, + dtrust=dtrust, + **kwargs, + ) + steps -= used_steps + self.steps += used_steps + pos = self.optimizable.get_positions() + else: + steps -= 1 + self.steps += 1 + # Get the energy of the structure + e = self.optimizable.get_potential_energy() + if e < best_energy: + best_energy = e + best_pos = pos.copy() + return best_pos, steps + + def rotation_matrix(self, angles, positions): + "Rotate the adsorbate" + # Get the angles + theta1, theta2, theta3 = angles + # Calculate the trigonometric functions + cos1 = cos(theta1) + sin1 = sin(theta1) + cos2 = cos(theta2) + sin2 = sin(theta2) + cos3 = cos(theta3) + sin3 = sin(theta3) + # Calculate the full rotation matrix + R = asarray( + [ + [cos2 * cos3, cos2 * sin3, -sin2], + [ + sin1 * sin2 * cos3 - cos1 * sin3, + sin1 * sin2 * sin3 + cos1 * cos3, + sin1 * cos2, + ], + [ + cos1 * sin2 * cos3 + sin1 * sin3, + cos1 * sin2 * sin3 - sin1 * cos3, + cos1 * cos2, + ], + ] + ) + # Calculate the rotation of the positions + positions = matmul(positions, R) + return positions + + def get_new_positions(self, x, **kwargs): + "Get the new positions of the adsorbate." + # Get the positions + pos = self.positions0.copy() + # Calculate the positions of the adsorbate + n_slab = self.n_slab + n_all = self.n_slab + self.n_ads + pos_ads = pos[n_slab:n_all] + pos_ads = self.rotation_matrix(x[3:6], pos_ads) + pos_ads += (self.cell * x[:3].reshape(-1, 1)).sum(axis=0) + pos[n_slab:n_all] = pos_ads + # Calculate the positions of the second adsorbate + if self.n_ads2 > 0: + pos_ads2 = pos[n_all:] + pos_ads2 = self.rotation_matrix(x[9:12], pos_ads2) + pos_ads2 += (self.cell * x[6:9].reshape(-1, 1)).sum(axis=0) + pos[n_all:] = pos_ads2 + return pos + + def evaluate_value(self, x, atoms, **kwargs): + "Evaluate the value of the adsorption." + # Get the new positions of the adsorption + pos = self.get_new_positions(x, **kwargs) + # Set the positions + atoms.set_positions(pos) + # Get the potential energy + return atoms.get_potential_energy() + + def get_reference_energy(self, **kwargs): + "Get the reference energy of the structure." + # If the repulsive check is not used, return 0.0 + if not self.use_repulsive_check: + return 0.0 + # Calculate the energy of the isolated slab + atoms = self.slab.copy() + atoms.calc = self.repulsive_calculator + e_ref = atoms.get_potential_energy() + # Calculate the energy of the isolated adsorbate + atoms = self.adsorbate.copy() + atoms.calc = self.repulsive_calculator + e_ref += atoms.get_potential_energy() + # Calculate the energy of the isolated second adsorbate + if self.adsorbate2 is not None: + atoms = self.adsorbate2.copy() + atoms.calc = self.repulsive_calculator + e_ref += atoms.get_potential_energy() + return e_ref + + def get_arguments(self): + "Get the arguments of the class itself." + # Get the arguments given to the class in the initialization + arg_kwargs = dict( + slab=self.slab, + adsorbate=self.adsorbate, + adsorbate2=self.adsorbate2, + bounds=self.bounds, + n_random_draws=self.n_random_draws, + use_initial_opt=self.use_initial_opt, + initial_fmax=self.initial_fmax, + use_repulsive_check=self.use_repulsive_check, + repulsive_tol=self.repulsive_tol, + repulsive_calculator=self.repulsive_calculator, + local_opt=self.local_opt, + local_opt_kwargs=self.local_opt_kwargs, + parallel_run=self.parallel_run, + comm=self.comm, + verbose=self.verbose, + seed=self.seed, + ) + # Get the constants made within the class + constant_kwargs = dict(steps=self.steps, _converged=self._converged) + # Get the objects made within the class + object_kwargs = dict() + return arg_kwargs, constant_kwargs, object_kwargs From cd1b8e729656d956e2cac606588921c4059b5efe Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Thu, 24 Jul 2025 10:38:49 +0200 Subject: [PATCH 146/194] Debug find_minimum_path_length --- catlearn/activelearning/mlneb.py | 5 ++++- catlearn/structures/neb/orgneb.py | 30 ++++++++++++++++++------------ 2 files changed, 22 insertions(+), 13 deletions(-) diff --git a/catlearn/activelearning/mlneb.py b/catlearn/activelearning/mlneb.py index 84379ebf..52a9c2ca 100644 --- a/catlearn/activelearning/mlneb.py +++ b/catlearn/activelearning/mlneb.py @@ -265,6 +265,7 @@ def __init__( parallel_run=parallel_run, comm=comm, verbose=verbose, + seed=seed, **kwargs, ) # Initialize the BayesianOptimizer @@ -390,6 +391,7 @@ def build_method( parallel_run=False, comm=world, verbose=False, + seed=None, **kwargs, ): "Build the optimization method." @@ -406,7 +408,7 @@ def build_method( if isinstance(neb_method, str) or issubclass(neb_method, OriginalNEB): self.neb_kwargs.update( dict( - use_image_permutation=True, + use_image_permutation=False, save_properties=True, mic=mic, comm=comm, @@ -442,6 +444,7 @@ def build_method( parallel_run=parallel_run, comm=comm, verbose=verbose, + seed=seed, ) return method diff --git a/catlearn/structures/neb/orgneb.py b/catlearn/structures/neb/orgneb.py index 6e92ee27..16ebce27 100644 --- a/catlearn/structures/neb/orgneb.py +++ b/catlearn/structures/neb/orgneb.py @@ -96,8 +96,6 @@ def __init__( self.mic = mic self.save_properties = save_properties self.use_image_permutation = use_image_permutation - # Find the minimum path length if requested - self.permute_images() # Set the parallelization self.parallel = parallel if parallel: @@ -111,6 +109,8 @@ def __init__( ) else: self.remove_parallel_setup() + # Find the minimum path length if requested + self.permute_images() # Set the properties self.reset() @@ -390,11 +390,12 @@ def find_minimum_path_length(self, **kwargs): """ # Get the positions of the images positions = self.get_image_positions() - positions = positions.reshape(self.nimages, -1) # Get the periodic boundary conditions pbc = self.get_pbc() cell = self.get_cell() use_mic = self.mic and pbc.any() + if not use_mic: + positions = positions.reshape(self.nimages, -1) # Set the indices for the selected images indices = arange(self.nimages, dtype=int) selected_indices = empty(self.nimages, dtype=int) @@ -412,17 +413,22 @@ def find_minimum_path_length(self, **kwargs): # Loop until all images are selected while available.any(): candidates = indices[available] - # Find the minimum distance to the current images + # Calculate the distance vectors from the current images dist = positions[candidates] - positions[i_min, None] if use_mic: - dist, _ = mic_distance( - dist, - cell=cell, - pbc=pbc, - use_vector=False, - ) - else: - dist = sqrt(einsum("ij,ij->i", dist, dist)) + dist = [ + mic_distance( + dis, + cell=cell, + pbc=pbc, + use_vector=False, + )[0] + for dis in dist + ] + dist = asarray(dist) + # Calculate the distances + dist = sqrt(einsum("ij,ij->i", dist, dist)) + # Find the minimum distance from the current images i_min = dist.argmin() if is_forward: # Find the minimum distance from the start image From f790e5abc8ddf2361a673f603b86a993059af0be Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Thu, 24 Jul 2025 10:39:28 +0200 Subject: [PATCH 147/194] Implement rattle function --- catlearn/activelearning/activelearning.py | 25 +++++++++++++++-------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/catlearn/activelearning/activelearning.py b/catlearn/activelearning/activelearning.py index d692a9e5..9b881b48 100644 --- a/catlearn/activelearning/activelearning.py +++ b/catlearn/activelearning/activelearning.py @@ -1495,21 +1495,14 @@ def ensure_not_in_database( # Return atoms if it does not exist if atoms is None: return atoms - # Get positions - pos = atoms.get_positions() # Boolean for checking if the atoms instance was in database was_in_database = False # Check if atoms instance is in the database while self.is_in_database(atoms, dtol=self.data_tol, **kwargs): # Atoms instance was in database was_in_database = True - # Rattle the positions - pos_new = pos + self.rng.normal( - loc=0.0, - scale=self.data_perturb, - size=pos.shape, - ) - atoms.set_positions(pos_new) + # Rattle the atoms + atoms = self.rattle_atoms(atoms) # Print message if requested if show_message: self.message_system( @@ -1518,6 +1511,20 @@ def ensure_not_in_database( ) return atoms, was_in_database + def rattle_atoms(self, atoms, **kwargs): + "Rattle the ASE Atoms instance positions." + # Get positions + pos = atoms.get_positions() + # Rattle the positions + pos_new = pos + self.rng.normal( + loc=0.0, + scale=self.data_perturb, + size=pos.shape, + ) + # Set the new positions + atoms.set_positions(pos_new) + return atoms + def ensure_candidate_not_in_database( self, candidate, From a7292a23c9534526991ea5dfa1d394fa9d716ffc Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Thu, 24 Jul 2025 13:54:35 +0200 Subject: [PATCH 148/194] Use 2 initial data points --- catlearn/activelearning/activelearning.py | 24 +++++++---- catlearn/activelearning/adsorption.py | 8 ++-- catlearn/activelearning/local.py | 46 +++++++++++++-------- catlearn/activelearning/mlneb.py | 2 +- catlearn/activelearning/randomadsorption.py | 8 ++-- 5 files changed, 53 insertions(+), 35 deletions(-) diff --git a/catlearn/activelearning/activelearning.py b/catlearn/activelearning/activelearning.py index 9b881b48..b79f91c8 100644 --- a/catlearn/activelearning/activelearning.py +++ b/catlearn/activelearning/activelearning.py @@ -1466,15 +1466,23 @@ def extra_initial_data(self, **kwargs): Get an initial structure for the active learning if the ML calculator does not have any training points. """ + # Get the number of training data + n_data = self.get_training_set_size() # Check if the training set is empty - if self.get_training_set_size() >= 1: + if n_data >= 2: return self - # Calculate the initial structure - self.evaluate( - self.get_structures(get_all=False, allow_calculation=False) - ) + # Get the initial structure + atoms = self.get_structures(get_all=False, allow_calculation=False) + # Rattle if the initial structure is calculated + if n_data == 1: + atoms = self.rattle_atoms(atoms, data_perturb=0.02) + # Evaluate the structure + self.evaluate(atoms) # Print summary table self.print_statement() + # Check if another initial data is needed + if n_data == 0: + self.extra_initial_data(**kwargs) return self def update_database_arguments(self, point_interest=None, **kwargs): @@ -1502,7 +1510,7 @@ def ensure_not_in_database( # Atoms instance was in database was_in_database = True # Rattle the atoms - atoms = self.rattle_atoms(atoms) + atoms = self.rattle_atoms(atoms, data_perturb=self.data_perturb) # Print message if requested if show_message: self.message_system( @@ -1511,14 +1519,14 @@ def ensure_not_in_database( ) return atoms, was_in_database - def rattle_atoms(self, atoms, **kwargs): + def rattle_atoms(self, atoms, data_perturb, **kwargs): "Rattle the ASE Atoms instance positions." # Get positions pos = atoms.get_positions() # Rattle the positions pos_new = pos + self.rng.normal( loc=0.0, - scale=self.data_perturb, + scale=data_perturb, size=pos.shape, ) # Set the new positions diff --git a/catlearn/activelearning/adsorption.py b/catlearn/activelearning/adsorption.py index a3cf0f6a..9bb77f62 100644 --- a/catlearn/activelearning/adsorption.py +++ b/catlearn/activelearning/adsorption.py @@ -312,12 +312,12 @@ def extra_initial_data(self, **kwargs): self.method.set_calculator(MieCalculator(r_scale=1.2, denergy=0.2)) self.method.run(fmax=0.05, steps=1000) atoms = self.method.get_candidates()[0] - # Calculate the initial structure + # Evaluate the structure self.evaluate(atoms) # Print summary table - if n_data == 1: - self.print_statement() - else: + self.print_statement() + # Check if another initial data is needed + if n_data == 0: self.extra_initial_data(**kwargs) return self diff --git a/catlearn/activelearning/local.py b/catlearn/activelearning/local.py index a26b9e30..d1d6092b 100644 --- a/catlearn/activelearning/local.py +++ b/catlearn/activelearning/local.py @@ -32,7 +32,7 @@ def __init__( use_restart=True, check_unc=True, check_energy=True, - check_fmax=True, + check_fmax=False, max_unc_restart=0.05, n_evaluations_each=1, min_data=3, @@ -278,27 +278,37 @@ def build_method( return method def extra_initial_data(self, **kwargs): + # Get the number of training data + n_data = self.get_training_set_size() # Check if the training set is empty - if self.get_training_set_size() >= 1: + if n_data >= 2: return self - # Get the initial structure if it is calculated - if self.atoms.calc is not None: - results = self.atoms.calc.results - if "energy" in results and "forces" in results: - if self.atoms.calc.atoms is not None: - is_same = self.compare_atoms( - self.atoms, - self.atoms.calc.atoms, - ) - if is_same: - self.use_prev_calculations([self.atoms]) - return self - # Calculate the initial structure - self.evaluate( - self.get_structures(get_all=False, allow_calculation=False) - ) + # Check if the initial structure is calculated + if n_data == 0: + if self.atoms.calc is not None: + results = self.atoms.calc.results + if "energy" in results and "forces" in results: + if self.atoms.calc.atoms is not None: + is_same = self.compare_atoms( + self.atoms, + self.atoms.calc.atoms, + ) + if is_same: + self.use_prev_calculations([self.atoms]) + self.extra_initial_data(**kwargs) + return self + # Get the initial structure + atoms = self.atoms.copy() + # Rattle if the initial structure is calculated + if n_data == 1: + atoms = self.rattle_atoms(atoms, data_perturb=0.02) + # Evaluate the structure + self.evaluate(atoms) # Print summary table self.print_statement() + # Check if another initial data is needed + if n_data == 0: + self.extra_initial_data(**kwargs) return self def get_arguments(self): diff --git a/catlearn/activelearning/mlneb.py b/catlearn/activelearning/mlneb.py index 52a9c2ca..9c4f583d 100644 --- a/catlearn/activelearning/mlneb.py +++ b/catlearn/activelearning/mlneb.py @@ -463,7 +463,7 @@ def extra_initial_data(self, **kwargs): else: i_middle = int(2.0 * (len(images) - 2) / 3.0) candidate = images[1 + i_middle].copy() - # Calculate the structure + # Evaluate the structure self.evaluate(candidate) # Print summary table self.print_statement() diff --git a/catlearn/activelearning/randomadsorption.py b/catlearn/activelearning/randomadsorption.py index 1e33c6e0..b6fbf402 100644 --- a/catlearn/activelearning/randomadsorption.py +++ b/catlearn/activelearning/randomadsorption.py @@ -391,12 +391,12 @@ def extra_initial_data(self, **kwargs): ) method_extra.run(fmax=0.1, steps=21) atoms = method_extra.get_candidates()[0] - # Calculate the initial structure + # Evaluate the structure self.evaluate(atoms) # Print summary table - if n_data == 1: - self.print_statement() - else: + self.print_statement() + # Check if another initial data is needed + if n_data == 0: self.extra_initial_data(**kwargs) return self From 37ad32df402a64bc149ebf787cdb58538ac22a0d Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Thu, 24 Jul 2025 13:54:56 +0200 Subject: [PATCH 149/194] Reset calculator --- catlearn/optimizer/localneb.py | 2 ++ catlearn/optimizer/method.py | 1 + 2 files changed, 3 insertions(+) diff --git a/catlearn/optimizer/localneb.py b/catlearn/optimizer/localneb.py index 1c477a5c..81163f69 100644 --- a/catlearn/optimizer/localneb.py +++ b/catlearn/optimizer/localneb.py @@ -162,12 +162,14 @@ def set_calculator(self, calculator, copy_calc=False, **kwargs): image.calc = calc.copy() else: image.calc = calc + image.calc.reset() else: for image in self.optimizable.images[1:-1]: if copy_calc: image.calc = calculator.copy() else: image.calc = calculator + image.calc.reset() return self def get_calculator(self): diff --git a/catlearn/optimizer/method.py b/catlearn/optimizer/method.py index a50ff5cb..b46b7504 100644 --- a/catlearn/optimizer/method.py +++ b/catlearn/optimizer/method.py @@ -200,6 +200,7 @@ def set_calculator(self, calculator, copy_calc=False, **kwargs): self.optimizable.calc = calculator.copy() else: self.optimizable.calc = calculator + self.optimizable.calc.reset() return self def get_calculator(self): From 4f905449b18638799f6e78583393d9954e4ff5bc Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Thu, 24 Jul 2025 14:04:43 +0200 Subject: [PATCH 150/194] Change the b parameter for TP --- catlearn/regression/gp/calculator/mlmodel.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/catlearn/regression/gp/calculator/mlmodel.py b/catlearn/regression/gp/calculator/mlmodel.py index bd9f864b..e7f8e6de 100644 --- a/catlearn/regression/gp/calculator/mlmodel.py +++ b/catlearn/regression/gp/calculator/mlmodel.py @@ -947,7 +947,7 @@ def get_default_model( model_kwargs_default = dict( a=1e-4, - b=10.0, + b=4.0, ) model_kwargs_default.update(model_kwargs) model = TProcess( From 8caefb88c90085657f84a20484e16470e1737f3a Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Thu, 24 Jul 2025 14:41:55 +0200 Subject: [PATCH 151/194] Update README --- README.md | 81 ++++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 72 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index e1939e9e..e845b28a 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,14 @@ # CatLearn -CatLearn utilizes machine learning in the form of the Gaussian Process or Student T process to accelerate catalysis simulations. +CatLearn utilizes machine learning, specifically the Gaussian Process or Student T process, to accelerate catalysis simulations. The local optimization of a structure is accelerated with the `LocalAL` code. -The Nudged-elastic-band method (NEB) is accelerated with `MLNEB` code. +The Nudged-elastic-band method (NEB) is accelerated with the `MLNEB` code. Furthermore, a global adsorption search without local relaxation is accelerated with the `AdsorptionAL` code. Additionally, a global adsorption search with local relaxation is accelerated with the `MLGO` code. +At last, a random sampling of adsorbate positions, combined with local relaxation, accelerates the global adsorption search with the `RandomAdsorptionAL` code. -CalLearn uses ASE to handle the atomic systems and the calculator interface to calculate the potential energy. +CalLearn uses ASE to handle atomic systems and the calculator interface to calculate the potential energy. ## Installation @@ -30,10 +31,16 @@ $ pip install git+https://github.com/avishart/CatLearn.git@v.x.x.x ## Usage The active learning class is generalized to work for any defined optimizer method for ASE `Atoms` structures. The optimization method is executed iteratively with a machine-learning calculator that is retrained for each iteration. The active learning converges when the uncertainty is low (`unc_convergence`) and the energy change is within `unc_convergence` or the maximum force is within the tolerance value set. -Predefined active learning methods are created: `LocalAL`, `MLNEB`, `AdsorptionAL`, and `MLGO`. +Predefined active learning methods are created: `LocalAL`, `MLNEB`, `AdsorptionAL`, `MLGO`, and `RandomAdsorptionAL`. -The outputs of the active learning are `predicted.traj`, `evaluated.traj`, `converged.traj`, `initial_struc.traj`, and `ml_summary.txt`. -The `predicted.traj` file contains the structures that the machine-learning calculator predicts after each optimization loop. The training data and ASE calculator evaluated structures are within `evaluated.traj` file. The converged structures calculated with the machine-learning calculator are saved in the `converged.traj` file. The initial structure(s) is/are saved into the `initial_struc.traj` file. The summary of the active learning is saved into a table in the `ml_summary.txt` file. +The outputs of the active learning are `predicted.traj`, `evaluated.traj`, `predicted_evaluated.traj`, `converged.traj`, `initial_struc.traj`, `ml_summary.txt`, and `ml_time.txt`: +* The `predicted.traj` file contains the structures that the machine-learning calculator predicts after each optimization loop. +* The training data and ASE calculator evaluated structures are within `evaluated.traj` file. +* The `predicted_evaluated.traj` file has the exact same structures as the `evaluated.traj` file, but with machine-learning predicted properties. +* The converged structures calculated with the machine-learning calculator are saved in the `converged.traj` file. +* The initial structure(s) is/are saved into the `initial_struc.traj` file. +* The summary of the active learning is saved into a table in the `ml_summary.txt` file. +* The time spent on structure evaluation, machine-learning training, and prediction at each iteration is stored in `ml_time.txt`. ### LocalAL The following code shows how to use `LocalAL`: @@ -76,7 +83,7 @@ import matplotlib.pyplot as plt from catlearn.tools.plot import plot_minimize fig, ax = plt.subplots() -plot_minimize("predicted.traj", "evaluated.traj", ax=ax) +plot_minimize("predicted_evaluated.traj", "evaluated.traj", ax=ax) plt.savefig('AL_minimization.png') plt.close() ``` @@ -186,7 +193,7 @@ bounds = np.array( [0.5, 1.0], [0.0, 2 * np.pi], [0.0, 2 * np.pi], - [0.5, 2 * np.pi], + [0.0, 2 * np.pi], ] ) @@ -237,7 +244,7 @@ bounds = np.array( [0.5, 1.0], [0.0, 2 * np.pi], [0.0, 2 * np.pi], - [0.5, 2 * np.pi], + [0.0, 2 * np.pi], ] ) @@ -268,3 +275,59 @@ mlgo.run( ``` The `MLGO` optimization can be visualized in the same way as the `LocalAL` optimization. + +### RandomAdsorptionAL +The following code shows how to use `RandomAdsorptionAL`: +```python +from catlearn.activelearning.randomadsorption import RandomAdsorptionAL +from ase.io import read +from ase.optimize import FIRE + +# Load the slab and the adsorbate +slab = read("slab.traj") +ads = read("adsorbate.traj") + +# Make the ASE calculator +calc = ... + +# Make the boundary conditions for the adsorbate +bounds = np.array( + [ + [0.0, 1.0], + [0.0, 1.0], + [0.5, 1.0], + [0.0, 2 * np.pi], + [0.0, 2 * np.pi], + [0.0, 2 * np.pi], + ] +) + +# Initialize MLGO +dyn = RandomAdsorptionAL( + slab=slab, + adsorbate=ads, + adsorbate2=None, + ase_calc=calc, + unc_convergence=0.02, + bounds=bounds, + n_random_draws=10, + use_initial_opt=False, + initial_fmax=0.2, + use_repulsive_check=True, + local_opt=FIRE, + local_opt_kwargs={}, + parallel_run=False, + min_data=3, + restart=False, + verbose=True +) +dyn.run( + fmax=0.05, + max_unc=0.30, + steps=100, + ml_steps=4000, +) + +``` + +The `RandomAdsorptionAL` optimization can be visualized in the same way as the `LocalAL` optimization. From 3d2c41a99af5be30b6cd623fbf96e53b8d0d130d Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Thu, 24 Jul 2025 14:58:20 +0200 Subject: [PATCH 152/194] Change default kappa value in AdsorptionAL --- catlearn/activelearning/adsorption.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/catlearn/activelearning/adsorption.py b/catlearn/activelearning/adsorption.py index 9bb77f62..c597b6de 100644 --- a/catlearn/activelearning/adsorption.py +++ b/catlearn/activelearning/adsorption.py @@ -328,7 +328,7 @@ def setup_default_mlcalc( baseline=BornRepulsionCalculator(), use_derivatives=True, calc_forces=False, - kappa=-3.0, + kappa=-1.0, **kwargs, ): from ..regression.gp.fingerprint import SortedInvDistances From 163982119fd784978117b01138d869eb12994455 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Thu, 24 Jul 2025 15:04:48 +0200 Subject: [PATCH 153/194] Minor correction to README --- README.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index e845b28a..e5c74da1 100644 --- a/README.md +++ b/README.md @@ -34,13 +34,13 @@ The active learning class is generalized to work for any defined optimizer metho Predefined active learning methods are created: `LocalAL`, `MLNEB`, `AdsorptionAL`, `MLGO`, and `RandomAdsorptionAL`. The outputs of the active learning are `predicted.traj`, `evaluated.traj`, `predicted_evaluated.traj`, `converged.traj`, `initial_struc.traj`, `ml_summary.txt`, and `ml_time.txt`: -* The `predicted.traj` file contains the structures that the machine-learning calculator predicts after each optimization loop. -* The training data and ASE calculator evaluated structures are within `evaluated.traj` file. -* The `predicted_evaluated.traj` file has the exact same structures as the `evaluated.traj` file, but with machine-learning predicted properties. -* The converged structures calculated with the machine-learning calculator are saved in the `converged.traj` file. -* The initial structure(s) is/are saved into the `initial_struc.traj` file. -* The summary of the active learning is saved into a table in the `ml_summary.txt` file. -* The time spent on structure evaluation, machine-learning training, and prediction at each iteration is stored in `ml_time.txt`. +- The `predicted.traj` file contains the structures that the machine-learning calculator predicts after each optimization loop. +- The training data and ASE calculator evaluated structures are within `evaluated.traj` file. +- The `predicted_evaluated.traj` file has the exact same structures as the `evaluated.traj` file, but with machine-learning predicted properties. +- The converged structures calculated with the machine-learning calculator are saved in the `converged.traj` file. +- The initial structure(s) is/are saved into the `initial_struc.traj` file. +- The summary of the active learning is saved into a table in the `ml_summary.txt` file. +- The time spent on structure evaluation, machine-learning training, and prediction at each iteration is stored in `ml_time.txt`. ### LocalAL The following code shows how to use `LocalAL`: From 8fa6274cc8630ff5b54040329bc96d9c2e8c7d2c Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Fri, 25 Jul 2025 09:03:05 +0200 Subject: [PATCH 154/194] Crucial debug in activelearning --- catlearn/activelearning/activelearning.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/catlearn/activelearning/activelearning.py b/catlearn/activelearning/activelearning.py index b79f91c8..2f29c85a 100644 --- a/catlearn/activelearning/activelearning.py +++ b/catlearn/activelearning/activelearning.py @@ -1113,7 +1113,7 @@ def find_next_candidates( "Run the method on the ML surrogate surface." # Convergence of the NEB method_converged = False - # If memeory is saved the method is only performed on one CPU + # Check if the method is running in parallel if not self.parallel_run and self.rank != 0: return None, method_converged # Check if the previous structure were better @@ -1540,7 +1540,7 @@ def ensure_candidate_not_in_database( **kwargs, ): "Ensure the candidate is not in database by perturb it." - # If memeory is saved the method is only performed on one CPU + # Check if the method is running in parallel if not self.parallel_run and self.rank != 0: return None # Ensure that the candidate is not already in the database @@ -1635,11 +1635,11 @@ def check_convergence(self, fmax, method_converged, **kwargs): e_dif = abs(self.energy_true - self.bests_data["energy"]) if e_dif > uci: converged = False - # Check the convergence - if converged: - self.copy_best_structures() # Broadcast convergence statement if MPI is used converged = broadcast(converged, root=0, comm=self.comm) + # Check the convergence + if converged: + self.copy_best_structures() return converged def copy_best_structures( @@ -1662,6 +1662,10 @@ def copy_best_structures( Returns: list of ASE Atoms objects: The best structures. """ + # Check if the method is running in parallel + if not self.parallel_run and self.rank != 0: + return self.best_structures + # Get the best structures with calculated properties self.best_structures = self.get_structures( get_all=get_all, properties=properties, From c8f68f5c6e9eb02b6c9984d9b4693857ae5e8567 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Fri, 25 Jul 2025 11:17:49 +0200 Subject: [PATCH 155/194] Change default values in RandomAdsorption --- catlearn/activelearning/randomadsorption.py | 4 ++-- catlearn/optimizer/randomadsorption.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/catlearn/activelearning/randomadsorption.py b/catlearn/activelearning/randomadsorption.py index b6fbf402..d98e99c3 100644 --- a/catlearn/activelearning/randomadsorption.py +++ b/catlearn/activelearning/randomadsorption.py @@ -23,8 +23,8 @@ def __init__( mlcalc=None, adsorbate2=None, bounds=None, - n_random_draws=50, - use_initial_opt=True, + n_random_draws=200, + use_initial_opt=False, initial_fmax=0.2, use_repulsive_check=True, repulsive_tol=0.1, diff --git a/catlearn/optimizer/randomadsorption.py b/catlearn/optimizer/randomadsorption.py index c029b8c7..35186f60 100644 --- a/catlearn/optimizer/randomadsorption.py +++ b/catlearn/optimizer/randomadsorption.py @@ -23,7 +23,7 @@ def __init__( adsorbate2=None, bounds=None, n_random_draws=50, - use_initial_opt=True, + use_initial_opt=False, initial_fmax=0.2, use_repulsive_check=True, repulsive_tol=0.1, From ed3d922cf670d99d2ae5076a2526f06495b6e0d5 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Fri, 25 Jul 2025 11:25:43 +0200 Subject: [PATCH 156/194] Update README for gp --- catlearn/regression/gp/README.md | 120 +++++++++++++++---------------- 1 file changed, 60 insertions(+), 60 deletions(-) diff --git a/catlearn/regression/gp/README.md b/catlearn/regression/gp/README.md index 26d4536b..c6be059b 100644 --- a/catlearn/regression/gp/README.md +++ b/catlearn/regression/gp/README.md @@ -1,95 +1,95 @@ # Gaussian Process Source Code -The Gaussian process class and the Student T process are implemented to use different classes for prior means, kernels, fingerprints, and hyperparameter fitter. The Gaussian process class itself that can be trained and predict with uncertainties. The derivatives of the targets can be used by using the True bool for the use_derivatives argument in the initialization of the Gaussian process. Furthermore, a noise correction can be added to the covariance matrix to always make it invertible by a True bool for the correction argument. The hyperparameters are in ln values due to robustness. The noise hyperparameter is the relative-noise hyperparameter (or noise-to-signal), which corresponds to a replacement of the noise hyperparameter divided with the prefactor hyperparameter defined as a new free hyperparameter. +The Gaussian process class and the Student T process are implemented to use different classes for prior means, kernels, fingerprints, and hyperparameter fitter. The Gaussian process class itself can be trained and used to predict with uncertainties. The derivatives of the targets can be used by using the True bool for the `use_derivatives` argument in the initialization of the Gaussian process. Furthermore, a noise correction can be added to the covariance matrix to always make it invertible by a True bool for the `use_correction` argument. The hyperparameters are in natural log-scale to enforce robustness. The noise hyperparameter is the relative-noise hyperparameter (or noise-to-signal), which corresponds to a replacement of the noise hyperparameter divided by the prefactor hyperparameter, defined as a new free hyperparameter. The Gaussian process class and the Student T process are imported from the models module. ## Baseline -The baseline class used for the Gaussian process is implemeted in the Baseline module. -The repulisive part of the Lennard-Jones potential as a baseline is implemeted as a baseline class. +The baseline class used for the Gaussian process is implemented in the `baseline` module. +The repulsive part of the Lennard-Jones potential is implemented as the `RepulsionCalculator` class. +The born repulsion with a cutoff at a scaled sum of covalent radii is implemented as the `BornRepulsionCalculator` class. +A Mie potential is also implemented as the `MieCalculator`class. ## HPBoundary ### Boundary conditions -The boundary classes used for constructing boundary conditions for the hyperparameters. +The boundary classes are used for constructing boundary conditions for the hyperparameters in the `hpboundary` module. ### Hptrans -A variable transformation of the hyperparameters is performed with Variable_Transformation class. The region of interest in hyperparameter space is enlarged without restricting any value. +A variable transformation of the hyperparameters is performed with the `VariableTransformation` class. The region of interest in hyperparameter space is enlarged without restricting any value. ## Calculator -The calculator module include the scripts needed for converging the Gaussian process to an ASE calculator. -The scripts are: -- mlmodel: MLModel is a class that calculate energies, forces, and uncertainties for ASE Atoms. -- mlcalc: MLCalculator is an ASE calculator class that uses the MLModel as calculator. -- database: Database is class that collects the atomic structures with their energies, forces, and fingerprints. -- database_reduction: Database_Reduction is a Database class that reduces the number of training structures. - -## Educated -The Educated_guess class make educated guesses of the MLE and boundary conditions of the hyperparameters. +The calculator module includes the scripts needed for converting the Gaussian process to an ASE calculator. +The modules are: +- `MLModel` is a class that calculates energies, forces, and uncertainties for ASE Atoms. +- `MLCalculator` is an ASE calculator class that uses the MLModel as a calculator. +- `BOCalculator` is like the `MLCalculator`, but the calculated potential energy is added together with the uncertainty times kappa. +- `Database` is a class that collects the atomic structures with their energies, forces, and fingerprints. +- `DatabaseReduction` is a `Database` class that reduces the number of training structures. ## Ensemble -The ensemble model uses multiple models that is trained and make an ensemble of their predictions in different ways. One way is the mean of the prediction another is the variance weighted predictions. The EnsembleClustering class uses one of the clustering algorithm from the clustering module to split the training data for the different machine learning models. +The ensemble model uses multiple models that are trained and make an ensemble of their predictions in different ways. One way is the mean of the prediction, and another is the variance-weighted prediction. The EnsembleClustering class uses one of the clustering algorithms from the clustering module to split the training data for the different machine learning models. The clustering algorithms are: -- K_means: The K-means++ clustering method that use the distances to the defined centroids. Each datapoints is assigned to each cluster. A training point can only be included in one cluster. -- K_means_number: This method uses distances similar to the K-means++ clustering method. However, the cluster are of the same size and the number of clusters are defined from the number of training points. A training point can be included in multiple clusters. -- K_means_auto: It is similar to K_means_number, but it uses a range of number of training points that the clusters include. A training point can be included in multiple clusters. -- DistanceClustering: It use predefined centroids where the data points are assigned to each cluster. A training point can only be included in one cluster. +- `K_means` is the K-means++ clustering method that uses the distances to the defined centroids. Each datapoint is assigned to each cluster. A training point can only be included in one cluster. +- `K_means_number`: This method uses distances similar to the K-means++ clustering method. However, the clusters are of the same size, and the number of clusters is defined by the number of training points. A training point can be included in multiple clusters if the data points can not be split equally. +- `K_means_auto` is similar to `K_means_number`, but it uses a range of the number of training points that the clusters include. A training point can be included in multiple clusters. +- `K_means_enumeration` uses the training point in the order it is given. +- `FixedClustering` uses predefined centroids where the data points are assigned to each cluster. A training point can only be included in one cluster. +- `RandomClustering` randomly places training points in the given number of clusters. +- `RandomClustering_number` randomly places training points in clusters so that they match the number of points in each cluster as requested. ## Fingerprint -The Fingerprint class convert ASE Atoms into a FingerprintObject class. The FingerprintObject contain a fingerprint vector and derivatives with respect to the Cartesian coordinates. The Fingerprint class has children of different fingerprints: -- cartesian: Cartesian coordinates of the ASE Atoms in the order of the Atoms index. -- coulomb: The Coulomb matrix fingerprint. -- fpwrapper: A wrapper of fingerprints from ASE-GPATOM to the FingerprintObject class. -- invdistances: The inverse distance of all element combinations, where the blocks of each combination is sorted with magnitude. The inverse distances is scaled with the sum of the elements covalent radii. -- sumdistances: The summed inverse distances for each element combinations scaled with elements covalent radii. -- sumdistancespower: The summed inverse distances for each element combinations scaled with elements covalent radii to different orders. +The Fingerprint class converts ASE Atoms into a `FingerprintObject` instance. The `FingerprintObject` contains a fingerprint vector and derivatives with respect to the Cartesian coordinates. The Fingerprint class has child classes with different fingerprints, which are: +- `Cartesian` is the Cartesian coordinates of the ASE Atoms in the order of the atom index. +- `InvDistances` is the inverse distance of all atom combinations. The inverse distances are scaled with the sum of the elements' covalent radii. +- `SortedInvDistances` is the inverse distances of all element combinations where the blocks of each combination are sorted by magnitude. +- `SumDistances` is the summed inverse distances for each element combination, scaled with the sum of the elements' covalent radii. +- `SumDistancesPower` is the summed inverse distances for each element combination, scaled with the sum of the elements' covalent radii to different orders. +- `MeanDistances` is the mean inverse distances for each element combination, scaled with the sum of the elements' covalent radii. +- `MeanDistancesPower` is the mean inverse distances for each element combination, scaled with the sum of the elements' covalent radii to different orders. +- `FingerprintWrapperGPAtom` is a wrapper of fingerprints from ASE-GPATOM to the `FingerprintObject` instance. +- `FingerprintWrapperDScribe` is a wrapper of fingerprints from DScribe to the `FingerprintObject` instance. ## Hpfitter -The hyperparameter fitter class that optimize the hyperparameters of the Gaussian process. The hyperparameter fitter needs an objective function and a optimization method as arguments. -A fully-Bayesian mimicking Gaussian process can be achived by the fbpmgp class. +The hyperparameter fitter class that optimizes the hyperparameters of the Gaussian process. The hyperparameter fitter needs an objective function and an optimization method as arguments. +A fully Bayesian mimicking Gaussian process can be achieved by the `FBPMGP` class. ## Kernel -The kernel function is a fundamental part of the Gaussian process. The kernel function uses a distance meassure. -The Distance_matrix class construct a distance matrix of the features that can be used by the kernel function. The Distance_matrix_per_dimension class is used when derivatives of the targets are used, since the distances in each feature dimension needs to be saved. -A parent Kernel class is defined when only targets are used. The Kernel_Derivative class is used when derivatives of the targets are needed. +The kernel function is a fundamental part of the Gaussian process. Derivatives of the kernel function can be used by setting the `use_derivatives` argument. An implemented kernel function is the squared exponential kernel (SE) class. ## Means -In the means module different prior mean classes is defined. The prior mean is a key part of the Gaussian process. Constant value prior means classes is implemented as the parent Prior_constant class in constant submodule. The implemented children prior means classes are: -- first: Use the value of the first target. -- max: Use the value of the target with the largest value. -- mean: Use the mean value of the targets. -- median: Use the median value of the targets. -- min: Use the value of the target with the smallest value. +In the means module, different prior mean classes are defined. The prior mean is a key part of the Gaussian process. Constant value prior means classes are implemented as the parent `Prior_constant` class in the constant submodule. The implemented child prior means classes are: +- `Prior_first` uses the value of the first target. +- `Prior_max` uses the value of the target with the largest value. +- `Prior_mean` uses the mean value of the targets. +- `Prior_median` uses the median value of the targets. +- `Prior_min` uses the value of the target with the smallest value. ## Models -The Gaussian process and the Student t process is imported from this module. +The Gaussian process and the Student t process are imported from this module. ## Objectivefunctions -The parent Object_functions class give the form of the objective functions used for optimizing the hyperparameters. The implemented children objective function classes are split into Gaussian process and Student t process objective functions. +The parent `ObjectiveFuction` class gives the form of the objective functions used for optimizing the hyperparameters. The implemented child objective function classes are split into Gaussian process and Student t process objective functions. ### GP The Gaussian process objective functions are: -- factorized_gpp: Calculate the minimum of the GPP objective function value over all relative-noise hyperparameter values for each length-scale hyperparameter. The prefactor hyperparameter is determined analytically. -- factorized_likelihood_svd: Calculate the minimum of the negative log-likelihood objective function value over all relative-noise hyperparameter values for each length-scale hyperparameter. The prefactor hyperparameter is determined analytically by maximum-likelihood-estimation. SVD is used for finding the singular values and therefore a noise correction is not needed and the inversion is robust. -- factorized_likelihood: Calculate the minimum of the negative log-likelihood objective function value over all relative-noise hyperparameter values for each length-scale hyperparameter. The prefactor hyperparameter is determined analytically by maximum-likelihood-estimation. -- gpe: The GPE objective function is calculated. -- gpp: The GPP objective function is calculated. -- likelihood: The negative log-likelihood is calculated. -- loo: The leave-one-out cross-validation from a single covariance matrix inversion is calculated. A modification can be used to also get good values for the prefactor hyperparameter. -- mle: The negative maximum log-likelihood is calculated by using an analutically expression of the prefactor hyperparameter. +- `LogLikelihood` is the negative log-likelihood. +- `MaximumLogLikelihood` is the negative maximum log-likelihood calculated by using an analytical expression of the prefactor hyperparameter. +- `FactorizedGPP` calculates the minimum of the GPP objective function value over all relative-noise hyperparameter values for each length-scale hyperparameter. The prefactor hyperparameter is determined analytically. +- `FactorizedLogLikelihood` calculates the minimum of the negative log-likelihood objective function value over all relative-noise hyperparameter values for each length-scale hyperparameter. The prefactor hyperparameter is determined analytically by maximum-likelihood estimation. +- `GPE` is Geisser's predictive mean square error objective function. +- `GPP` is Geisser's surrogate predictive probability objective function. +- `LOO` is the leave-one-out cross-validation from a single covariance matrix inversion is calculated. A modification can also be used to get good values for the prefactor hyperparameter. ### TP -- factorized_likelihood_svd: Calculate the minimum of the negative log-likelihood objective function value over all relative-noise hyperparameter values for each length-scale hyperparameter. SVD is used for finding the singular values and therefore a noise correction is not needed and the inversion is robust. -- factorized_likelihood: Calculate the minimum of the negative log-likelihood objective function value over all relative-noise hyperparameter values for each length-scale hyperparameter. -- likelihood: The negative log-likelihood is calculated. +- `LogLikelihood` is the negative log-likelihood. +- `FactorizedLogLikelihood` calculates the minimum of the negative log-likelihood objective function value over all relative-noise hyperparameter values for each length-scale hyperparameter. ## Optimizers -Different optimizers can be used for optimizing the hyperparameters of the Gaussian process. The optimizers are split into local and global optimizers. +Different optimizers can be used for optimizing the hyperparameters of the Gaussian process. The optimizers are split into local, global, line search, and noise line search optimizers. ## Pdistributions -Prior distributions for the hyperparameters can be applied to the objective function. Thereby, the log-posterior is maximized instead of the log-likelihood. The prior distributions are important for the optimization of hyperparameters, since it gives prior knowledge about decent hyperparameters. The hyperparameter values are in log-scale. -The parent prior distribution class is Prior_distribution in pdistributions. The children classes are: -- gamma: The gamma distribution. -- gen_normal: The generalized normal distribution. -- invgamma: The inverse-gamma distribution. -- normal: The normal distribution. -- uniform: The uniform prior distribution within an interval. - - +Prior distributions for the hyperparameters can be applied to the objective function. Thereby, the log-posterior is maximized instead of the log-likelihood. The prior distributions are important for the optimization of hyperparameters, since they give prior knowledge about decent hyperparameters. The hyperparameter values are on a natural log-scale. +The parent prior distribution class is the `Prior_distribution` in the `pdistributions`module. The child classes are: +- `Gamma_prior` is the gamma distribution. +- `Gen_normal_prior` is the generalized normal distribution. +- `Invgamma_prior` is the inverse-gamma distribution. +- `Normal_prior` is the normal distribution. +- `Uniform_prior` is the uniform prior distribution within an interval. From 93d146ae39c7a8f0eac64f49c99cd7656df76955 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Fri, 25 Jul 2025 13:08:11 +0200 Subject: [PATCH 157/194] Update and use rng seeds for tests --- tests/test_adsorption.py | 16 +-- tests/test_local.py | 11 +- tests/test_mlgo.py | 17 +-- tests/test_mlneb.py | 220 +++++++++++++++++++-------------- tests/test_randomadsorption.py | 143 +++++++++++++++++++++ 5 files changed, 296 insertions(+), 111 deletions(-) create mode 100644 tests/test_randomadsorption.py diff --git a/tests/test_adsorption.py b/tests/test_adsorption.py index 113f2607..4d9207c0 100644 --- a/tests/test_adsorption.py +++ b/tests/test_adsorption.py @@ -13,6 +13,8 @@ def test_adsorption_init(self): from catlearn.activelearning.adsorption import AdsorptionAL from ase.calculators.emt import EMT + # Set random seed to give the same results every time + seed = 1 # Get the initial and final states slab, ads = get_slab_ads() # Make the boundary conditions for the global search @@ -35,7 +37,7 @@ def test_adsorption_init(self): bounds=bounds, min_data=4, verbose=False, - tabletxt=None, + seed=seed, ) def test_adsorption_run(self): @@ -44,21 +46,21 @@ def test_adsorption_run(self): from catlearn.activelearning.adsorption import AdsorptionAL from ase.calculators.emt import EMT + # Set random seed to give the same results every time + seed = 1 # Get the initial and final states slab, ads = get_slab_ads() # Make the boundary conditions for the global search bounds = np.array( [ - [0.0, 1.0], - [0.0, 1.0], + [0.0, 0.5], + [0.0, 0.5], [0.5, 0.95], [0.0, 2 * np.pi], [0.0, 2 * np.pi], [0.0, 2 * np.pi], ] ) - # Set random seed - np.random.seed(1) # Initialize Adsorption AL ads_al = AdsorptionAL( slab=slab, @@ -68,14 +70,14 @@ def test_adsorption_run(self): bounds=bounds, min_data=4, verbose=False, - tabletxt=None, + seed=seed, ) # Test if the Adsorption AL can be run ads_al.run( fmax=0.05, steps=50, max_unc=0.050, - ml_steps=500, + ml_steps=4000, ) # Check that Adsorption AL converged self.assertTrue(ads_al.converged() is True) diff --git a/tests/test_local.py b/tests/test_local.py index c5d0818c..93bf0e55 100644 --- a/tests/test_local.py +++ b/tests/test_local.py @@ -1,5 +1,4 @@ import unittest -import numpy as np from .functions import get_endstructures, check_fmax @@ -14,6 +13,8 @@ def test_local_init(self): from catlearn.activelearning.local import LocalAL from ase.calculators.emt import EMT + # Set random seed to give the same results every time + seed = 1 # Get the atoms from initial and final states atoms, _ = get_endstructures() # Move the gold atom up to prepare optimization @@ -21,8 +22,6 @@ def test_local_init(self): pos[-1, 2] += 0.5 atoms.set_positions(pos) atoms.get_forces() - # Set random seed - np.random.seed(1) # Initialize Local AL optimization LocalAL( atoms=atoms, @@ -31,6 +30,7 @@ def test_local_init(self): use_restart=True, check_unc=True, verbose=False, + seed=seed, ) def test_local_run(self): @@ -38,6 +38,8 @@ def test_local_run(self): from catlearn.activelearning.local import LocalAL from ase.calculators.emt import EMT + # Set random seed to give the same results every time + seed = 1 # Get the atoms from initial and final states atoms, _ = get_endstructures() # Move the gold atom up to prepare optimization @@ -45,8 +47,6 @@ def test_local_run(self): pos[-1, 2] += 0.5 atoms.set_positions(pos) atoms.get_forces() - # Set random seed - np.random.seed(1) # Initialize Local AL optimization local_al = LocalAL( atoms=atoms, @@ -55,6 +55,7 @@ def test_local_run(self): use_restart=True, check_unc=True, verbose=False, + seed=seed, ) # Test if the Local AL optimization can be run local_al.run( diff --git a/tests/test_mlgo.py b/tests/test_mlgo.py index cc99220e..0aa4eb53 100644 --- a/tests/test_mlgo.py +++ b/tests/test_mlgo.py @@ -13,6 +13,8 @@ def test_mlgo_init(self): from catlearn.activelearning.mlgo import MLGO from ase.calculators.emt import EMT + # Set random seed to give the same results every time + seed = 1 # Get the initial and final states slab, ads = get_slab_ads() # Make the boundary conditions for the global search @@ -36,7 +38,7 @@ def test_mlgo_init(self): min_data=4, verbose=False, local_opt_kwargs=dict(logfile=None), - tabletxt=None, + seed=seed, ) def test_mlgo_run(self): @@ -45,21 +47,21 @@ def test_mlgo_run(self): from catlearn.activelearning.mlgo import MLGO from ase.calculators.emt import EMT + # Set random seed to give the same results every time + seed = 1 # Get the initial and final states slab, ads = get_slab_ads() # Make the boundary conditions for the global search bounds = np.array( [ - [0.0, 1.0], - [0.0, 1.0], + [0.0, 0.5], + [0.0, 0.5], [0.5, 0.95], [0.0, 2 * np.pi], [0.0, 2 * np.pi], [0.0, 2 * np.pi], ] ) - # Set random seed - np.random.seed(1) # Initialize MLGO mlgo = MLGO( slab=slab, @@ -70,14 +72,15 @@ def test_mlgo_run(self): min_data=4, verbose=False, local_opt_kwargs=dict(logfile=None), - tabletxt=None, + seed=seed, ) # Test if the MLGO can be run mlgo.run( fmax=0.05, steps=50, max_unc=0.050, - ml_steps=500, + ml_steps=4000, + ml_steps_local=1000, ) # Check that MLGO converged self.assertTrue(mlgo.converged() is True) diff --git a/tests/test_mlneb.py b/tests/test_mlneb.py index c47aeaa3..bbe0a17e 100644 --- a/tests/test_mlneb.py +++ b/tests/test_mlneb.py @@ -1,5 +1,4 @@ import unittest -import numpy as np from .functions import get_endstructures, check_image_fmax @@ -13,10 +12,10 @@ def test_mlneb_init(self): from catlearn.activelearning.mlneb import MLNEB from ase.calculators.emt import EMT + # Set random seed to give the same results every time + seed = 1 # Get the initial and final states initial, final = get_endstructures() - # Set random seed - np.random.seed(1) # Initialize MLNEB MLNEB( start=initial, @@ -28,6 +27,7 @@ def test_mlneb_init(self): use_restart=True, check_unc=True, verbose=False, + seed=seed, ) def test_mlneb_run(self): @@ -35,10 +35,10 @@ def test_mlneb_run(self): from catlearn.activelearning.mlneb import MLNEB from ase.calculators.emt import EMT + # Set random seed to give the same results every time + seed = 1 # Get the initial and final states initial, final = get_endstructures() - # Set random seed - np.random.seed(1) # Initialize MLNEB mlneb = MLNEB( start=initial, @@ -51,46 +51,7 @@ def test_mlneb_run(self): check_unc=True, verbose=False, local_opt_kwargs=dict(logfile=None), - tabletxt=None, - ) - # Test if the MLNEB can be run - mlneb.run( - fmax=0.05, - steps=50, - ml_steps=250, - max_unc=0.05, - ) - # Check that MLNEB converged - self.assertTrue(mlneb.converged() is True) - # Check that MLNEB gives a saddle point - images = mlneb.get_best_structures() - self.assertTrue(check_image_fmax(images, EMT(), fmax=0.05)) - - def test_mlneb_run_idpp(self): - """ - Test if the MLNEB can run and converge with - restart of path from IDPP. - """ - from catlearn.activelearning.mlneb import MLNEB - from ase.calculators.emt import EMT - - # Get the initial and final states - initial, final = get_endstructures() - # Set random seed - np.random.seed(1) - # Initialize MLNEB - mlneb = MLNEB( - start=initial, - end=final, - ase_calc=EMT(), - neb_interpolation="idpp", - n_images=11, - unc_convergence=0.05, - use_restart=True, - check_unc=True, - verbose=False, - local_opt_kwargs=dict(logfile=None), - tabletxt=None, + seed=seed, ) # Test if the MLNEB can be run mlneb.run( @@ -113,13 +74,13 @@ def test_mlneb_run_path(self): from catlearn.activelearning.mlneb import MLNEB from ase.calculators.emt import EMT + # Set random seed to give the same results every time + seed = 1 # Get the initial and final states initial, final = get_endstructures() - interpolations = ["idpp", "rep", "ends"] + interpolations = ["born", "ends", "idpp", "rep"] for interpolation in interpolations: with self.subTest(interpolation=interpolation): - # Set random seed - np.random.seed(1) # Initialize MLNEB mlneb = MLNEB( start=initial, @@ -132,7 +93,7 @@ def test_mlneb_run_path(self): check_unc=True, verbose=False, local_opt_kwargs=dict(logfile=None), - tabletxt=None, + seed=seed, ) # Test if the MLNEB can be run mlneb.run( @@ -152,10 +113,10 @@ def test_mlneb_run_norestart(self): from catlearn.activelearning.mlneb import MLNEB from ase.calculators.emt import EMT + # Set random seed to give the same results every time + seed = 1 # Get the initial and final states initial, final = get_endstructures() - # Set random seed - np.random.seed(1) # Initialize MLNEB mlneb = MLNEB( start=initial, @@ -167,7 +128,7 @@ def test_mlneb_run_norestart(self): use_restart=False, verbose=False, local_opt_kwargs=dict(logfile=None), - tabletxt=None, + seed=seed, ) # Test if the MLNEB can be run mlneb.run( @@ -187,10 +148,10 @@ def test_mlneb_run_savememory(self): from catlearn.activelearning.mlneb import MLNEB from ase.calculators.emt import EMT + # Set random seed to give the same results every time + seed = 1 # Get the initial and final states initial, final = get_endstructures() - # Set random seed - np.random.seed(1) # Initialize MLNEB mlneb = MLNEB( start=initial, @@ -204,7 +165,7 @@ def test_mlneb_run_savememory(self): save_memory=True, verbose=False, local_opt_kwargs=dict(logfile=None), - tabletxt=None, + seed=seed, ) # Test if the MLNEB can be run mlneb.run( @@ -224,10 +185,10 @@ def test_mlneb_run_no_maxunc(self): from catlearn.activelearning.mlneb import MLNEB from ase.calculators.emt import EMT + # Set random seed to give the same results every time + seed = 1 # Get the initial and final states initial, final = get_endstructures() - # Set random seed - np.random.seed(1) # Initialize MLNEB mlneb = MLNEB( start=initial, @@ -240,7 +201,7 @@ def test_mlneb_run_no_maxunc(self): check_unc=True, verbose=False, local_opt_kwargs=dict(logfile=None), - tabletxt=None, + seed=seed, ) # Test if the MLNEB can be run mlneb.run( @@ -255,42 +216,117 @@ def test_mlneb_run_no_maxunc(self): images = mlneb.get_best_structures() self.assertTrue(check_image_fmax(images, EMT(), fmax=0.05)) + def test_mlneb_run_dtrust(self): + "Test if the MLNEB can run and converge when it use a trust distance." + from catlearn.activelearning.mlneb import MLNEB + from ase.calculators.emt import EMT -def test_mlneb_run_dtrust(self): - "Test if the MLNEB can run and converge when it use a trust distance." - from catlearn.activelearning.mlneb import MLNEB - from ase.calculators.emt import EMT + # Set random seed to give the same results every time + seed = 1 + # Get the initial and final states + initial, final = get_endstructures() + # Initialize MLNEB + mlneb = MLNEB( + start=initial, + end=final, + ase_calc=EMT(), + neb_interpolation="linear", + n_images=11, + unc_convergence=0.05, + use_restart=True, + check_unc=True, + verbose=False, + local_opt_kwargs=dict(logfile=None), + seed=seed, + ) + # Test if the MLNEB can be run + mlneb.run( + fmax=0.05, + steps=50, + ml_steps=250, + dtrust=0.5, + ) + # Check that MLNEB converged + self.assertTrue(mlneb.converged() is True) + # Check that MLNEB gives a saddle point + images = mlneb.get_best_structures() + self.assertTrue(check_image_fmax(images, EMT(), fmax=0.05)) - # Get the initial and final states - initial, final = get_endstructures() - # Set random seed - np.random.seed(1) - # Initialize MLNEB - mlneb = MLNEB( - start=initial, - end=final, - ase_calc=EMT(), - neb_interpolation="linear", - n_images=11, - unc_convergence=0.05, - use_restart=True, - check_unc=True, - verbose=False, - local_opt_kwargs=dict(logfile=None), - tabletxt=None, - ) - # Test if the MLNEB can be run - mlneb.run( - fmax=0.05, - steps=50, - ml_steps=250, - dtrust=0.5, - ) - # Check that MLNEB converged - self.assertTrue(mlneb.converged() is True) - # Check that MLNEB gives a saddle point - images = mlneb.get_best_structures() - self.assertTrue(check_image_fmax(images, EMT(), fmax=0.05)) + def test_mlneb_run_start_with_ci(self): + """ + Test if the MLNEB can run and converge without starting + without climbing image. + """ + from catlearn.activelearning.mlneb import MLNEB + from ase.calculators.emt import EMT + + # Set random seed to give the same results every time + seed = 1 + # Get the initial and final states + initial, final = get_endstructures() + # Initialize MLNEB + mlneb = MLNEB( + start=initial, + end=final, + ase_calc=EMT(), + neb_interpolation="linear", + start_without_ci=False, + n_images=11, + unc_convergence=0.05, + use_restart=True, + check_unc=True, + verbose=False, + local_opt_kwargs=dict(logfile=None), + seed=seed, + ) + # Test if the MLNEB can be run + mlneb.run( + fmax=0.05, + steps=50, + ml_steps=250, + max_unc=0.05, + ) + # Check that MLNEB converged + self.assertTrue(mlneb.converged() is True) + # Check that MLNEB gives a saddle point + images = mlneb.get_best_structures() + self.assertTrue(check_image_fmax(images, EMT(), fmax=0.05)) + + def test_mlneb_run_no_ci(self): + """ + Test if the MLNEB can run and converge without climbing image. + """ + from catlearn.activelearning.mlneb import MLNEB + from ase.calculators.emt import EMT + + # Set random seed to give the same results every time + seed = 1 + # Get the initial and final states + initial, final = get_endstructures() + # Initialize MLNEB + mlneb = MLNEB( + start=initial, + end=final, + ase_calc=EMT(), + neb_interpolation="linear", + n_images=11, + climb=False, + unc_convergence=0.05, + use_restart=True, + check_unc=True, + verbose=False, + local_opt_kwargs=dict(logfile=None), + seed=seed, + ) + # Test if the MLNEB can be run + mlneb.run( + fmax=0.05, + steps=50, + ml_steps=250, + max_unc=0.05, + ) + # Check that MLNEB converged + self.assertTrue(mlneb.converged() is True) if __name__ == "__main__": diff --git a/tests/test_randomadsorption.py b/tests/test_randomadsorption.py new file mode 100644 index 00000000..dbdf9428 --- /dev/null +++ b/tests/test_randomadsorption.py @@ -0,0 +1,143 @@ +import unittest +from .functions import get_slab_ads, check_fmax + + +class TestRandomAdsorption(unittest.TestCase): + """ + Test if the RandomAdsorption works and give the right output. + """ + + def test_randomadsorption_init(self): + "Test if the RandomAdsorption can be initialized." + import numpy as np + from catlearn.activelearning import RandomAdsorptionAL + from ase.calculators.emt import EMT + + # Set random seed to give the same results every time + seed = 1 + # Get the initial and final states + slab, ads = get_slab_ads() + # Make the boundary conditions for the global search + bounds = np.array( + [ + [0.0, 1.0], + [0.0, 1.0], + [0.5, 0.95], + [0.0, 2 * np.pi], + [0.0, 2 * np.pi], + [0.0, 2 * np.pi], + ] + ) + # Initialize RandomAdsorption AL + RandomAdsorptionAL( + slab=slab, + adsorbate=ads, + ase_calc=EMT(), + unc_convergence=0.025, + bounds=bounds, + min_data=4, + verbose=False, + seed=seed, + ) + + def test_randomadsorption_run(self): + "Test if the RandomAdsorption can run and converge." + import numpy as np + from catlearn.activelearning import RandomAdsorptionAL + from ase.calculators.emt import EMT + + # Set random seed to give the same results every time + seed = 1 + # Get the initial and final states + slab, ads = get_slab_ads() + # Make the boundary conditions for the global search + bounds = np.array( + [ + [0.0, 0.5], + [0.0, 0.5], + [0.5, 0.95], + [0.0, 2 * np.pi], + [0.0, 2 * np.pi], + [0.0, 2 * np.pi], + ] + ) + # Initialize RandomAdsorption AL + ads_al = RandomAdsorptionAL( + slab=slab, + adsorbate=ads, + ase_calc=EMT(), + n_random_draws=20, + use_initial_opt=True, + initial_fmax=0.2, + unc_convergence=0.025, + bounds=bounds, + min_data=4, + verbose=False, + seed=seed, + ) + # Test if the RandomAdsorption AL can be run + ads_al.run( + fmax=0.05, + steps=50, + max_unc=0.3, + ml_steps=5000, + ) + # Check that RandomAdsorption AL converged + self.assertTrue(ads_al.converged() is True) + # Check that RandomAdsorption AL give a minimum + atoms = ads_al.get_best_structures() + self.assertTrue(check_fmax(atoms, EMT(), fmax=0.05)) + + def test_randomadsorption_run_no_initial_opt(self): + """ + Test if the RandomAdsorption without initial optimization + can run and converge. + """ + import numpy as np + from catlearn.activelearning import RandomAdsorptionAL + from ase.calculators.emt import EMT + + # Set random seed to give the same results every time + seed = 1 + # Get the initial and final states + slab, ads = get_slab_ads() + # Make the boundary conditions for the global search + bounds = np.array( + [ + [0.0, 0.5], + [0.0, 0.5], + [0.5, 0.95], + [0.0, 2 * np.pi], + [0.0, 2 * np.pi], + [0.0, 2 * np.pi], + ] + ) + # Initialize RandomAdsorption AL + ads_al = RandomAdsorptionAL( + slab=slab, + adsorbate=ads, + ase_calc=EMT(), + n_random_draws=50, + use_initial_opt=False, + unc_convergence=0.025, + bounds=bounds, + min_data=4, + verbose=False, + seed=seed, + ) + # Test if the RandomAdsorption AL can be run + ads_al.run( + fmax=0.05, + steps=50, + max_unc=0.3, + ml_steps=5000, + ) + # Check that RandomAdsorption AL converged + self.assertTrue(ads_al.converged() is True) + # Check that RandomAdsorption AL give a minimum + atoms = ads_al.get_best_structures() + self.assertTrue(check_fmax(atoms, EMT(), fmax=0.05)) + + +if __name__ == "__main__": + unittest.main() From 64db0352456eff28b08bf5f99916d7faf6a6c637 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Fri, 25 Jul 2025 14:06:51 +0200 Subject: [PATCH 158/194] Split the default model, mlmodel, and database into a new file --- catlearn/activelearning/activelearning.py | 2 +- catlearn/regression/gp/calculator/__init__.py | 4 +- .../regression/gp/calculator/default_model.py | 505 ++++++++++++++++++ catlearn/regression/gp/calculator/mlmodel.py | 503 +---------------- catlearn/regression/gp/ensemble/ensemble.py | 2 +- 5 files changed, 510 insertions(+), 506 deletions(-) create mode 100644 catlearn/regression/gp/calculator/default_model.py diff --git a/catlearn/activelearning/activelearning.py b/catlearn/activelearning/activelearning.py index 2f29c85a..77ab92db 100644 --- a/catlearn/activelearning/activelearning.py +++ b/catlearn/activelearning/activelearning.py @@ -523,7 +523,7 @@ def setup_default_mlcalc( self: The object itself. """ # Create the ML calculator - from ..regression.gp.calculator.mlmodel import get_default_mlmodel + from ..regression.gp.calculator.default_model import get_default_mlmodel from ..regression.gp.calculator.mlcalc import MLCalculator from ..regression.gp.fingerprint.invdistances import InvDistances diff --git a/catlearn/regression/gp/calculator/__init__.py b/catlearn/regression/gp/calculator/__init__.py index a1d51992..9ad1dc99 100644 --- a/catlearn/regression/gp/calculator/__init__.py +++ b/catlearn/regression/gp/calculator/__init__.py @@ -11,8 +11,8 @@ DatabasePointsInterest, DatabasePointsInterestEach, ) -from .mlmodel import ( - MLModel, +from .mlmodel import MLModel +from .default_model import ( get_default_model, get_default_database, get_default_mlmodel, diff --git a/catlearn/regression/gp/calculator/default_model.py b/catlearn/regression/gp/calculator/default_model.py new file mode 100644 index 00000000..4610c0a7 --- /dev/null +++ b/catlearn/regression/gp/calculator/default_model.py @@ -0,0 +1,505 @@ +import warnings + + +def get_default_model( + model="tp", + prior="median", + use_derivatives=True, + use_fingerprint=False, + global_optimization=True, + parallel=False, + n_reduced=None, + round_hp=3, + seed=None, + dtype=float, + model_kwargs={}, + prior_kwargs={}, + kernel_kwargs={}, + hpfitter_kwargs={}, + optimizer_kwargs={}, + lineoptimizer_kwargs={}, + function_kwargs={}, + **kwargs, +): + """ + Get the default ML model from the simple given arguments. + + Parameters: + model: str + Either the tp that gives the Studen T process or + gp that gives the Gaussian process. + prior: str + Specify what prior mean should be used. + use_derivatives: bool + Whether to use derivatives of the targets. + use_fingerprint: bool + Whether to use fingerprints for the features. + This has to be the same as for the database! + global_optimization: bool + Whether to perform a global optimization of the hyperparameters. + A local optimization is used if global_optimization=False, + which can not be parallelized. + parallel: bool + Whether to optimize the hyperparameters in parallel. + n_reduced: int or None + If n_reduced is an integer, the hyperparameters are only optimized + when the data set size is equal to or below the integer. + If n_reduced is None, the hyperparameter is always optimized. + round_hp: int (optional) + The number of decimals to round the hyperparameters to. + If None, the hyperparameters are not rounded. + seed: int (optional) + The random seed for the optimization. + The seed an also be a RandomState or Generator instance. + If not given, the default random number generator is used. + dtype: type + The data type of the arrays. + model_kwargs: dict (optional) + The keyword arguments for the model. + The additional arguments are passed to the model. + prior_kwargs: dict (optional) + The keyword arguments for the prior mean. + kernel_kwargs: dict (optional) + The keyword arguments for the kernel. + hpfitter_kwargs: dict (optional) + The keyword arguments for the hyperparameter fitter. + optimizer_kwargs: dict (optional) + The keyword arguments for the optimizer. + lineoptimizer_kwargs: dict (optional) + The keyword arguments for the line optimizer. + function_kwargs: dict (optional) + The keyword arguments for the objective function. + + Returns: + model: Model + The Machine Learning Model with kernel and + prior that are optimized. + """ + # Check that the model is given as a string + if not isinstance(model, str): + return model + # Make the prior mean from given string + if isinstance(prior, str): + from ..means import Prior_median, Prior_mean, Prior_min, Prior_max + + if prior.lower() == "median": + prior = Prior_median(**prior_kwargs) + elif prior.lower() == "mean": + prior = Prior_mean(**prior_kwargs) + elif prior.lower() == "min": + prior = Prior_min(**prior_kwargs) + elif prior.lower() == "max": + prior = Prior_max(**prior_kwargs) + # Construct the kernel class object + from ..kernel.se import SE + + kernel = SE( + use_fingerprint=use_fingerprint, + use_derivatives=use_derivatives, + dtype=dtype, + **kernel_kwargs, + ) + # Set the hyperparameter optimization method + if global_optimization: + # Set global optimization with or without parallelization + from ..optimizers.globaloptimizer import FactorizedOptimizer + + # Set the line searcher for the hyperparameter optimization + if parallel: + from ..optimizers.linesearcher import FineGridSearch + + lineoptimizer_kwargs_default = dict( + optimize=True, + multiple_min=False, + ngrid=80, + loops=3, + ) + lineoptimizer_kwargs_default.update(lineoptimizer_kwargs) + line_optimizer = FineGridSearch( + parallel=True, + dtype=dtype, + **lineoptimizer_kwargs_default, + ) + else: + from ..optimizers.linesearcher import GoldenSearch + + lineoptimizer_kwargs_default = dict( + optimize=True, + multiple_min=False, + ) + lineoptimizer_kwargs_default.update(lineoptimizer_kwargs) + line_optimizer = GoldenSearch( + parallel=False, + dtype=dtype, + **lineoptimizer_kwargs_default, + ) + # Set the optimizer for the hyperparameter optimization + optimizer_kwargs_default = dict( + ngrid=80, + calculate_init=False, + ) + optimizer_kwargs_default.update(optimizer_kwargs) + optimizer = FactorizedOptimizer( + line_optimizer=line_optimizer, + parallel=parallel, + dtype=dtype, + **optimizer_kwargs_default, + ) + else: + from ..optimizers.localoptimizer import ScipyOptimizer + + optimizer_kwargs_default = dict( + maxiter=500, + jac=True, + method="l-bfgs-b", + use_bounds=False, + tol=1e-12, + ) + optimizer_kwargs_default.update(optimizer_kwargs) + # Make the local optimizer + optimizer = ScipyOptimizer( + dtype=dtype, + **optimizer_kwargs_default, + ) + if parallel: + warnings.warn( + "Parallel optimization is not implemented" + "with local optimization!" + ) + # Use either the Student t process or the Gaussian process + model_kwargs.update(kwargs) + if model.lower() == "tp": + # Set model + from ..models.tp import TProcess + + model_kwargs_default = dict( + a=1e-4, + b=4.0, + ) + model_kwargs_default.update(model_kwargs) + model = TProcess( + prior=prior, + kernel=kernel, + use_derivatives=use_derivatives, + dtype=dtype, + **model_kwargs_default, + ) + # Set objective function + if global_optimization: + from ..objectivefunctions.tp.factorized_likelihood import ( + FactorizedLogLikelihood, + ) + + func = FactorizedLogLikelihood(dtype=dtype, **function_kwargs) + else: + from ..objectivefunctions.tp.likelihood import LogLikelihood + + func = LogLikelihood(dtype=dtype, **function_kwargs) + else: + # Set model + from ..models.gp import GaussianProcess + + model = GaussianProcess( + prior=prior, + kernel=kernel, + use_derivatives=use_derivatives, + dtype=dtype, + **model_kwargs, + ) + # Set objective function + if global_optimization: + from ..objectivefunctions.gp.factorized_likelihood import ( + FactorizedLogLikelihood, + ) + + func = FactorizedLogLikelihood(dtype=dtype, **function_kwargs) + else: + from ..objectivefunctions.gp.likelihood import LogLikelihood + + func = LogLikelihood(dtype=dtype, **function_kwargs) + # Set hpfitter and whether a maximum data set size is applied + if n_reduced is None: + from ..hpfitter import HyperparameterFitter + + hpfitter = HyperparameterFitter( + func=func, + optimizer=optimizer, + round_hp=round_hp, + dtype=dtype, + **hpfitter_kwargs, + ) + else: + from ..hpfitter.redhpfitter import ReducedHyperparameterFitter + + hpfitter = ReducedHyperparameterFitter( + func=func, + optimizer=optimizer, + opt_tr_size=n_reduced, + round_hp=round_hp, + dtype=dtype, + **hpfitter_kwargs, + ) + model.update_arguments(hpfitter=hpfitter) + # Set the seed for the model + if seed is not None: + model.set_seed(seed=seed) + # Return the model + return model + + +def get_default_database( + fp=None, + use_derivatives=True, + database_reduction=False, + round_targets=5, + seed=None, + dtype=float, + **database_kwargs, +): + """ + Get the default Database from the simple given arguments. + + Parameters: + fp: Fingerprint class object or None + The fingerprint object used to generate the fingerprints. + Cartesian coordinates are used if it is None. + use_derivatives: bool + Whether to use derivatives of the targets. + database_reduction: bool + Whether to used a reduced database after a number + of training points. + round_targets: int (optional) + The number of decimals to round the targets to. + If None, the targets are not rounded. + seed: int (optional) + The random seed for the optimization. + The seed an also be a RandomState or Generator instance. + If not given, the default random number generator is used. + dtype: type + The data type of the arrays. + database_kwargs: dict (optional) + A dictionary with additional arguments for the database. + Also used for the reduced databases. + + Returns: + database: Database object + The Database object with ASE atoms. + """ + # Set a fingerprint + if fp is None: + from ..fingerprint.cartesian import Cartesian + + # Use cartesian coordinates as the fingerprint + fp = Cartesian(reduce_dimensions=True, use_derivatives=use_derivatives) + use_fingerprint = False + else: + use_fingerprint = True + # Make the data base ready + if isinstance(database_reduction, str): + data_kwargs = dict( + fingerprint=fp, + reduce_dimensions=True, + use_derivatives=use_derivatives, + use_fingerprint=use_fingerprint, + round_targets=round_targets, + seed=seed, + dtype=dtype, + npoints=50, + initial_indices=[0, 1], + include_last=1, + ) + data_kwargs.update(database_kwargs) + if database_reduction.lower() == "distance": + from .database_reduction import DatabaseDistance + + database = DatabaseDistance(**data_kwargs) + elif database_reduction.lower() == "random": + from .database_reduction import DatabaseRandom + + database = DatabaseRandom(**data_kwargs) + elif database_reduction.lower() == "hybrid": + from .database_reduction import DatabaseHybrid + + database = DatabaseHybrid(**data_kwargs) + elif database_reduction.lower() == "min": + from .database_reduction import DatabaseMin + + database = DatabaseMin(**data_kwargs) + elif database_reduction.lower() == "last": + from .database_reduction import DatabaseLast + + database = DatabaseLast(**data_kwargs) + elif database_reduction.lower() == "restart": + from .database_reduction import DatabaseRestart + + database = DatabaseRestart(**data_kwargs) + elif database_reduction.lower() == "interest": + from .database_reduction import DatabasePointsInterest + + database = DatabasePointsInterest(**data_kwargs) + elif database_reduction.lower() == "each_interest": + from .database_reduction import DatabasePointsInterestEach + + database = DatabasePointsInterestEach(**data_kwargs) + else: + from .database import Database + + data_kwargs = dict( + reduce_dimensions=True, + ) + data_kwargs.update(database_kwargs) + database = Database( + fingerprint=fp, + use_derivatives=use_derivatives, + use_fingerprint=use_fingerprint, + round_targets=round_targets, + seed=seed, + dtype=dtype, + **data_kwargs, + ) + return database + + +def get_default_mlmodel( + model="tp", + fp=None, + baseline=None, + optimize_hp=True, + use_pdis=True, + pdis=None, + prior="median", + use_derivatives=True, + global_optimization=True, + parallel=False, + n_reduced=None, + round_hp=3, + all_model_kwargs={}, + database_reduction=False, + round_targets=5, + database_kwargs={}, + use_ensemble=False, + verbose=False, + seed=None, + dtype=float, + **kwargs, +): + """ + Get the default ML model with a database for the ASE Atoms + from the simple given arguments. + + Parameters: + model: str + Either the tp that gives the Studen T process or + gp that gives the Gaussian process. + fp: Fingerprint class object or None + The fingerprint object used to generate the fingerprints. + Cartesian coordinates are used if it is None. + baseline: Baseline object + The Baseline object calculator that calculates energy and forces. + optimize_hp: bool + Whether to optimize the hyperparameters when the model is trained. + use_pdis: bool + Whether to make prior distributions for the hyperparameters. + pdis: dict (optional) + A dict of prior distributions for each hyperparameter type. + If None, the default prior distributions are used. + No prior distributions are used if use_pdis=False or pdis is {}. + prior: str + Specify what prior mean should be used. + use_derivatives: bool + Whether to use derivatives of the targets. + global_optimization: bool + Whether to perform a global optimization of the hyperparameters. + A local optimization is used if global_optimization=False, + which can not be parallelized. + parallel: bool + Whether to optimize the hyperparameters in parallel. + n_reduced: int or None + If n_reduced is an integer, the hyperparameters are only optimized + when the data set size is equal to or below the integer. + If n_reduced is None, the hyperparameter is always optimized. + round_hp: int (optional) + The number of decimals to round the hyperparameters to. + If None, the hyperparameters are not rounded. + all_model_kwargs: dict (optional) + A dictionary with additional arguments for the model. + It also can include model_kwargs, prior_kwargs, + kernel_kwargs, hpfitter_kwargs, optimizer_kwargs, + lineoptimizer_kwargs, and function_kwargs. + database_reduction: bool + Whether to used a reduced database after a number + of training points. + round_targets: int (optional) + The number of decimals to round the targets to. + If None, the targets are not rounded. + database_kwargs: dict + A dictionary with the arguments for the database + if it is used. + verbose: bool + Whether to print statements in the optimization. + seed: int (optional) + The random seed for the optimization. + The seed an also be a RandomState or Generator instance. + If not given, the default random number generator is used. + dtype: type + The data type of the arrays. + kwargs: dict (optional) + Additional keyword arguments for the MLModel class. + + Returns: + mlmodel: MLModel class object + Machine Learning model used for ASE Atoms and calculator. + """ + from .mlmodel import MLModel + + # Check if fingerprints are used + if fp is None: + use_fingerprint = False + else: + use_fingerprint = True + # Make the model + if isinstance(model, str): + model = get_default_model( + model=model, + prior=prior, + use_derivatives=use_derivatives, + use_fingerprint=use_fingerprint, + global_optimization=global_optimization, + parallel=parallel, + n_reduced=n_reduced, + round_hp=round_hp, + seed=seed, + dtype=dtype, + **all_model_kwargs, + ) + # Make the database + database = get_default_database( + fp=fp, + use_derivatives=use_derivatives, + database_reduction=database_reduction, + round_targets=round_targets, + seed=seed, + dtype=dtype, + **database_kwargs, + ) + # Make prior distributions for the hyperparameters if specified + if use_pdis and pdis is None: + from ..pdistributions.normal import Normal_prior + + pdis = dict( + length=Normal_prior(mu=[-0.8], std=[0.2], dtype=dtype), + noise=Normal_prior(mu=[-9.0], std=[1.0], dtype=dtype), + ) + elif not use_pdis: + pdis = None + # Make the ML model with database + return MLModel( + model=model, + database=database, + baseline=baseline, + optimize=optimize_hp, + pdis=pdis, + verbose=verbose, + dtype=dtype, + **kwargs, + ) diff --git a/catlearn/regression/gp/calculator/mlmodel.py b/catlearn/regression/gp/calculator/mlmodel.py index e7f8e6de..96f9d032 100644 --- a/catlearn/regression/gp/calculator/mlmodel.py +++ b/catlearn/regression/gp/calculator/mlmodel.py @@ -1,7 +1,7 @@ from numpy import asarray, ndarray, sqrt, zeros -import warnings from ase.parallel import parprint import pickle +from .default_model import get_default_model, get_default_database class MLModel: @@ -772,504 +772,3 @@ def __repr__(self): [f"{key}={value}" for key, value in arg_kwargs.items()] ) return "{}({})".format(self.__class__.__name__, str_kwargs) - - -def get_default_model( - model="tp", - prior="median", - use_derivatives=True, - use_fingerprint=False, - global_optimization=True, - parallel=False, - n_reduced=None, - round_hp=3, - seed=None, - dtype=float, - model_kwargs={}, - prior_kwargs={}, - kernel_kwargs={}, - hpfitter_kwargs={}, - optimizer_kwargs={}, - lineoptimizer_kwargs={}, - function_kwargs={}, - **kwargs, -): - """ - Get the default ML model from the simple given arguments. - - Parameters: - model: str - Either the tp that gives the Studen T process or - gp that gives the Gaussian process. - prior: str - Specify what prior mean should be used. - use_derivatives: bool - Whether to use derivatives of the targets. - use_fingerprint: bool - Whether to use fingerprints for the features. - This has to be the same as for the database! - global_optimization: bool - Whether to perform a global optimization of the hyperparameters. - A local optimization is used if global_optimization=False, - which can not be parallelized. - parallel: bool - Whether to optimize the hyperparameters in parallel. - n_reduced: int or None - If n_reduced is an integer, the hyperparameters are only optimized - when the data set size is equal to or below the integer. - If n_reduced is None, the hyperparameter is always optimized. - round_hp: int (optional) - The number of decimals to round the hyperparameters to. - If None, the hyperparameters are not rounded. - seed: int (optional) - The random seed for the optimization. - The seed an also be a RandomState or Generator instance. - If not given, the default random number generator is used. - dtype: type - The data type of the arrays. - model_kwargs: dict (optional) - The keyword arguments for the model. - The additional arguments are passed to the model. - prior_kwargs: dict (optional) - The keyword arguments for the prior mean. - kernel_kwargs: dict (optional) - The keyword arguments for the kernel. - hpfitter_kwargs: dict (optional) - The keyword arguments for the hyperparameter fitter. - optimizer_kwargs: dict (optional) - The keyword arguments for the optimizer. - lineoptimizer_kwargs: dict (optional) - The keyword arguments for the line optimizer. - function_kwargs: dict (optional) - The keyword arguments for the objective function. - - Returns: - model: Model - The Machine Learning Model with kernel and - prior that are optimized. - """ - # Check that the model is given as a string - if not isinstance(model, str): - return model - # Make the prior mean from given string - if isinstance(prior, str): - from ..means import Prior_median, Prior_mean, Prior_min, Prior_max - - if prior.lower() == "median": - prior = Prior_median(**prior_kwargs) - elif prior.lower() == "mean": - prior = Prior_mean(**prior_kwargs) - elif prior.lower() == "min": - prior = Prior_min(**prior_kwargs) - elif prior.lower() == "max": - prior = Prior_max(**prior_kwargs) - # Construct the kernel class object - from ..kernel.se import SE - - kernel = SE( - use_fingerprint=use_fingerprint, - use_derivatives=use_derivatives, - dtype=dtype, - **kernel_kwargs, - ) - # Set the hyperparameter optimization method - if global_optimization: - # Set global optimization with or without parallelization - from ..optimizers.globaloptimizer import FactorizedOptimizer - - # Set the line searcher for the hyperparameter optimization - if parallel: - from ..optimizers.linesearcher import FineGridSearch - - lineoptimizer_kwargs_default = dict( - optimize=True, - multiple_min=False, - ngrid=80, - loops=3, - ) - lineoptimizer_kwargs_default.update(lineoptimizer_kwargs) - line_optimizer = FineGridSearch( - parallel=True, - dtype=dtype, - **lineoptimizer_kwargs_default, - ) - else: - from ..optimizers.linesearcher import GoldenSearch - - lineoptimizer_kwargs_default = dict( - optimize=True, - multiple_min=False, - ) - lineoptimizer_kwargs_default.update(lineoptimizer_kwargs) - line_optimizer = GoldenSearch( - parallel=False, - dtype=dtype, - **lineoptimizer_kwargs_default, - ) - # Set the optimizer for the hyperparameter optimization - optimizer_kwargs_default = dict( - ngrid=80, - calculate_init=False, - ) - optimizer_kwargs_default.update(optimizer_kwargs) - optimizer = FactorizedOptimizer( - line_optimizer=line_optimizer, - parallel=parallel, - dtype=dtype, - **optimizer_kwargs_default, - ) - else: - from ..optimizers.localoptimizer import ScipyOptimizer - - optimizer_kwargs_default = dict( - maxiter=500, - jac=True, - method="l-bfgs-b", - use_bounds=False, - tol=1e-12, - ) - optimizer_kwargs_default.update(optimizer_kwargs) - # Make the local optimizer - optimizer = ScipyOptimizer( - dtype=dtype, - **optimizer_kwargs_default, - ) - if parallel: - warnings.warn( - "Parallel optimization is not implemented" - "with local optimization!" - ) - # Use either the Student t process or the Gaussian process - model_kwargs.update(kwargs) - if model.lower() == "tp": - # Set model - from ..models.tp import TProcess - - model_kwargs_default = dict( - a=1e-4, - b=4.0, - ) - model_kwargs_default.update(model_kwargs) - model = TProcess( - prior=prior, - kernel=kernel, - use_derivatives=use_derivatives, - dtype=dtype, - **model_kwargs_default, - ) - # Set objective function - if global_optimization: - from ..objectivefunctions.tp.factorized_likelihood import ( - FactorizedLogLikelihood, - ) - - func = FactorizedLogLikelihood(dtype=dtype, **function_kwargs) - else: - from ..objectivefunctions.tp.likelihood import LogLikelihood - - func = LogLikelihood(dtype=dtype, **function_kwargs) - else: - # Set model - from ..models.gp import GaussianProcess - - model = GaussianProcess( - prior=prior, - kernel=kernel, - use_derivatives=use_derivatives, - dtype=dtype, - **model_kwargs, - ) - # Set objective function - if global_optimization: - from ..objectivefunctions.gp.factorized_likelihood import ( - FactorizedLogLikelihood, - ) - - func = FactorizedLogLikelihood(dtype=dtype, **function_kwargs) - else: - from ..objectivefunctions.gp.likelihood import LogLikelihood - - func = LogLikelihood(dtype=dtype, **function_kwargs) - # Set hpfitter and whether a maximum data set size is applied - if n_reduced is None: - from ..hpfitter import HyperparameterFitter - - hpfitter = HyperparameterFitter( - func=func, - optimizer=optimizer, - round_hp=round_hp, - dtype=dtype, - **hpfitter_kwargs, - ) - else: - from ..hpfitter.redhpfitter import ReducedHyperparameterFitter - - hpfitter = ReducedHyperparameterFitter( - func=func, - optimizer=optimizer, - opt_tr_size=n_reduced, - round_hp=round_hp, - dtype=dtype, - **hpfitter_kwargs, - ) - model.update_arguments(hpfitter=hpfitter) - # Set the seed for the model - if seed is not None: - model.set_seed(seed=seed) - # Return the model - return model - - -def get_default_database( - fp=None, - use_derivatives=True, - database_reduction=False, - round_targets=5, - seed=None, - dtype=float, - **database_kwargs, -): - """ - Get the default Database from the simple given arguments. - - Parameters: - fp: Fingerprint class object or None - The fingerprint object used to generate the fingerprints. - Cartesian coordinates are used if it is None. - use_derivatives: bool - Whether to use derivatives of the targets. - database_reduction: bool - Whether to used a reduced database after a number - of training points. - round_targets: int (optional) - The number of decimals to round the targets to. - If None, the targets are not rounded. - seed: int (optional) - The random seed for the optimization. - The seed an also be a RandomState or Generator instance. - If not given, the default random number generator is used. - dtype: type - The data type of the arrays. - database_kwargs: dict (optional) - A dictionary with additional arguments for the database. - Also used for the reduced databases. - - Returns: - database: Database object - The Database object with ASE atoms. - """ - # Set a fingerprint - if fp is None: - from ..fingerprint.cartesian import Cartesian - - # Use cartesian coordinates as the fingerprint - fp = Cartesian(reduce_dimensions=True, use_derivatives=use_derivatives) - use_fingerprint = False - else: - use_fingerprint = True - # Make the data base ready - if isinstance(database_reduction, str): - data_kwargs = dict( - fingerprint=fp, - reduce_dimensions=True, - use_derivatives=use_derivatives, - use_fingerprint=use_fingerprint, - round_targets=round_targets, - seed=seed, - dtype=dtype, - npoints=50, - initial_indices=[0, 1], - include_last=1, - ) - data_kwargs.update(database_kwargs) - if database_reduction.lower() == "distance": - from .database_reduction import DatabaseDistance - - database = DatabaseDistance(**data_kwargs) - elif database_reduction.lower() == "random": - from .database_reduction import DatabaseRandom - - database = DatabaseRandom(**data_kwargs) - elif database_reduction.lower() == "hybrid": - from .database_reduction import DatabaseHybrid - - database = DatabaseHybrid(**data_kwargs) - elif database_reduction.lower() == "min": - from .database_reduction import DatabaseMin - - database = DatabaseMin(**data_kwargs) - elif database_reduction.lower() == "last": - from .database_reduction import DatabaseLast - - database = DatabaseLast(**data_kwargs) - elif database_reduction.lower() == "restart": - from .database_reduction import DatabaseRestart - - database = DatabaseRestart(**data_kwargs) - elif database_reduction.lower() == "interest": - from .database_reduction import DatabasePointsInterest - - database = DatabasePointsInterest(**data_kwargs) - elif database_reduction.lower() == "each_interest": - from .database_reduction import DatabasePointsInterestEach - - database = DatabasePointsInterestEach(**data_kwargs) - else: - from .database import Database - - data_kwargs = dict( - reduce_dimensions=True, - ) - data_kwargs.update(database_kwargs) - database = Database( - fingerprint=fp, - use_derivatives=use_derivatives, - use_fingerprint=use_fingerprint, - round_targets=round_targets, - seed=seed, - dtype=dtype, - **data_kwargs, - ) - return database - - -def get_default_mlmodel( - model="tp", - fp=None, - baseline=None, - optimize_hp=True, - use_pdis=True, - pdis=None, - prior="median", - use_derivatives=True, - global_optimization=True, - parallel=False, - n_reduced=None, - round_hp=3, - all_model_kwargs={}, - database_reduction=False, - round_targets=5, - database_kwargs={}, - verbose=False, - seed=None, - dtype=float, - **kwargs, -): - """ - Get the default ML model with a database for the ASE Atoms - from the simple given arguments. - - Parameters: - model: str - Either the tp that gives the Studen T process or - gp that gives the Gaussian process. - fp: Fingerprint class object or None - The fingerprint object used to generate the fingerprints. - Cartesian coordinates are used if it is None. - baseline: Baseline object - The Baseline object calculator that calculates energy and forces. - optimize_hp: bool - Whether to optimize the hyperparameters when the model is trained. - use_pdis: bool - Whether to make prior distributions for the hyperparameters. - pdis: dict (optional) - A dict of prior distributions for each hyperparameter type. - If None, the default prior distributions are used. - No prior distributions are used if use_pdis=False or pdis is {}. - prior: str - Specify what prior mean should be used. - use_derivatives: bool - Whether to use derivatives of the targets. - global_optimization: bool - Whether to perform a global optimization of the hyperparameters. - A local optimization is used if global_optimization=False, - which can not be parallelized. - parallel: bool - Whether to optimize the hyperparameters in parallel. - n_reduced: int or None - If n_reduced is an integer, the hyperparameters are only optimized - when the data set size is equal to or below the integer. - If n_reduced is None, the hyperparameter is always optimized. - round_hp: int (optional) - The number of decimals to round the hyperparameters to. - If None, the hyperparameters are not rounded. - all_model_kwargs: dict (optional) - A dictionary with additional arguments for the model. - It also can include model_kwargs, prior_kwargs, - kernel_kwargs, hpfitter_kwargs, optimizer_kwargs, - lineoptimizer_kwargs, and function_kwargs. - database_reduction: bool - Whether to used a reduced database after a number - of training points. - round_targets: int (optional) - The number of decimals to round the targets to. - If None, the targets are not rounded. - database_kwargs: dict - A dictionary with the arguments for the database - if it is used. - verbose: bool - Whether to print statements in the optimization. - seed: int (optional) - The random seed for the optimization. - The seed an also be a RandomState or Generator instance. - If not given, the default random number generator is used. - dtype: type - The data type of the arrays. - kwargs: dict (optional) - Additional keyword arguments for the MLModel class. - - Returns: - mlmodel: MLModel class object - Machine Learning model used for ASE Atoms and calculator. - """ - # Check if fingerprints are used - if fp is None: - use_fingerprint = False - else: - use_fingerprint = True - # Make the model - if isinstance(model, str): - model = get_default_model( - model=model, - prior=prior, - use_derivatives=use_derivatives, - use_fingerprint=use_fingerprint, - global_optimization=global_optimization, - parallel=parallel, - n_reduced=n_reduced, - round_hp=round_hp, - seed=seed, - dtype=dtype, - **all_model_kwargs, - ) - # Make the database - database = get_default_database( - fp=fp, - use_derivatives=use_derivatives, - database_reduction=database_reduction, - round_targets=round_targets, - seed=seed, - dtype=dtype, - **database_kwargs, - ) - # Make prior distributions for the hyperparameters if specified - if use_pdis and pdis is None: - from ..pdistributions.normal import Normal_prior - - pdis = dict( - length=Normal_prior(mu=[-0.8], std=[0.2], dtype=dtype), - noise=Normal_prior(mu=[-9.0], std=[1.0], dtype=dtype), - ) - elif not use_pdis: - pdis = None - # Make the ML model with database - return MLModel( - model=model, - database=database, - baseline=baseline, - optimize=optimize_hp, - pdis=pdis, - verbose=verbose, - dtype=dtype, - **kwargs, - ) diff --git a/catlearn/regression/gp/ensemble/ensemble.py b/catlearn/regression/gp/ensemble/ensemble.py index bc4a9326..f081dcf9 100644 --- a/catlearn/regression/gp/ensemble/ensemble.py +++ b/catlearn/regression/gp/ensemble/ensemble.py @@ -2,7 +2,7 @@ import pickle import warnings from ..means.constant import Prior_constant -from ..calculator.mlmodel import get_default_model +from ..calculator.default_model import get_default_model class EnsembleModel: From caac06638fef437ec3587ceee1a4c3b95939f420 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Fri, 25 Jul 2025 15:28:31 +0200 Subject: [PATCH 159/194] Implementation of default ensemble model function --- catlearn/regression/gp/calculator/__init__.py | 2 + .../regression/gp/calculator/default_model.py | 146 ++++++++++++++++-- 2 files changed, 135 insertions(+), 13 deletions(-) diff --git a/catlearn/regression/gp/calculator/__init__.py b/catlearn/regression/gp/calculator/__init__.py index 9ad1dc99..e856e813 100644 --- a/catlearn/regression/gp/calculator/__init__.py +++ b/catlearn/regression/gp/calculator/__init__.py @@ -15,6 +15,7 @@ from .default_model import ( get_default_model, get_default_database, + get_default_ensemble, get_default_mlmodel, ) from .hiermodel import HierarchicalMLModel @@ -37,6 +38,7 @@ "MLModel", "get_default_model", "get_default_database", + "get_default_ensemble", "get_default_mlmodel", "HierarchicalMLModel", "MLCalculator", diff --git a/catlearn/regression/gp/calculator/default_model.py b/catlearn/regression/gp/calculator/default_model.py index 4610c0a7..73f114f6 100644 --- a/catlearn/regression/gp/calculator/default_model.py +++ b/catlearn/regression/gp/calculator/default_model.py @@ -296,6 +296,7 @@ def get_default_database( use_fingerprint = True # Make the data base ready if isinstance(database_reduction, str): + # Set the default database arguments data_kwargs = dict( fingerprint=fp, reduce_dimensions=True, @@ -360,14 +361,102 @@ def get_default_database( return database +def get_default_ensemble( + model, + clustering="k_number", + clustering_kwargs={}, + seed=None, + dtype=float, + **ensemble_kwargs, +): + """ + Get the default ensemble model with clustering and ensemble. + + Parameters: + model: Model + The Machine Learning Model with kernel and prior. + clustering: str or Clustering class instance + The clustering method used to split the data to different models. + If a string is given, the clustering method is created from the + string. + clustering_kwargs: dict (optional) + A dictionary with the arguments for the clustering method. + If clustering is a string, the arguments are used to create the + clustering method. + seed: int (optional) + The random seed for the clustering. + The seed can be an integer, RandomState, or Generator instance. + If not given, the default random number generator is used. + dtype: type + The data type of the arrays. + ensemble_kwargs: dict (optional) + Additional keyword arguments for the EnsembleClustering class. + + Returns: + ensemble_model: EnsembleClustering + The EnsembleClustering with clustering and ensemble. + """ + from ..ensemble.ensemble_clustering import EnsembleClustering + + # Check if clustering is a string and make the clustering method + if isinstance(clustering, str): + # Load the clustering methods + from ..ensemble.clustering import ( + K_means_number, + K_means, + K_means_auto, + K_means_enumeration, + RandomClustering, + RandomClustering_number, + FixedClustering, + ) + + # Set the default clustering arguments + clustering_kwargs_default = dict( + seed=seed, + dtype=dtype, + ) + # Set the data number for the specific clustering method + if clustering.lower() in [ + "k_number", + "k_enumeration", + "random_number", + ]: + clustering_kwargs_default.update(dict(data_number=25)) + clustering_kwargs_default.update(clustering_kwargs) + if clustering.lower() == "k_number": + clustering = K_means_number(**clustering_kwargs_default) + elif clustering.lower() == "k_means": + clustering = K_means(**clustering_kwargs_default) + elif clustering.lower() == "k_auto": + clustering = K_means_auto(**clustering_kwargs_default) + elif clustering.lower() == "k_enumeration": + clustering = K_means_enumeration(**clustering_kwargs_default) + elif clustering.lower() == "random": + clustering = RandomClustering(**clustering_kwargs_default) + elif clustering.lower() == "random_number": + clustering = RandomClustering_number(**clustering_kwargs_default) + elif clustering.lower() == "fixed": + clustering = FixedClustering(**clustering_kwargs_default) + else: + raise ValueError(f"Clustering {clustering} is not implemented!") + # Create the ensemble model + return EnsembleClustering( + model=model, + clustering=clustering, + dtype=dtype, + **ensemble_kwargs, + ) + + def get_default_mlmodel( model="tp", fp=None, baseline=None, + prior="median", optimize_hp=True, use_pdis=True, pdis=None, - prior="median", use_derivatives=True, global_optimization=True, parallel=False, @@ -378,24 +467,29 @@ def get_default_mlmodel( round_targets=5, database_kwargs={}, use_ensemble=False, + clustering="k_number", + cluster_kwargs=dict(), + ensemble_kwargs={}, verbose=False, seed=None, dtype=float, - **kwargs, + **mlmodel_kwargs, ): """ Get the default ML model with a database for the ASE Atoms from the simple given arguments. Parameters: - model: str - Either the tp that gives the Studen T process or + model: str or Model class instance + Either the tp that gives the Students T process or gp that gives the Gaussian process. - fp: Fingerprint class object or None - The fingerprint object used to generate the fingerprints. + fp: Fingerprint class instance or None + The fingerprint instance used to generate the fingerprints. Cartesian coordinates are used if it is None. - baseline: Baseline object - The Baseline object calculator that calculates energy and forces. + baseline: Baseline class instance + The Baseline instance used to calculate energy and forces. + prior: str + Specify what prior mean should be used. optimize_hp: bool Whether to optimize the hyperparameters when the model is trained. use_pdis: bool @@ -404,8 +498,6 @@ def get_default_mlmodel( A dict of prior distributions for each hyperparameter type. If None, the default prior distributions are used. No prior distributions are used if use_pdis=False or pdis is {}. - prior: str - Specify what prior mean should be used. use_derivatives: bool Whether to use derivatives of the targets. global_optimization: bool @@ -435,6 +527,20 @@ def get_default_mlmodel( database_kwargs: dict A dictionary with the arguments for the database if it is used. + use_ensemble: bool + Whether to use an ensemble model with clustering. + The use of ensemble models can avoid memory issues and speed up + the training. + clustering: str or Clustering class instance + The clustering method used to split the data to different models. + If a string is given, the clustering method is created from the + string. + cluster_kwargs: dict (optional) + A dictionary with the arguments for the clustering method. + If clustering is a string, the arguments are used to create the + clustering method. + ensemble_kwargs: dict (optional) + Additional keyword arguments for the EnsembleClustering class. verbose: bool Whether to print statements in the optimization. seed: int (optional) @@ -443,11 +549,11 @@ def get_default_mlmodel( If not given, the default random number generator is used. dtype: type The data type of the arrays. - kwargs: dict (optional) + mlmodel_kwargs: dict (optional) Additional keyword arguments for the MLModel class. Returns: - mlmodel: MLModel class object + mlmodel: MLModel class instance Machine Learning model used for ASE Atoms and calculator. """ from .mlmodel import MLModel @@ -472,6 +578,20 @@ def get_default_mlmodel( dtype=dtype, **all_model_kwargs, ) + # Make the model as an ensemble model if specified + if use_ensemble: + if database_reduction: + warnings.warn( + "Database reduction is not allowed with ensemble models!" + ) + model = get_default_ensemble( + model=model, + clustering=clustering, + clustering_kwargs=cluster_kwargs, + seed=seed, + dtype=dtype, + **ensemble_kwargs, + ) # Make the database database = get_default_database( fp=fp, @@ -501,5 +621,5 @@ def get_default_mlmodel( pdis=pdis, verbose=verbose, dtype=dtype, - **kwargs, + **mlmodel_kwargs, ) From 9ecb42c9ef1d8ca4744e165a91d83b20d8063a56 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Fri, 25 Jul 2025 15:35:01 +0200 Subject: [PATCH 160/194] Add the option to specify default mlcalc arguments --- catlearn/activelearning/activelearning.py | 73 +++++++++++++-------- catlearn/activelearning/adsorption.py | 6 +- catlearn/activelearning/local.py | 4 ++ catlearn/activelearning/mlgo.py | 13 +++- catlearn/activelearning/mlneb.py | 4 ++ catlearn/activelearning/randomadsorption.py | 4 ++ 6 files changed, 74 insertions(+), 30 deletions(-) diff --git a/catlearn/activelearning/activelearning.py b/catlearn/activelearning/activelearning.py index 77ab92db..94d54867 100644 --- a/catlearn/activelearning/activelearning.py +++ b/catlearn/activelearning/activelearning.py @@ -56,6 +56,7 @@ def __init__( save_properties_traj=True, to_save_mlcalc=False, save_mlcalc_kwargs={}, + default_mlcalc_kwargs={}, trajectory="predicted.traj", trainingset="evaluated.traj", pred_evaluated="predicted_evaluated.traj", @@ -163,6 +164,8 @@ def __init__( Whether to save the ML calculator to a file after training. save_mlcalc_kwargs: dict Arguments for saving the ML calculator, like the filename. + default_mlcalc_kwargs: dict + The default keyword arguments for the ML calculator. trajectory: str or TrajectoryWriter instance Trajectory filename to store the predicted data. Or the TrajectoryWriter instance to store the predicted data. @@ -218,6 +221,7 @@ def __init__( mlcalc, save_memory=save_memory, verbose=verbose, + **default_mlcalc_kwargs, ) # Setup the acquisition function self.setup_acq( @@ -413,7 +417,7 @@ def setup_mlcalc( self, mlcalc=None, verbose=True, - **kwargs, + **default_mlcalc_kwargs, ): """ Setup the ML calculator. @@ -425,6 +429,8 @@ def setup_mlcalc( verbose: bool Whether to print on screen the full output (True) or not (False). + default_mlcalc_kwargs: dict + The default keyword arguments for the ML calculator. Returns: self: The object itself. @@ -438,7 +444,7 @@ def setup_mlcalc( else: self.mlcalc = self.setup_default_mlcalc( verbose=verbose, - **kwargs, + **default_mlcalc_kwargs, ) # Check if the seed is given if hasattr(self, "seed"): @@ -452,60 +458,67 @@ def setup_mlcalc( def setup_default_mlcalc( self, + atoms=None, save_memory=False, + model="tp", fp=None, - atoms=None, - prior=Prior_max(add=1.0), baseline=BornRepulsionCalculator(), + prior=Prior_max(add=1.0), use_derivatives=True, + optimize_hp=True, database_reduction=False, + use_ensemble=False, calc_forces=True, round_pred=5, - optimize_hp=True, bayesian=True, kappa=2.0, reuse_mlcalc_data=False, verbose=True, calc_kwargs={}, - **kwargs, + **mlmodel_kwargs, ): """ Setup the ML calculator. Parameters: - mlcalc: ML-calculator instance (optional) - The ML-calculator instance used as surrogate surface. - A default ML-model is used if mlcalc is None. + atoms: Atoms instance (optional if fp is not None) + The Atoms instance from the optimization method. + It is used to setup the fingerprint if it is None. save_memory: bool Whether to only train the ML calculator and store - all objects on one CPU. + all instances on one CPU. If save_memory==True then parallel optimization of the hyperparameters can not be achived. - If save_memory==False no MPI object is used. + If save_memory==False no MPI instance is used. + model: str or Model class instance + Either the tp that gives the Students T process or + gp that gives the Gaussian process. fp: Fingerprint class instance (optional) The fingerprint instance used for the ML model. The default InvDistances instance is used if fp is None. - atoms: Atoms object (optional if fp is not None) - The Atoms object from the optimization method. - It is used to setup the fingerprint if it is None. - prior: Prior class instance (optional) - The prior mean instance used for the ML model. - The default prior is the Prior_max. baseline: Baseline class instance (optional) The baseline instance used for the ML model. The default is the BornRepulsionCalculator. + prior: Prior class instance (optional) + The prior mean instance used for the ML model. + The default prior is the Prior_max. use_derivatives: bool Whether to use derivatives of the targets in the ML model. + optimize_hp: bool + Whether to optimize the hyperparameters when the model is + trained. database_reduction: bool - Whether to reduce the database. + Whether to reduce the training database size. + A reduction can avoid memory issues and speed up the training. + use_ensemble: bool + Whether to use an ensemble model with clustering. + The use of ensemble models can avoid memory issues and speed up + the training. calc_forces: bool Whether to calculate the forces for all energy predictions. round_pred: int (optional) The number of decimals to round the predictions to. If None, the predictions are not rounded. - optimize_hp: bool - Whether to optimize the hyperparameters when the model is - trained. bayesian: bool Whether to use the Bayesian optimization calculator. kappa: float @@ -518,12 +531,17 @@ def setup_default_mlcalc( not (False). calc_kwargs: dict The keyword arguments for the ML calculator. + mlmodel_kwargs: dict + Additional keyword arguments for the function + to create the MLModel instance. Returns: - self: The object itself. + self: The instance itself. """ # Create the ML calculator - from ..regression.gp.calculator.default_model import get_default_mlmodel + from ..regression.gp.calculator.default_model import ( + get_default_mlmodel, + ) from ..regression.gp.calculator.mlcalc import MLCalculator from ..regression.gp.fingerprint.invdistances import InvDistances @@ -535,7 +553,7 @@ def setup_default_mlcalc( raise NameError("The save_memory is not given.") # Setup the fingerprint if fp is None: - # Check if the Atoms object is given + # Check if the Atoms instance is given if atoms is None: try: atoms = self.get_structures( @@ -558,16 +576,17 @@ def setup_default_mlcalc( ) # Setup the ML model mlmodel = get_default_mlmodel( - model="tp", + model=model, prior=prior, fp=fp, baseline=baseline, use_derivatives=use_derivatives, parallel=(not save_memory), - database_reduction=database_reduction, optimize_hp=optimize_hp, + database_reduction=database_reduction, + use_ensemble=use_ensemble, verbose=verbose, - **kwargs, + **mlmodel_kwargs, ) # Get the data from a previous mlcalc if requested and it exist if reuse_mlcalc_data: diff --git a/catlearn/activelearning/adsorption.py b/catlearn/activelearning/adsorption.py index c597b6de..78fbc808 100644 --- a/catlearn/activelearning/adsorption.py +++ b/catlearn/activelearning/adsorption.py @@ -43,6 +43,7 @@ def __init__( save_properties_traj=True, to_save_mlcalc=False, save_mlcalc_kwargs={}, + default_mlcalc_kwargs={}, trajectory="predicted.traj", trainingset="evaluated.traj", pred_evaluated="predicted_evaluated.traj", @@ -152,6 +153,8 @@ def __init__( Whether to save the ML calculator to a file after training. save_mlcalc_kwargs: dict Arguments for saving the ML calculator, like the filename. + default_mlcalc_kwargs: dict + The default keyword arguments for the ML calculator. trajectory: str or TrajectoryWriter instance Trajectory filename to store the predicted data. Or the TrajectoryWriter instance to store the predicted data. @@ -235,6 +238,7 @@ def __init__( save_properties_traj=save_properties_traj, to_save_mlcalc=to_save_mlcalc, save_mlcalc_kwargs=save_mlcalc_kwargs, + default_mlcalc_kwargs=default_mlcalc_kwargs, trajectory=trajectory, trainingset=trainingset, pred_evaluated=pred_evaluated, @@ -323,8 +327,8 @@ def extra_initial_data(self, **kwargs): def setup_default_mlcalc( self, - fp=None, atoms=None, + fp=None, baseline=BornRepulsionCalculator(), use_derivatives=True, calc_forces=False, diff --git a/catlearn/activelearning/local.py b/catlearn/activelearning/local.py index d1d6092b..6b56c895 100644 --- a/catlearn/activelearning/local.py +++ b/catlearn/activelearning/local.py @@ -42,6 +42,7 @@ def __init__( save_properties_traj=True, to_save_mlcalc=False, save_mlcalc_kwargs={}, + default_mlcalc_kwargs={}, trajectory="predicted.traj", trainingset="evaluated.traj", pred_evaluated="predicted_evaluated.traj", @@ -153,6 +154,8 @@ def __init__( Whether to save the ML calculator to a file after training. save_mlcalc_kwargs: dict Arguments for saving the ML calculator, like the filename. + default_mlcalc_kwargs: dict + The default keyword arguments for the ML calculator. trajectory: str or TrajectoryWriter instance Trajectory filename to store the predicted data. Or the TrajectoryWriter instance to store the predicted data. @@ -236,6 +239,7 @@ def __init__( save_properties_traj=save_properties_traj, to_save_mlcalc=to_save_mlcalc, save_mlcalc_kwargs=save_mlcalc_kwargs, + default_mlcalc_kwargs=default_mlcalc_kwargs, trajectory=trajectory, trainingset=trainingset, pred_evaluated=pred_evaluated, diff --git a/catlearn/activelearning/mlgo.py b/catlearn/activelearning/mlgo.py index 7454e483..d8160b63 100644 --- a/catlearn/activelearning/mlgo.py +++ b/catlearn/activelearning/mlgo.py @@ -55,6 +55,8 @@ def __init__( save_properties_traj=True, to_save_mlcalc=False, save_mlcalc_kwargs={}, + default_mlcalc_kwargs={}, + default_mlcalc_local_kwargs={}, trajectory="predicted.traj", trainingset="evaluated.traj", pred_evaluated="predicted_evaluated.traj", @@ -196,6 +198,10 @@ def __init__( Whether to save the ML calculator to a file after training. save_mlcalc_kwargs: dict Arguments for saving the ML calculator, like the filename. + default_mlcalc_kwargs: dict + The default keyword arguments for the ML calculator. + default_mlcalc_local_kwargs: dict + The default keyword arguments for the local ML calculator. trajectory: str or TrajectoryWriter instance Trajectory filename to store the predicted data. Or the TrajectoryWriter instance to store the predicted data. @@ -244,6 +250,7 @@ def __init__( self.reuse_data_local = reuse_data_local # Save the local ML-calculator self.mlcalc_local = mlcalc_local + self.default_mlcalc_local_kwargs = default_mlcalc_local_kwargs # Initialize the AdsorptionBO super().__init__( slab=slab, @@ -278,6 +285,7 @@ def __init__( save_properties_traj=save_properties_traj, to_save_mlcalc=to_save_mlcalc, save_mlcalc_kwargs=save_mlcalc_kwargs, + default_mlcalc_kwargs=default_mlcalc_kwargs, trajectory=trajectory, trainingset=trainingset, pred_evaluated=pred_evaluated, @@ -424,12 +432,12 @@ def switch_mlcalcs(self, data, **kwargs): ) # Setup the ML-calculator for the local optimization self.setup_mlcalc_local( - mlcalc_local=self.mlcalc_local, + mlcalc=self.mlcalc_local, save_memory=self.save_memory, atoms=structures, reuse_mlcalc_data=False, verbose=self.verbose, - **kwargs, + **self.default_mlcalc_local_kwargs, ) # Add the training data to the local ML-calculator self.use_prev_calculations(data) @@ -561,6 +569,7 @@ def get_arguments(self): save_properties_traj=self.save_properties_traj, to_save_mlcalc=self.to_save_mlcalc, save_mlcalc_kwargs=self.save_mlcalc_kwargs, + default_mlcalc_local_kwargs=self.default_mlcalc_local_kwargs, trajectory=self.trajectory, trainingset=self.trainingset, pred_evaluated=self.pred_evaluated, diff --git a/catlearn/activelearning/mlneb.py b/catlearn/activelearning/mlneb.py index 9c4f583d..ee9343a5 100644 --- a/catlearn/activelearning/mlneb.py +++ b/catlearn/activelearning/mlneb.py @@ -51,6 +51,7 @@ def __init__( save_properties_traj=True, to_save_mlcalc=True, save_mlcalc_kwargs={}, + default_mlcalc_kwargs={}, trajectory="predicted.traj", trainingset="evaluated.traj", pred_evaluated="predicted_evaluated.traj", @@ -204,6 +205,8 @@ def __init__( Whether to save the ML calculator to a file after training. save_mlcalc_kwargs: dict Arguments for saving the ML calculator, like the filename. + default_mlcalc_kwargs: dict + The default keyword arguments for the ML calculator. trajectory: str or TrajectoryWriter instance Trajectory filename to store the predicted data. Or the TrajectoryWriter instance to store the predicted data. @@ -298,6 +301,7 @@ def __init__( save_properties_traj=save_properties_traj, to_save_mlcalc=to_save_mlcalc, save_mlcalc_kwargs=save_mlcalc_kwargs, + default_mlcalc_kwargs=default_mlcalc_kwargs, trajectory=trajectory, trainingset=trainingset, pred_evaluated=pred_evaluated, diff --git a/catlearn/activelearning/randomadsorption.py b/catlearn/activelearning/randomadsorption.py index d98e99c3..bf16f835 100644 --- a/catlearn/activelearning/randomadsorption.py +++ b/catlearn/activelearning/randomadsorption.py @@ -55,6 +55,7 @@ def __init__( save_properties_traj=True, to_save_mlcalc=False, save_mlcalc_kwargs={}, + default_mlcalc_kwargs={}, trajectory="predicted.traj", trainingset="evaluated.traj", pred_evaluated="predicted_evaluated.traj", @@ -195,6 +196,8 @@ def __init__( Whether to save the ML calculator to a file after training. save_mlcalc_kwargs: dict Arguments for saving the ML calculator, like the filename. + default_mlcalc_kwargs: dict + The default keyword arguments for the ML calculator. trajectory: str or TrajectoryWriter instance Trajectory filename to store the predicted data. Or the TrajectoryWriter instance to store the predicted data. @@ -288,6 +291,7 @@ def __init__( save_properties_traj=save_properties_traj, to_save_mlcalc=to_save_mlcalc, save_mlcalc_kwargs=save_mlcalc_kwargs, + default_mlcalc_kwargs=default_mlcalc_kwargs, trajectory=trajectory, trainingset=trainingset, pred_evaluated=pred_evaluated, From 3a4e65a5d7a5edba30120a5416d673f738baf589 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Mon, 28 Jul 2025 13:16:12 +0200 Subject: [PATCH 161/194] Minor bug in setup_default_mlcalc when reuse_mlcalc_data --- catlearn/activelearning/activelearning.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/catlearn/activelearning/activelearning.py b/catlearn/activelearning/activelearning.py index 94d54867..b2af859a 100644 --- a/catlearn/activelearning/activelearning.py +++ b/catlearn/activelearning/activelearning.py @@ -620,7 +620,7 @@ def setup_default_mlcalc( # Reuse the data from a previous mlcalc if requested if reuse_mlcalc_data: if len(data): - self.add_training(data) + mlcalc.add_training(data) return mlcalc def setup_acq( From 7fdae3999eb96fda5811931902d2b06e79b9bc0c Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Mon, 28 Jul 2025 13:53:58 +0200 Subject: [PATCH 162/194] Minor change in suggested argument --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e5c74da1..92468fe4 100644 --- a/README.md +++ b/README.md @@ -310,7 +310,7 @@ dyn = RandomAdsorptionAL( ase_calc=calc, unc_convergence=0.02, bounds=bounds, - n_random_draws=10, + n_random_draws=200, use_initial_opt=False, initial_fmax=0.2, use_repulsive_check=True, From 8441c7c6ab6154e52da48e180415c853a243af4b Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Mon, 28 Jul 2025 13:56:28 +0200 Subject: [PATCH 163/194] Include the Python versions 3.12 and 3.13 for testing. --- .github/workflows/test-package.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test-package.yml b/.github/workflows/test-package.yml index 31b14c38..d421b0db 100644 --- a/.github/workflows/test-package.yml +++ b/.github/workflows/test-package.yml @@ -12,7 +12,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.8", "3.9", "3.10", "3.11"] + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] steps: - uses: actions/checkout@v4 From 395959b3ad51c757b005b00b140f0aadf320846b Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Fri, 1 Aug 2025 14:30:20 +0200 Subject: [PATCH 164/194] New version --- catlearn/_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/catlearn/_version.py b/catlearn/_version.py index 8be5e30b..d438fdba 100644 --- a/catlearn/_version.py +++ b/catlearn/_version.py @@ -1,3 +1,3 @@ -__version__ = "7.0.0" +__version__ = "7.1.0" __all__ = ["__version__"] From d64e0434ffd0938f2a51232b209c1ec430715086 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Fri, 1 Aug 2025 14:31:51 +0200 Subject: [PATCH 165/194] Debug point_interest --- catlearn/regression/gp/calculator/database_reduction.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/catlearn/regression/gp/calculator/database_reduction.py b/catlearn/regression/gp/calculator/database_reduction.py index 749ff5e2..d7adb4cd 100644 --- a/catlearn/regression/gp/calculator/database_reduction.py +++ b/catlearn/regression/gp/calculator/database_reduction.py @@ -1089,6 +1089,9 @@ def update_arguments( self.feature_distance = feature_distance # Set the points of interest if point_interest is not None: + # Ensure point_interest is a list of ASE Atoms instances + if not isinstance(point_interest, list): + point_interest = [point_interest] self.point_interest = [atoms.copy() for atoms in point_interest] self.fp_interest = [ self.make_atoms_feature(atoms) for atoms in self.point_interest From 57eb4d021eea2faf81c8c845dd4bb536a124fcd5 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Fri, 1 Aug 2025 14:33:08 +0200 Subject: [PATCH 166/194] Correct scale_fmax docstring --- catlearn/activelearning/activelearning.py | 12 +++++++----- catlearn/activelearning/adsorption.py | 5 +++-- catlearn/activelearning/local.py | 5 +++-- catlearn/activelearning/mlgo.py | 5 +++-- catlearn/activelearning/mlneb.py | 5 +++-- 5 files changed, 19 insertions(+), 13 deletions(-) diff --git a/catlearn/activelearning/activelearning.py b/catlearn/activelearning/activelearning.py index b2af859a..af7f18ec 100644 --- a/catlearn/activelearning/activelearning.py +++ b/catlearn/activelearning/activelearning.py @@ -114,8 +114,9 @@ def __init__( extrapolated to 0 K). By default force_consistent=False. scale_fmax: float - The scaling of the fmax for the ML-NEB runs. - It makes the path converge tighter on surrogate surface. + The scaling of the fmax convergence criteria. + It makes the structure(s) converge tighter on surrogate + surface. use_fmax_convergence: bool Whether to use the maximum force as an convergence criterion. unc_convergence: float @@ -900,8 +901,9 @@ def update_arguments( extrapolated to 0 K). By default force_consistent=False. scale_fmax: float - The scaling of the fmax for the ML-NEB runs. - It makes the path converge tighter on surrogate surface. + The scaling of the fmax convergence criteria. + It makes the structure(s) converge tighter on surrogate + surface. use_fmax_convergence: bool Whether to use the maximum force as an convergence criterion. unc_convergence: float @@ -1130,7 +1132,7 @@ def find_next_candidates( **kwargs, ): "Run the method on the ML surrogate surface." - # Convergence of the NEB + # Convergence of the method method_converged = False # Check if the method is running in parallel if not self.parallel_run and self.rank != 0: diff --git a/catlearn/activelearning/adsorption.py b/catlearn/activelearning/adsorption.py index 78fbc808..a37a0181 100644 --- a/catlearn/activelearning/adsorption.py +++ b/catlearn/activelearning/adsorption.py @@ -119,8 +119,9 @@ def __init__( extrapolated to 0 K). By default force_consistent=False. scale_fmax: float - The scaling of the fmax for the ML-NEB runs. - It makes the path converge tighter on surrogate surface. + The scaling of the fmax convergence criteria. + It makes the structure(s) converge tighter on surrogate + surface. use_fmax_convergence: bool Whether to use the maximum force as an convergence criterion. unc_convergence: float diff --git a/catlearn/activelearning/local.py b/catlearn/activelearning/local.py index 6b56c895..4ab4e447 100644 --- a/catlearn/activelearning/local.py +++ b/catlearn/activelearning/local.py @@ -104,8 +104,9 @@ def __init__( extrapolated to 0 K). By default force_consistent=False. scale_fmax: float - The scaling of the fmax for the ML-NEB runs. - It makes the path converge tighter on surrogate surface. + The scaling of the fmax convergence criteria. + It makes the structure(s) converge tighter on surrogate + surface. use_fmax_convergence: bool Whether to use the maximum force as an convergence criterion. unc_convergence: float diff --git a/catlearn/activelearning/mlgo.py b/catlearn/activelearning/mlgo.py index d8160b63..f4958221 100644 --- a/catlearn/activelearning/mlgo.py +++ b/catlearn/activelearning/mlgo.py @@ -147,8 +147,9 @@ def __init__( extrapolated to 0 K). By default force_consistent=False. scale_fmax: float - The scaling of the fmax for the ML-NEB runs. - It makes the path converge tighter on surrogate surface. + The scaling of the fmax convergence criteria. + It makes the structure(s) converge tighter on surrogate + surface. use_fmax_convergence: bool Whether to use the maximum force as an convergence criterion. unc_convergence: float diff --git a/catlearn/activelearning/mlneb.py b/catlearn/activelearning/mlneb.py index ee9343a5..7a2c10c8 100644 --- a/catlearn/activelearning/mlneb.py +++ b/catlearn/activelearning/mlneb.py @@ -157,8 +157,9 @@ def __init__( extrapolated to 0 K). By default force_consistent=False. scale_fmax: float - The scaling of the fmax for the ML-NEB runs. - It makes the path converge tighter on surrogate surface. + The scaling of the fmax convergence criteria. + It makes the structure(s) converge tighter on surrogate + surface. unc_convergence: float Maximum uncertainty for convergence in the active learning (in eV). From 70ad052baba81630738e0fa4c7d7e2844d5eeb45 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Fri, 1 Aug 2025 14:33:47 +0200 Subject: [PATCH 167/194] Use 5 local steps in extra_initial_data --- catlearn/activelearning/randomadsorption.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/catlearn/activelearning/randomadsorption.py b/catlearn/activelearning/randomadsorption.py index bf16f835..45979281 100644 --- a/catlearn/activelearning/randomadsorption.py +++ b/catlearn/activelearning/randomadsorption.py @@ -148,8 +148,9 @@ def __init__( extrapolated to 0 K). By default force_consistent=False. scale_fmax: float - The scaling of the fmax for the ML-NEB runs. - It makes the path converge tighter on surrogate surface. + The scaling of the fmax convergence criteria. + It makes the structure(s) converge tighter on surrogate + surface. use_fmax_convergence: bool Whether to use the maximum force as an convergence criterion. unc_convergence: float @@ -393,7 +394,7 @@ def extra_initial_data(self, **kwargs): method_extra.set_calculator( MieCalculator(r_scale=1.2, denergy=0.2) ) - method_extra.run(fmax=0.1, steps=21) + method_extra.run(fmax=0.1, steps=25) atoms = method_extra.get_candidates()[0] # Evaluate the structure self.evaluate(atoms) From d8fd74acf28baf371d8d5ab9c7971b7c75ccbd1f Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Fri, 1 Aug 2025 14:34:12 +0200 Subject: [PATCH 168/194] Use max steps in parallel method and not sum --- catlearn/optimizer/parallelopt.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/catlearn/optimizer/parallelopt.py b/catlearn/optimizer/parallelopt.py index 720dfd35..a6908c0b 100644 --- a/catlearn/optimizer/parallelopt.py +++ b/catlearn/optimizer/parallelopt.py @@ -187,7 +187,7 @@ def run( ) ) # Get the number of steps - self.steps += sum( + self.steps += max( [ broadcast( method.get_number_of_steps(), From 3b71d8d0d6fc7ed47fbbf0735aa4b35ce5688d12 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Fri, 1 Aug 2025 14:47:04 +0200 Subject: [PATCH 169/194] Change default reuse_data_local to False --- catlearn/activelearning/mlgo.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/catlearn/activelearning/mlgo.py b/catlearn/activelearning/mlgo.py index f4958221..3c1977fb 100644 --- a/catlearn/activelearning/mlgo.py +++ b/catlearn/activelearning/mlgo.py @@ -30,7 +30,7 @@ def __init__( chains=None, local_opt=FIRE, local_opt_kwargs={}, - reuse_data_local=True, + reuse_data_local=False, acq=None, save_memory=False, parallel_run=False, From 77cbb527c55421b15372f645b2ebc7773910c618 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Mon, 4 Aug 2025 08:35:53 +0200 Subject: [PATCH 170/194] Use cutoff for RepulsionCalculator as default --- catlearn/regression/gp/baseline/repulsive.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/catlearn/regression/gp/baseline/repulsive.py b/catlearn/regression/gp/baseline/repulsive.py index 57ae44bc..bf5ca5fa 100644 --- a/catlearn/regression/gp/baseline/repulsive.py +++ b/catlearn/regression/gp/baseline/repulsive.py @@ -28,7 +28,7 @@ def __init__( mic=False, all_ncells=True, cell_cutoff=4.0, - use_cutoff=False, + use_cutoff=True, rs_cutoff=3.0, re_cutoff=4.0, r_scale=0.7, From 1f4fbeb01b2cd83a14d10c0205b5e216dcbe002e Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Mon, 4 Aug 2025 09:13:02 +0200 Subject: [PATCH 171/194] Reduce scale_fmax if structure is in database --- catlearn/activelearning/activelearning.py | 23 ++++++++++++++++++--- catlearn/activelearning/adsorption.py | 10 +++++++-- catlearn/activelearning/local.py | 10 +++++++-- catlearn/activelearning/mlgo.py | 10 +++++++-- catlearn/activelearning/mlneb.py | 10 +++++++-- catlearn/activelearning/randomadsorption.py | 10 +++++++-- 6 files changed, 60 insertions(+), 13 deletions(-) diff --git a/catlearn/activelearning/activelearning.py b/catlearn/activelearning/activelearning.py index af7f18ec..63c4600d 100644 --- a/catlearn/activelearning/activelearning.py +++ b/catlearn/activelearning/activelearning.py @@ -114,9 +114,12 @@ def __init__( extrapolated to 0 K). By default force_consistent=False. scale_fmax: float - The scaling of the fmax convergence criteria. + The scaling of the fmax convergence criterion. It makes the structure(s) converge tighter on surrogate surface. + If use_database_check is True and the structure is in the + database, then the scale_fmax is multiplied by the original + scale_fmax to give tighter convergence. use_fmax_convergence: bool Whether to use the maximum force as an convergence criterion. unc_convergence: float @@ -151,6 +154,9 @@ def __init__( If it is in the database, the structure is rattled. Please be aware that the predicted structure will differ from the structure in the database if the rattling is applied. + If use_database_check is True and the structure is in the + database, then the scale_fmax is multiplied by the original + scale_fmax to give tighter convergence. data_perturb: float The perturbation of the data structure if it is in the database and use_database_check is True. @@ -901,9 +907,12 @@ def update_arguments( extrapolated to 0 K). By default force_consistent=False. scale_fmax: float - The scaling of the fmax convergence criteria. + The scaling of the fmax convergence criterion. It makes the structure(s) converge tighter on surrogate surface. + If use_database_check is True and the structure is in the + database, then the scale_fmax is multiplied by the original + scale_fmax to give tighter convergence. use_fmax_convergence: bool Whether to use the maximum force as an convergence criterion. unc_convergence: float @@ -938,6 +947,9 @@ def update_arguments( If it is in the database, the structure is rattled. Please be aware that the predicted structure will differ from the structure in the database if the rattling is applied. + If use_database_check is True and the structure is in the + database, then the scale_fmax is multiplied by the original + scale_fmax to give tighter convergence. data_perturb: float The perturbation of the data structure if it is in the database and use_database_check is True. @@ -1044,8 +1056,11 @@ def update_arguments( self.force_consistent = force_consistent elif not hasattr(self, "force_consistent"): self.force_consistent = False + if scale_fmax is None and not hasattr(self, "scale_fmax"): + scale_fmax = 1.0 if scale_fmax is not None: self.scale_fmax = abs(float(scale_fmax)) + self.scale_fmax_org = self.scale_fmax if use_fmax_convergence is not None: self.use_fmax_convergence = use_fmax_convergence if unc_convergence is not None: @@ -1579,6 +1594,8 @@ def ensure_candidate_not_in_database( ) self.pred_energies[0] = self.get_true_predicted_energy(candidate) self.uncertainties[0] = candidate.calc.results["uncertainty"] + # Rescale the fmax criterion + self.scale_fmax *= self.scale_fmax_org return candidate def store_best_data(self, atoms, **kwargs): @@ -2077,7 +2094,7 @@ def get_arguments(self): verbose=self.verbose, apply_constraint=self.apply_constraint, force_consistent=self.force_consistent, - scale_fmax=self.scale_fmax, + scale_fmax=self.scale_fmax_org, use_fmax_convergence=self.use_fmax_convergence, unc_convergence=self.unc_convergence, use_method_unc_conv=self.use_method_unc_conv, diff --git a/catlearn/activelearning/adsorption.py b/catlearn/activelearning/adsorption.py index a37a0181..46647c31 100644 --- a/catlearn/activelearning/adsorption.py +++ b/catlearn/activelearning/adsorption.py @@ -119,9 +119,12 @@ def __init__( extrapolated to 0 K). By default force_consistent=False. scale_fmax: float - The scaling of the fmax convergence criteria. + The scaling of the fmax convergence criterion. It makes the structure(s) converge tighter on surrogate surface. + If use_database_check is True and the structure is in the + database, then the scale_fmax is multiplied by the original + scale_fmax to give tighter convergence. use_fmax_convergence: bool Whether to use the maximum force as an convergence criterion. unc_convergence: float @@ -140,6 +143,9 @@ def __init__( If it is in the database, the structure is rattled. Please be aware that the predicted structure will differ from the structure in the database if the rattling is applied. + If use_database_check is True and the structure is in the + database, then the scale_fmax is multiplied by the original + scale_fmax to give tighter convergence. data_perturb: float The perturbation of the data structure if it is in the database and use_database_check is True. @@ -402,7 +408,7 @@ def get_arguments(self): verbose=self.verbose, apply_constraint=self.apply_constraint, force_consistent=self.force_consistent, - scale_fmax=self.scale_fmax, + scale_fmax=self.scale_fmax_org, use_fmax_convergence=self.use_fmax_convergence, unc_convergence=self.unc_convergence, use_method_unc_conv=self.use_method_unc_conv, diff --git a/catlearn/activelearning/local.py b/catlearn/activelearning/local.py index 4ab4e447..2ecf1771 100644 --- a/catlearn/activelearning/local.py +++ b/catlearn/activelearning/local.py @@ -104,9 +104,12 @@ def __init__( extrapolated to 0 K). By default force_consistent=False. scale_fmax: float - The scaling of the fmax convergence criteria. + The scaling of the fmax convergence criterion. It makes the structure(s) converge tighter on surrogate surface. + If use_database_check is True and the structure is in the + database, then the scale_fmax is multiplied by the original + scale_fmax to give tighter convergence. use_fmax_convergence: bool Whether to use the maximum force as an convergence criterion. unc_convergence: float @@ -141,6 +144,9 @@ def __init__( If it is in the database, the structure is rattled. Please be aware that the predicted structure will differ from the structure in the database if the rattling is applied. + If use_database_check is True and the structure is in the + database, then the scale_fmax is multiplied by the original + scale_fmax to give tighter convergence. data_perturb: float The perturbation of the data structure if it is in the database and use_database_check is True. @@ -333,7 +339,7 @@ def get_arguments(self): verbose=self.verbose, apply_constraint=self.apply_constraint, force_consistent=self.force_consistent, - scale_fmax=self.scale_fmax, + scale_fmax=self.scale_fmax_org, use_fmax_convergence=self.use_fmax_convergence, unc_convergence=self.unc_convergence, use_method_unc_conv=self.use_method_unc_conv, diff --git a/catlearn/activelearning/mlgo.py b/catlearn/activelearning/mlgo.py index 3c1977fb..7d70d875 100644 --- a/catlearn/activelearning/mlgo.py +++ b/catlearn/activelearning/mlgo.py @@ -147,9 +147,12 @@ def __init__( extrapolated to 0 K). By default force_consistent=False. scale_fmax: float - The scaling of the fmax convergence criteria. + The scaling of the fmax convergence criterion. It makes the structure(s) converge tighter on surrogate surface. + If use_database_check is True and the structure is in the + database, then the scale_fmax is multiplied by the original + scale_fmax to give tighter convergence. use_fmax_convergence: bool Whether to use the maximum force as an convergence criterion. unc_convergence: float @@ -185,6 +188,9 @@ def __init__( If it is in the database, the structure is rattled. Please be aware that the predicted structure will differ from the structure in the database if the rattling is applied. + If use_database_check is True and the structure is in the + database, then the scale_fmax is multiplied by the original + scale_fmax to give tighter convergence. data_perturb: float The perturbation of the data structure if it is in the database and use_database_check is True. @@ -553,7 +559,7 @@ def get_arguments(self): verbose=self.verbose, apply_constraint=self.apply_constraint, force_consistent=self.force_consistent, - scale_fmax=self.scale_fmax, + scale_fmax=self.scale_fmax_org, use_fmax_convergence=self.use_fmax_convergence, unc_convergence=self.unc_convergence, use_method_unc_conv=self.use_method_unc_conv, diff --git a/catlearn/activelearning/mlneb.py b/catlearn/activelearning/mlneb.py index 7a2c10c8..c71065f2 100644 --- a/catlearn/activelearning/mlneb.py +++ b/catlearn/activelearning/mlneb.py @@ -157,9 +157,12 @@ def __init__( extrapolated to 0 K). By default force_consistent=False. scale_fmax: float - The scaling of the fmax convergence criteria. + The scaling of the fmax convergence criterion. It makes the structure(s) converge tighter on surrogate surface. + If use_database_check is True and the structure is in the + database, then the scale_fmax is multiplied by the original + scale_fmax to give tighter convergence. unc_convergence: float Maximum uncertainty for convergence in the active learning (in eV). @@ -192,6 +195,9 @@ def __init__( If it is in the database, the structure is rattled. Please be aware that the predicted structure will differ from the structure in the database if the rattling is applied. + If use_database_check is True and the structure is in the + database, then the scale_fmax is multiplied by the original + scale_fmax to give tighter convergence. data_perturb: float The perturbation of the data structure if it is in the database and use_database_check is True. @@ -500,7 +506,7 @@ def get_arguments(self): verbose=self.verbose, apply_constraint=self.apply_constraint, force_consistent=self.force_consistent, - scale_fmax=self.scale_fmax, + scale_fmax=self.scale_fmax_org, unc_convergence=self.unc_convergence, use_method_unc_conv=self.use_method_unc_conv, use_restart=self.use_restart, diff --git a/catlearn/activelearning/randomadsorption.py b/catlearn/activelearning/randomadsorption.py index 45979281..06dca883 100644 --- a/catlearn/activelearning/randomadsorption.py +++ b/catlearn/activelearning/randomadsorption.py @@ -148,9 +148,12 @@ def __init__( extrapolated to 0 K). By default force_consistent=False. scale_fmax: float - The scaling of the fmax convergence criteria. + The scaling of the fmax convergence criterion. It makes the structure(s) converge tighter on surrogate surface. + If use_database_check is True and the structure is in the + database, then the scale_fmax is multiplied by the original + scale_fmax to give tighter convergence. use_fmax_convergence: bool Whether to use the maximum force as an convergence criterion. unc_convergence: float @@ -183,6 +186,9 @@ def __init__( If it is in the database, the structure is rattled. Please be aware that the predicted structure will differ from the structure in the database if the rattling is applied. + If use_database_check is True and the structure is in the + database, then the scale_fmax is multiplied by the original + scale_fmax to give tighter convergence. data_perturb: float The perturbation of the data structure if it is in the database and use_database_check is True. @@ -431,7 +437,7 @@ def get_arguments(self): verbose=self.verbose, apply_constraint=self.apply_constraint, force_consistent=self.force_consistent, - scale_fmax=self.scale_fmax, + scale_fmax=self.scale_fmax_org, use_fmax_convergence=self.use_fmax_convergence, unc_convergence=self.unc_convergence, use_method_unc_conv=self.use_method_unc_conv, From bfac099147e9306b35c509c849546a47d393a3ce Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Thu, 7 Aug 2025 14:09:33 +0200 Subject: [PATCH 172/194] Implement cutoff and activation functions --- catlearn/regression/gp/baseline/repulsive.py | 4 +- .../regression/gp/fingerprint/geometry.py | 124 +++++++++++++++--- .../regression/gp/fingerprint/invdistances.py | 4 +- 3 files changed, 113 insertions(+), 19 deletions(-) diff --git a/catlearn/regression/gp/baseline/repulsive.py b/catlearn/regression/gp/baseline/repulsive.py index bf5ca5fa..acc803f9 100644 --- a/catlearn/regression/gp/baseline/repulsive.py +++ b/catlearn/regression/gp/baseline/repulsive.py @@ -4,7 +4,7 @@ from ..fingerprint.geometry import ( get_constraints, get_full_distance_matrix, - cosine_cutoff, + fp_cosine_cutoff, ) @@ -357,7 +357,7 @@ def get_inv_dis( deriv = None # Calculate the cutoff function if self.use_cutoff: - inv_dist, deriv = cosine_cutoff( + inv_dist, deriv = fp_cosine_cutoff( inv_dist, deriv, rs_cutoff=self.rs_cutoff, diff --git a/catlearn/regression/gp/fingerprint/geometry.py b/catlearn/regression/gp/fingerprint/geometry.py index 9ea110a1..86fb23b8 100644 --- a/catlearn/regression/gp/fingerprint/geometry.py +++ b/catlearn/regression/gp/fingerprint/geometry.py @@ -849,7 +849,100 @@ def get_periodic_softmax( return fp, g -def cosine_cutoff(fp, g, rs_cutoff=3.0, re_cutoff=4.0, eps=1e-16, **kwargs): +def cosine_cutoff( + x, + use_derivatives=False, + xs_cutoff=3.0, + xe_cutoff=4.0, + **kwargs, +): + """ + Cosine cutoff function. + Modification of eq. 24 in https://doi.org/10.1002/qua.24927. + + Parameters: + x: float or array of floats + The input values for the cutoff function. + use_derivatives: bool + If the derivatives of the cutoff function should be returned. + xs_cutoff: float + The start of the cutoff function. + xe_cutoff: float + The end of the cutoff function. + + Returns: + fc: float or array of floats + The cutoff function values. + The fingerprint. + gc: float or array of floats + The derivatives of the cutoff function. + """ + # Calculate the scale of the cutoff function + x_scale = xe_cutoff - xs_cutoff + # Calculate the cutoff function + fc_inner = pi * (x - xs_cutoff) / x_scale + fc = 0.5 * (1.0 + cos(fc_inner)) + # Crop the cutoff function + fc_rs = x <= xs_cutoff + fc_re = x >= xe_cutoff + fc = where(fc_rs, 1.0, fc) + fc = where(fc_re, 0.0, fc) + # Calculate the derivative of the cutoff function + if use_derivatives: + gc = (-0.5 * pi / x_scale) * sin(fc_inner) + gc = where(fc_rs, 0.0, gc) + gc = where(fc_re, 0.0, gc) + return fc, gc + return fc, None + + +def sine_activation( + x, + use_derivatives=False, + xs_activation=3.0, + xe_activation=4.0, + **kwargs, +): + """ + Sine activation function. + + Parameters: + x: float or array of floats + The input values for the activation function. + use_derivatives: bool + If the derivatives of the activation function should be returned. + xs_activation: float + The start of the activation function. + xe_activation: float + The end of the activation function. + + Returns: + fc: float or array of floats + The activation function values. + The fingerprint. + gc: float or array of floats + The derivatives of the activation function. + """ + # Calculate the scale of the activation function + x_scale = xe_activation - xs_activation + # Calculate the activation function + fc_inner = pi * (x - xs_activation) / x_scale + fc = 0.5 * (1.0 - cos(fc_inner)) + # Crop the activation function + fc_rs = x <= xs_activation + fc_re = x >= xe_activation + fc = where(fc_rs, 0.0, fc) + fc = where(fc_re, 1.0, fc) + # Calculate the derivative of the activation function + if use_derivatives: + gc = (0.5 * pi / x_scale) * sin(fc_inner) + gc = where(fc_rs, 0.0, gc) + gc = where(fc_re, 0.0, gc) + return fc, gc + return fc, None + + +def fp_cosine_cutoff(fp, g, rs_cutoff=3.0, re_cutoff=4.0, eps=1e-16, **kwargs): """ Cosine cutoff function. Modification of eq. 24 in https://doi.org/10.1002/qua.24927. @@ -874,23 +967,24 @@ def cosine_cutoff(fp, g, rs_cutoff=3.0, re_cutoff=4.0, eps=1e-16, **kwargs): g: (N, Nnm, 3) or (Nnm*Nm+(Nnm*(Nnm-1)/2), 3) array The derivatives of the fingerprint. """ - # Find the scale of the cutoff function - rscale = re_cutoff - rs_cutoff # Calculate the inverse fingerprint with small number added fp_inv = 1.0 / (fp + eps) - # Calculate the cutoff function - fc_inner = pi * (fp_inv - rs_cutoff) / rscale - fc = 0.5 * (cos(fc_inner) + 1.0) - # Crop the cutoff function - fp_rs = fp_inv < rs_cutoff - fp_re = fp_inv > re_cutoff - fc = where(fp_rs, 1.0, fc) - fc = where(fp_re, 0.0, fc) - # Calculate the derivative of the cutoff function + # Check if the derivatives are requested if g is not None: - gc = (0.5 * pi / rscale) * sin(fc_inner) * (fp_inv**2) - gc = where(fp_rs, 0.0, gc) - gc = where(fp_re, 0.0, gc) + use_derivatives = True + else: + use_derivatives = False + # Calculate the cutoff function + fc, gc = cosine_cutoff( + fp_inv, + use_derivatives=use_derivatives, + xs_cutoff=rs_cutoff, + xe_cutoff=re_cutoff, + **kwargs, + ) + # If the derivatives are requested, calculate them + if use_derivatives: + gc *= fp_inv**2 g = g * (fc + fp * gc)[..., None] # Multiply the fingerprint with the cutoff function fp = fp * fc diff --git a/catlearn/regression/gp/fingerprint/invdistances.py b/catlearn/regression/gp/fingerprint/invdistances.py index 59829ab9..ccf2191c 100644 --- a/catlearn/regression/gp/fingerprint/invdistances.py +++ b/catlearn/regression/gp/fingerprint/invdistances.py @@ -1,5 +1,5 @@ from .geometry import ( - cosine_cutoff, + fp_cosine_cutoff, get_covalent_distances, get_periodic_softmax, get_periodic_sum, @@ -286,7 +286,7 @@ def calc_fp( def apply_cutoff(self, fp, g, **kwargs): "Get the cutoff function." - return cosine_cutoff( + return fp_cosine_cutoff( fp, g, rs_cutoff=self.rs_cutoff, From 920d1df224c78ef87d8aef60a8f0db576eb0972e Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Thu, 7 Aug 2025 14:10:14 +0200 Subject: [PATCH 173/194] Maximum allowed uncertainty for BOCalculator --- catlearn/regression/gp/calculator/bocalc.py | 70 ++++++++++++++++++++- 1 file changed, 67 insertions(+), 3 deletions(-) diff --git a/catlearn/regression/gp/calculator/bocalc.py b/catlearn/regression/gp/calculator/bocalc.py index 0986f464..52df343f 100644 --- a/catlearn/regression/gp/calculator/bocalc.py +++ b/catlearn/regression/gp/calculator/bocalc.py @@ -1,4 +1,5 @@ from .mlcalc import MLCalculator +from ..fingerprint.geometry import sine_activation from ase.calculators.calculator import Calculator, all_changes @@ -33,6 +34,8 @@ def __init__( calc_kwargs={}, round_pred=None, kappa=2.0, + max_unc=None, + max_unc_scale=0.95, **kwargs, ): """ @@ -63,6 +66,12 @@ def __init__( kappa: float The weight of the uncertainty relative to the energy. If kappa>0, the uncertainty is added to the predicted energy. + max_unc: float (optional) + The maximum uncertainty value that can be added to the energy. + If the uncertainty is larger than the max_unc_scale times this + value, the cutoff is activated to limit the uncertainty. + max_unc_scale: float (optional) + The scale of the maximum uncertainty value to start the cutoff. """ super().__init__( mlmodel=mlmodel, @@ -73,6 +82,8 @@ def __init__( calc_kwargs=calc_kwargs, round_pred=round_pred, kappa=kappa, + max_unc=max_unc, + max_unc_scale=max_unc_scale, **kwargs, ) @@ -167,11 +178,23 @@ def modify_results_bo( self.results["predicted forces"] = self.results["forces"].copy() # Calculate the acquisition function and its derivative if self.kappa != 0.0: - self.results["energy"] += self.kappa * self.results["uncertainty"] + # Get the uncertainty and its derivatives + unc = self.results["uncertainty"] if get_forces: - self.results["forces"] -= ( - self.kappa * self.results["uncertainty derivatives"] + unc_deriv = self.results["uncertainty derivatives"] + else: + unc_deriv = None + # Limit the uncertainty to the maximum uncertainty + if self.max_unc is not None and unc > self.max_unc_start: + unc, unc_deriv = self.max_unc_activation( + unc, + unc_deriv=unc_deriv, + use_derivatives=get_forces, ) + # Add the uncertainty to the energy and forces + self.results["energy"] += self.kappa * unc + if get_forces: + self.results["forces"] -= self.kappa * unc_deriv return self.results def update_arguments( @@ -184,6 +207,8 @@ def update_arguments( calc_kwargs=None, round_pred=None, kappa=None, + max_unc=None, + max_unc_scale=None, **kwargs, ): """ @@ -214,6 +239,12 @@ def update_arguments( If None, the predictions are not rounded. kappa: float The weight of the uncertainty relative to the energy. + max_unc: float (optional) + The maximum uncertainty value that can be added to the energy. + If the uncertainty is larger than the max_unc_scale times this + value, the cutoff is activated to limit the uncertainty. + max_unc_scale: float (optional) + The scale of the maximum uncertainty value to start the cutoff. Returns: self: The updated object itself. @@ -231,6 +262,21 @@ def update_arguments( # Set the kappa value if kappa is not None: self.set_kappa(kappa) + elif not hasattr(self, "kappa"): + self.set_kappa(0.0) + # Set the maximum uncertainty value + if max_unc is not None: + self.max_unc = abs(float(max_unc)) + elif not hasattr(self, "max_unc"): + self.max_unc = None + if max_unc_scale is not None: + self.max_unc_scale = float(max_unc_scale) + if self.max_unc_scale > 1.0: + raise ValueError( + "max_unc_scale must be less than or equal to 1.0" + ) + if self.max_unc is not None: + self.max_unc_start = self.max_unc_scale * self.max_unc return self def set_kappa(self, kappa, **kwargs): @@ -282,6 +328,22 @@ def get_property_arguments(self, properties=[], **kwargs): get_unc_derivatives, ) + def max_unc_activation(self, unc, unc_deriv=None, use_derivatives=False): + # Calculate the activation function + fc, gc = sine_activation( + unc, + use_derivatives=use_derivatives, + xs_activation=self.max_unc_start, + xe_activation=self.max_unc, + ) + # Calculate the derivative of the uncertainty + if use_derivatives: + unc_deriv = unc_deriv * (1.0 - fc) + unc_deriv += gc * (self.max_unc_start - unc) + # Apply the activation function to the uncertainty + unc = (unc * (1.0 - fc)) + (self.max_unc * fc) + return unc, unc_deriv + def get_arguments(self): "Get the arguments of the class itself." # Get the arguments given to the class in the initialization @@ -294,6 +356,8 @@ def get_arguments(self): calc_kwargs=self.calc_kwargs, round_pred=self.round_pred, kappa=self.kappa, + max_unc=self.max_unc, + max_unc_scale=self.max_unc_scale, ) # Get the constants made within the class constant_kwargs = dict() From bea2262a3e2d2463a014309431a40dc2e057add2 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Thu, 7 Aug 2025 16:45:30 +0200 Subject: [PATCH 174/194] Implement calculation of predicted covariance matrix --- catlearn/regression/gp/models/model.py | 71 ++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/catlearn/regression/gp/models/model.py b/catlearn/regression/gp/models/model.py index 0e7fd598..0a10b01c 100644 --- a/catlearn/regression/gp/models/model.py +++ b/catlearn/regression/gp/models/model.py @@ -412,6 +412,77 @@ def calculate_variance_derivatives(self, features, KQX=None, **kwargs): # Rearrange derivative of variance return var_deriv.reshape(m_data, -1, order="F") + def predict_covariance( + self, + features, + KQX=None, + get_derivatives=False, + include_noise=False, + **kwargs, + ): + """ + Calculate the predicted covariance matrix of the test targets. + + Parameters: + features: (M,D) array or (M) list of fingerprint objects + Test features with M data points. + KQX: (M,N) or (M,N+N*D) or (M+M*D,N+N*D) array + The kernel matrix of the test and training features. + If KQX=None, it is calculated. + get_derivatives: bool + Whether to predict the uncertainty of the derivatives of + the targets. + include_noise: bool + Whether to include the noise of data in the predicted variance + + Returns: + var: (M,M) array + The predicted covariance matrix of the targets + if get_derivatives=False. + or + var: (M*(1+D),M*(1+D)) array + The predicted covariance matrix of the targets + and its derivatives if get_derivatives=True. + + """ + # Check if the model is trained + if not self.trained_model: + raise AttributeError("The model is not trained!") + # Get the number of test points + n_data = len(features) + # Calculate the kernel of test and training data if it is not given + if KQX is None: + KQX = self.get_kernel( + features, + self.features, + get_derivatives=get_derivatives, + ) + else: + if not get_derivatives: + KQX = KQX[:n_data] + # Calculate the kernel matrix of the test data + KQQ = self.get_kernel( + features, + get_derivatives=get_derivatives, + ) + # Add noise to the diagonal of the kernel matrix + if include_noise: + add_v = self.inf_to_num(exp(2.0 * self.hp["noise"][0])) + self.corr + m_data = len(KQQ) + if "noise_deriv" in self.hp: + KQQ[range(n_data), range(n_data)] += add_v + add_v = self.inf_to_num(exp(2.0 * self.hp["noise_deriv"][0])) + add_v += self.corr + KQQ[range(n_data, m_data), range(n_data, m_data)] += add_v + else: + KQQ[range(m_data), range(m_data)] += add_v + # Calculate predicted variance + var = KQQ - matmul(KQX, self.calculate_CinvKQX(KQX)) + # Scale prediction variance with the prefactor + var = var * self.prefactor + # Return the predicted covariance matrix + return var + def set_hyperparams(self, new_params, **kwargs): """ Set or update the hyperparameters for the model. From d2157ac2a7e8caca3643565d032e0b40d0270dcf Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Mon, 11 Aug 2025 08:21:19 +0200 Subject: [PATCH 175/194] Implement max_unc and trust distance for global search --- catlearn/optimizer/adsorption.py | 47 +++++++++++++++++++++++--- catlearn/optimizer/randomadsorption.py | 25 ++++++++++++++ 2 files changed, 67 insertions(+), 5 deletions(-) diff --git a/catlearn/optimizer/adsorption.py b/catlearn/optimizer/adsorption.py index 1147ba6c..6e26703c 100644 --- a/catlearn/optimizer/adsorption.py +++ b/catlearn/optimizer/adsorption.py @@ -2,7 +2,7 @@ from ase.parallel import world from ase.constraints import FixAtoms, FixBondLengths import itertools -from numpy import array, asarray, concatenate, cos, matmul, pi, sin +from numpy import array, asarray, concatenate, cos, inf, matmul, pi, sin from numpy.linalg import norm from scipy import __version__ as scipy_version from scipy.optimize import dual_annealing @@ -262,6 +262,7 @@ def run( fmax=0.05, steps=1000000, max_unc=None, + dtrust=None, unc_convergence=None, **kwargs, ): @@ -270,15 +271,27 @@ def run( return self._converged # Use original constraints self.optimizable.set_constraint(self.constraints_used) + # Initialize the best energy and position + self.best_energy = inf + self.best_pos = None + self.best_energy_no_crit = inf + self.best_pos_no_crit = None # Perform the simulated annealing - sol = dual_annealing( + dual_annealing( self.evaluate_value, + args=(max_unc, dtrust), bounds=self.bounds, maxfun=steps, **self.opt_kwargs, ) + # Return the best position and number of steps + if self.best_energy == inf: + self.message( + "Uncertainty or trust distance is above the maximum allowed." + ) + self.best_pos = self.best_pos_no_crit.copy() # Set the positions - self.evaluate_value(sol["x"]) + self.evaluate_value(self.best_pos) # Set the new constraints self.optimizable.set_constraint(self.constraints_new) # Calculate the maximum force to check convergence @@ -287,6 +300,7 @@ def run( self._converged = self.check_convergence( converged=True, max_unc=max_unc, + dtrust=dtrust, unc_convergence=unc_convergence, ) return self._converged @@ -436,14 +450,37 @@ def get_new_positions(self, x, **kwargs): pos[n_all:] = pos_ads2 return pos - def evaluate_value(self, x, **kwargs): + def evaluate_value(self, x, max_unc=None, dtrust=None, **kwargs): "Evaluate the value of the adsorption." # Get the new positions of the adsorption pos = self.get_new_positions(x, **kwargs) # Set the positions self.optimizable.set_positions(pos) # Get the potential energy - return self.optimizable.get_potential_energy() + e = self.optimizable.get_potential_energy() + # Check if the energy is lower than the best energy + if e < self.best_energy: + # Update the best energy and position without criteria + if e < self.best_energy_no_crit: + self.best_energy_no_crit = e + self.best_pos_no_crit = x.copy() + # Check if criteria are met + is_within_crit = True + # Check if the uncertainty is above the maximum allowed + if max_unc is not None: + unc = self.get_uncertainty() + if unc > max_unc: + is_within_crit = False + # Check if the structures are within the trust distance + if dtrust is not None and is_within_crit: + within_dtrust = self.is_within_dtrust(dtrust=dtrust) + if not within_dtrust: + is_within_crit = False + # Update the best energy and position + if is_within_crit: + self.best_energy = e + self.best_pos = x.copy() + return e def get_arguments(self): "Get the arguments of the class itself." diff --git a/catlearn/optimizer/randomadsorption.py b/catlearn/optimizer/randomadsorption.py index 35186f60..5f20ce67 100644 --- a/catlearn/optimizer/randomadsorption.py +++ b/catlearn/optimizer/randomadsorption.py @@ -425,6 +425,9 @@ def get_best_drawn_structure( # Initialize the best energy and position best_energy = inf best_pos = None + best_energy_no_crit = inf + best_pos_no_crit = None + # Check each drawn structure for x in x_drawn: # Get the new positions of the adsorbate pos = self.get_new_positions(x, **kwargs) @@ -449,9 +452,31 @@ def get_best_drawn_structure( self.steps += 1 # Get the energy of the structure e = self.optimizable.get_potential_energy() + # Check if the energy is lower than the best energy if e < best_energy: + # Update the best energy and position without criteria + if e < best_energy_no_crit: + best_energy_no_crit = e + best_pos_no_crit = pos.copy() + # Check if the uncertainty is above the maximum allowed + if max_unc is not None: + unc = self.get_uncertainty() + if unc > max_unc: + continue + # Check if the structures are within the trust distance + if dtrust is not None: + within_dtrust = self.is_within_dtrust(dtrust=dtrust) + if not within_dtrust: + continue + # Update the best energy and position best_energy = e best_pos = pos.copy() + # Return the best position and number of steps + if best_energy == inf: + self.message( + "Uncertainty or trust distance is above the maximum allowed." + ) + return best_pos_no_crit, steps return best_pos, steps def rotation_matrix(self, angles, positions): From 42a054763ec01f48bdf50b57964667e4b44aa13e Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Mon, 11 Aug 2025 08:21:54 +0200 Subject: [PATCH 176/194] Use last two structures in local optimization --- catlearn/activelearning/mlgo.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/catlearn/activelearning/mlgo.py b/catlearn/activelearning/mlgo.py index 7d70d875..2aabd650 100644 --- a/catlearn/activelearning/mlgo.py +++ b/catlearn/activelearning/mlgo.py @@ -115,6 +115,8 @@ def __init__( reuse_data_local: bool Whether to reuse the data from the global optimization in the ML-calculator for the local optimization. + If reuse_data_local is False, the last two structures + are used to train the local ML-calculator. acq: Acquisition class instance. The Acquisition instance used for calculating the acq. function and choose a candidate to calculate next. @@ -524,8 +526,8 @@ def use_prev_calculations(self, prev_calculations=None, **kwargs): index_local = bool_constraints.index(False) prev_calculations = prev_calculations[index_local:] else: - # Use only the last calculation - prev_calculations = prev_calculations[-1:] + # Use only the last two calculations + prev_calculations = prev_calculations[-2:] # Remove the constraints from the previous calculations prev_calculations = self.rm_constraints( self.get_structures(get_all=False, allow_calculation=False), From ba83828ce44d1388d19bd0524a77859659853184 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Mon, 11 Aug 2025 08:22:21 +0200 Subject: [PATCH 177/194] Debug saving of mlcalc --- catlearn/activelearning/activelearning.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/catlearn/activelearning/activelearning.py b/catlearn/activelearning/activelearning.py index 63c4600d..6073b091 100644 --- a/catlearn/activelearning/activelearning.py +++ b/catlearn/activelearning/activelearning.py @@ -16,7 +16,7 @@ from time import time import warnings from ..regression.gp.calculator import BOCalculator, compare_atoms, copy_atoms -from ..regression.gp.means.max import Prior_max +from ..regression.gp.means import Prior_max from ..regression.gp.baseline import BornRepulsionCalculator @@ -1788,7 +1788,8 @@ def save_mlcalc(self, filename="mlcalc.pkl", **kwargs): Returns: self: The object itself. """ - self.mlcalc.save_mlcalc(filename, **kwargs) + if self.rank == 0: + self.mlcalc.save_mlcalc(filename, **kwargs) return self def get_mlcalc(self, copy_mlcalc=True, **kwargs): From ed52f60985810466590c88108eefb292ab68edf0 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Tue, 12 Aug 2025 09:22:36 +0200 Subject: [PATCH 178/194] Implement max_unc and dtrust in global optimization together with argument to restrict initial opt --- catlearn/activelearning/randomadsorption.py | 42 +++++- catlearn/optimizer/randomadsorption.py | 135 +++++++++++++++----- 2 files changed, 145 insertions(+), 32 deletions(-) diff --git a/catlearn/activelearning/randomadsorption.py b/catlearn/activelearning/randomadsorption.py index 06dca883..153947ba 100644 --- a/catlearn/activelearning/randomadsorption.py +++ b/catlearn/activelearning/randomadsorption.py @@ -26,6 +26,7 @@ def __init__( n_random_draws=200, use_initial_opt=False, initial_fmax=0.2, + initial_steps=50, use_repulsive_check=True, repulsive_tol=0.1, repulsive_calculator=BornRepulsionCalculator(), @@ -43,6 +44,7 @@ def __init__( use_fmax_convergence=True, unc_convergence=0.02, use_method_unc_conv=True, + use_restart=True, check_unc=True, check_energy=True, check_fmax=True, @@ -105,6 +107,9 @@ def __init__( with lowest energy are local optimized. initial_fmax: float The maximum force for the initial local optimizations. + initial_steps: int + The maximum number of steps for the initial local + optimizations. use_repulsive_check: bool If True, a energy will be calculated for each randomly drawn structure to check if the energy is not too large. @@ -162,6 +167,10 @@ def __init__( use_method_unc_conv: bool Whether to use the unc_convergence as a convergence criterion in the optimization method. + use_restart: bool + Use the result from last robust iteration. + Be aware that restart and low max_unc can result in only the + initial structure passing the maximum uncertainty criterion. check_unc: bool Check if the uncertainty is large for the restarted result and if it is then use the previous initial. @@ -256,8 +265,10 @@ def __init__( adsorbate2=adsorbate2, bounds=bounds, n_random_draws=n_random_draws, + use_initial_struc=use_restart, use_initial_opt=use_initial_opt, initial_fmax=initial_fmax, + initial_steps=initial_steps, use_repulsive_check=use_repulsive_check, repulsive_tol=repulsive_tol, repulsive_calculator=repulsive_calculator, @@ -267,6 +278,7 @@ def __init__( parallel_run=parallel_run, comm=comm, verbose=verbose, + seed=seed, ) # Initialize the BayesianOptimizer super().__init__( @@ -285,7 +297,7 @@ def __init__( use_fmax_convergence=use_fmax_convergence, unc_convergence=unc_convergence, use_method_unc_conv=use_method_unc_conv, - use_restart=False, + use_restart=use_restart, check_unc=check_unc, check_energy=check_energy, check_fmax=check_fmax, @@ -321,8 +333,10 @@ def build_method( adsorbate2=None, bounds=None, n_random_draws=20, - use_initial_opt=True, + use_initial_struc=True, + use_initial_opt=False, initial_fmax=0.2, + initial_steps=50, use_repulsive_check=True, repulsive_tol=0.1, repulsive_calculator=BornRepulsionCalculator(), @@ -332,6 +346,7 @@ def build_method( parallel_run=False, comm=world, verbose=False, + seed=None, **kwargs, ): "Build the optimization method." @@ -344,8 +359,10 @@ def build_method( self.adsorbate2 = None self.bounds = bounds self.n_random_draws = n_random_draws + self.use_initial_struc = use_initial_struc self.use_initial_opt = use_initial_opt self.initial_fmax = initial_fmax + self.initial_steps = initial_steps self.use_repulsive_check = use_repulsive_check self.repulsive_tol = repulsive_tol self.repulsive_calculator = repulsive_calculator @@ -359,8 +376,10 @@ def build_method( adsorbate2=adsorbate2, bounds=bounds, n_random_draws=n_random_draws, + use_initial_struc=use_initial_struc, use_initial_opt=use_initial_opt, initial_fmax=initial_fmax, + initial_steps=initial_steps, use_repulsive_check=use_repulsive_check, repulsive_tol=repulsive_tol, repulsive_calculator=repulsive_calculator, @@ -369,6 +388,7 @@ def build_method( parallel_run=False, comm=comm, verbose=verbose, + seed=seed, ) # Run the method in parallel if requested if parallel_run: @@ -378,6 +398,7 @@ def build_method( parallel_run=parallel_run, comm=comm, verbose=verbose, + seed=seed, ) return method @@ -411,6 +432,21 @@ def extra_initial_data(self, **kwargs): self.extra_initial_data(**kwargs) return self + def setup_default_mlcalc( + self, + kappa=-1.0, + calc_kwargs={}, + **kwargs, + ): + # Set a limit for the uncertainty + if "max_unc" not in calc_kwargs.keys(): + calc_kwargs["max_unc"] = 2.0 + return super().setup_default_mlcalc( + kappa=kappa, + calc_kwargs=calc_kwargs, + **kwargs, + ) + def get_arguments(self): "Get the arguments of the class itself." # Get the arguments given to the class in the initialization @@ -424,6 +460,7 @@ def get_arguments(self): n_random_draws=self.n_random_draws, use_initial_opt=self.use_initial_opt, initial_fmax=self.initial_fmax, + initial_steps=self.initial_steps, use_repulsive_check=self.use_repulsive_check, repulsive_tol=self.repulsive_tol, repulsive_calculator=self.repulsive_calculator, @@ -441,6 +478,7 @@ def get_arguments(self): use_fmax_convergence=self.use_fmax_convergence, unc_convergence=self.unc_convergence, use_method_unc_conv=self.use_method_unc_conv, + use_restart=self.use_restart, check_unc=self.check_unc, check_energy=self.check_energy, check_fmax=self.check_fmax, diff --git a/catlearn/optimizer/randomadsorption.py b/catlearn/optimizer/randomadsorption.py index 5f20ce67..57c430a2 100644 --- a/catlearn/optimizer/randomadsorption.py +++ b/catlearn/optimizer/randomadsorption.py @@ -23,8 +23,10 @@ def __init__( adsorbate2=None, bounds=None, n_random_draws=50, + use_initial_struc=True, use_initial_opt=False, initial_fmax=0.2, + initial_steps=50, use_repulsive_check=True, repulsive_tol=0.1, repulsive_calculator=BornRepulsionCalculator(), @@ -55,12 +57,18 @@ def __init__( if chosen. n_random_draws: int The number of random structures to be drawn. + use_initial_struc: bool + If True, the initial structure is used as one of the drawn + structures. use_initial_opt: bool If True, the initial structures, drawn from the random sampling, will be local optimized before the structure with lowest energy are local optimized. initial_fmax: float The maximum force for the initial local optimizations. + initial_steps: int + The maximum number of steps for the initial local + optimizations. use_repulsive_check: bool If True, a energy will be calculated for each randomly drawn structure to check if the energy is not too large. @@ -93,8 +101,10 @@ def __init__( # Set the parameters self.update_arguments( n_random_draws=n_random_draws, + use_initial_struc=use_initial_struc, use_initial_opt=use_initial_opt, initial_fmax=initial_fmax, + initial_steps=initial_steps, use_repulsive_check=use_repulsive_check, repulsive_tol=repulsive_tol, repulsive_calculator=repulsive_calculator, @@ -106,6 +116,8 @@ def __init__( seed=seed, **kwargs, ) + # Make initial optimizable structure + self.make_initial_structure() def create_slab_ads( self, @@ -226,8 +238,15 @@ def run( # Check if the optimization can take any steps if steps <= 0: return self._converged + # Take initial structure into account + n_random_draws = self.n_random_draws + if self.use_initial_struc: + n_random_draws -= 1 # Draw random structures - x_drawn = self.draw_random_structures() + x_drawn = self.draw_random_structures( + n_random_draws=n_random_draws, + **kwargs, + ) # Get the best drawn structure best_pos, steps = self.get_best_drawn_structure( x_drawn, @@ -274,8 +293,10 @@ def update_arguments( adsorbate2=None, bounds=None, n_random_draws=None, + use_initial_struc=None, use_initial_opt=None, initial_fmax=None, + initial_steps=None, use_repulsive_check=None, repulsive_tol=None, repulsive_calculator=None, @@ -307,12 +328,18 @@ def update_arguments( if chosen. n_random_draws: int The number of random structures to be drawn. + use_initial_struc: bool + If True, the initial structure is used as one of the drawn + structures. use_initial_opt: bool If True, the initial structures, drawn from the random sampling, will be local optimized before the structure with lowest energy are local optimized. initial_fmax: float The maximum force for the initial local optimizations. + initial_steps: int + The maximum number of steps for the initial local + optimizations. use_repulsive_check: bool If True, a energy will be calculated for each randomly drawn structure to check if the energy is not too large. @@ -365,10 +392,14 @@ def update_arguments( # Set the rest of the parameters if n_random_draws is not None: self.n_random_draws = int(n_random_draws) + if use_initial_struc is not None: + self.use_initial_struc = use_initial_struc if use_initial_opt is not None: self.use_initial_opt = use_initial_opt if initial_fmax is not None: self.initial_fmax = float(initial_fmax) + if initial_steps is not None: + self.initial_steps = int(initial_steps) if use_repulsive_check is not None: self.use_repulsive_check = use_repulsive_check if repulsive_tol is not None: @@ -379,7 +410,7 @@ def update_arguments( self.repulsive_calculator = repulsive_calculator return self - def draw_random_structures(self, **kwargs): + def draw_random_structures(self, n_random_draws=50, **kwargs): "Draw random structures for the adsorption optimization." # Get reference energy self.e_ref = self.get_reference_energy() @@ -392,7 +423,7 @@ def draw_random_structures(self, **kwargs): dummy_optimizable = self.optimizable.copy() dummy_optimizable.calc = self.repulsive_calculator # Draw random structures - while n_drawn < self.n_random_draws: + while n_drawn < n_random_draws: # Draw a random structure x = self.rng.uniform(low=self.bounds[:, 0], high=self.bounds[:, 1]) # Evaluate the value of the structure @@ -401,7 +432,7 @@ def draw_random_structures(self, **kwargs): # Check if the value is not too large if e - self.e_ref > self.repulsive_tol: failed_steps += 1 - if failed_steps > 100.0 * self.n_random_draws: + if failed_steps > 100.0 * n_random_draws: self.message( f"{failed_steps} failed drawns. " "Stopping is recommended!", @@ -423,10 +454,22 @@ def get_best_drawn_structure( ): "Get the best drawn structure from the random sampling." # Initialize the best energy and position - best_energy = inf - best_pos = None - best_energy_no_crit = inf - best_pos_no_crit = None + self.best_energy = inf + self.best_pos = None + self.best_energy_no_crit = inf + self.best_pos_no_crit = None + # Calculate the energy of the initial structure if used + if self.use_initial_struc: + # Get the energy of the structure + e = self.optimizable.get_potential_energy() + # Check if the energy is lower than the best energy + self.check_best_structure( + e=e, + pos=self.optimizable.get_positions(), + max_unc=max_unc, + dtrust=dtrust, + **kwargs, + ) # Check each drawn structure for x in x_drawn: # Get the new positions of the adsorbate @@ -439,7 +482,7 @@ def get_best_drawn_structure( _, used_steps = self.local_optimize( atoms=self.optimizable, fmax=self.initial_fmax, - steps=steps, + steps=self.initial_steps, max_unc=max_unc, dtrust=dtrust, **kwargs, @@ -453,31 +496,53 @@ def get_best_drawn_structure( # Get the energy of the structure e = self.optimizable.get_potential_energy() # Check if the energy is lower than the best energy - if e < best_energy: - # Update the best energy and position without criteria - if e < best_energy_no_crit: - best_energy_no_crit = e - best_pos_no_crit = pos.copy() - # Check if the uncertainty is above the maximum allowed - if max_unc is not None: - unc = self.get_uncertainty() - if unc > max_unc: - continue - # Check if the structures are within the trust distance - if dtrust is not None: - within_dtrust = self.is_within_dtrust(dtrust=dtrust) - if not within_dtrust: - continue - # Update the best energy and position - best_energy = e - best_pos = pos.copy() + self.check_best_structure( + e=e, + pos=pos, + max_unc=max_unc, + dtrust=dtrust, + **kwargs, + ) # Return the best position and number of steps - if best_energy == inf: + if self.best_energy == inf: self.message( "Uncertainty or trust distance is above the maximum allowed." ) - return best_pos_no_crit, steps - return best_pos, steps + return self.best_pos_no_crit, steps + return self.best_pos, steps + + def check_best_structure( + self, + e, + pos, + max_unc=None, + dtrust=None, + **kwargs, + ): + "Check if the structure is the best one." + # Check if the energy is lower than the best energy + if e < self.best_energy: + # Update the best energy and position without criteria + if e < self.best_energy_no_crit: + self.best_energy_no_crit = e + self.best_pos_no_crit = pos.copy() + # Check if criteria are met + is_within_crit = True + # Check if the uncertainty is above the maximum allowed + if max_unc is not None: + unc = self.get_uncertainty() + if unc > max_unc: + is_within_crit = False + # Check if the structures are within the trust distance + if dtrust is not None: + within_dtrust = self.is_within_dtrust(dtrust=dtrust) + if not within_dtrust: + is_within_crit = False + # Update the best energy and position + if is_within_crit: + self.best_energy = e + self.best_pos = pos.copy() + return self.best_energy, self.best_pos def rotation_matrix(self, angles, positions): "Rotate the adsorbate" @@ -558,6 +623,14 @@ def get_reference_energy(self, **kwargs): e_ref += atoms.get_potential_energy() return e_ref + def make_initial_structure(self, **kwargs): + "Get the initial structure for the optimization." + x_drawn = self.draw_random_structures(n_random_draws=1, **kwargs) + x_drawn = x_drawn[0] + pos = self.get_new_positions(x_drawn, **kwargs) + self.optimizable.set_positions(pos) + return self + def get_arguments(self): "Get the arguments of the class itself." # Get the arguments given to the class in the initialization @@ -567,8 +640,10 @@ def get_arguments(self): adsorbate2=self.adsorbate2, bounds=self.bounds, n_random_draws=self.n_random_draws, + use_initial_struc=self.use_initial_struc, use_initial_opt=self.use_initial_opt, initial_fmax=self.initial_fmax, + initial_steps=self.initial_steps, use_repulsive_check=self.use_repulsive_check, repulsive_tol=self.repulsive_tol, repulsive_calculator=self.repulsive_calculator, From dddf99722220021e7cfbbea9b5020e4319bda846 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Tue, 12 Aug 2025 09:23:46 +0200 Subject: [PATCH 179/194] Implement max_unc and dtrust in global optimization together with restart and initial structure --- catlearn/activelearning/adsorption.py | 35 ++++++++++++- catlearn/activelearning/mlgo.py | 20 +++++--- catlearn/optimizer/adsorption.py | 72 ++++++++++++++++++++++++--- tests/test_adsorption.py | 2 +- tests/test_mlgo.py | 2 +- 5 files changed, 115 insertions(+), 16 deletions(-) diff --git a/catlearn/activelearning/adsorption.py b/catlearn/activelearning/adsorption.py index 46647c31..67a47e43 100644 --- a/catlearn/activelearning/adsorption.py +++ b/catlearn/activelearning/adsorption.py @@ -35,6 +35,10 @@ def __init__( use_fmax_convergence=True, unc_convergence=0.02, use_method_unc_conv=True, + use_restart=True, + check_unc=True, + check_energy=True, + check_fmax=False, n_evaluations_each=1, min_data=5, use_database_check=True, @@ -133,6 +137,19 @@ def __init__( use_method_unc_conv: bool Whether to use the unc_convergence as a convergence criterion in the optimization method. + use_restart: bool + Use the result from last robust iteration. + Be aware that restart and low max_unc can result in only the + initial structure passing the maximum uncertainty criterion. + check_unc: bool + Check if the uncertainty is large for the restarted result and + if it is then use the previous initial. + check_energy: bool + Check if the energy is larger for the restarted result than + the previous. + check_fmax: bool + Check if the maximum force is larger for the restarted result + than the initial interpolation and if so then replace it. n_evaluations_each: int Number of evaluations for each candidate. min_data: int @@ -218,6 +235,7 @@ def __init__( parallel_run=parallel_run, comm=comm, verbose=verbose, + seed=seed, ) # Initialize the BayesianOptimizer super().__init__( @@ -236,7 +254,10 @@ def __init__( use_fmax_convergence=use_fmax_convergence, unc_convergence=unc_convergence, use_method_unc_conv=use_method_unc_conv, - use_restart=False, + use_restart=use_restart, + check_unc=check_unc, + check_energy=check_energy, + check_fmax=check_fmax, n_evaluations_each=n_evaluations_each, min_data=min_data, use_database_check=use_database_check, @@ -273,6 +294,7 @@ def build_method( parallel_run=False, comm=world, verbose=False, + seed=None, **kwargs, ): "Build the optimization method." @@ -298,6 +320,7 @@ def build_method( parallel_run=False, comm=comm, verbose=verbose, + seed=seed, ) # Run the method in parallel if requested if parallel_run: @@ -307,6 +330,7 @@ def build_method( parallel_run=parallel_run, comm=comm, verbose=verbose, + seed=seed, ) return method @@ -340,6 +364,7 @@ def setup_default_mlcalc( use_derivatives=True, calc_forces=False, kappa=-1.0, + calc_kwargs={}, **kwargs, ): from ..regression.gp.fingerprint import SortedInvDistances @@ -368,6 +393,9 @@ def setup_default_mlcalc( wrap=True, use_tags=True, ) + # Set a limit for the uncertainty + if "max_unc" not in calc_kwargs.keys(): + calc_kwargs["max_unc"] = 2.0 return super().setup_default_mlcalc( fp=fp, atoms=atoms, @@ -375,6 +403,7 @@ def setup_default_mlcalc( use_derivatives=use_derivatives, calc_forces=calc_forces, kappa=kappa, + calc_kwargs=calc_kwargs, **kwargs, ) @@ -412,6 +441,10 @@ def get_arguments(self): use_fmax_convergence=self.use_fmax_convergence, unc_convergence=self.unc_convergence, use_method_unc_conv=self.use_method_unc_conv, + use_restart=self.use_restart, + check_unc=self.check_unc, + check_energy=self.check_energy, + check_fmax=self.check_fmax, n_evaluations_each=self.n_evaluations_each, min_data=self.min_data, use_database_check=self.use_database_check, diff --git a/catlearn/activelearning/mlgo.py b/catlearn/activelearning/mlgo.py index 2aabd650..46e64b02 100644 --- a/catlearn/activelearning/mlgo.py +++ b/catlearn/activelearning/mlgo.py @@ -43,9 +43,10 @@ def __init__( unc_convergence=0.02, use_method_unc_conv=True, use_restart=True, + use_restart_local=True, check_unc=True, check_energy=True, - check_fmax=True, + check_fmax=False, max_unc_restart=0.05, n_evaluations_each=1, min_data=3, @@ -164,6 +165,11 @@ def __init__( Whether to use the unc_convergence as a convergence criterion in the optimization method. use_restart: bool + Use the result from last robust iteration in + the global optimization. + Be aware that restart and low max_unc can result in only the + initial structure passing the maximum uncertainty criterion. + use_restart_local: bool Use the result from last robust iteration in the local optimization. check_unc: bool @@ -282,6 +288,7 @@ def __init__( use_fmax_convergence=use_fmax_convergence, unc_convergence=unc_convergence, use_method_unc_conv=use_method_unc_conv, + use_restart=use_restart, check_unc=check_unc, check_energy=check_energy, check_fmax=check_fmax, @@ -316,7 +323,7 @@ def __init__( atoms=atoms, local_opt=local_opt, local_opt_kwargs=local_opt_kwargs, - use_restart=use_restart, + use_restart=use_restart_local, ) # Restart the active learning prev_calculations = self.restart_optimization( @@ -331,7 +338,7 @@ def build_local_method( atoms, local_opt=FIRE, local_opt_kwargs={}, - use_restart=True, + use_restart_local=True, **kwargs, ): "Build the local optimization method." @@ -340,7 +347,7 @@ def build_local_method( self.local_opt = local_opt self.local_opt_kwargs = local_opt_kwargs # Set whether to use the restart in the local optimization - self.use_local_restart = use_restart + self.use_restart_local = use_restart_local # Build the local optimizer method self.local_method = LocalOptimizer( atoms, @@ -470,7 +477,7 @@ def switch_to_local(self, data, **kwargs): # Switch to the local optimization self.setup_method(self.local_method) # Set whether to use the restart - self.use_restart = self.use_local_restart + self.use_restart = self.use_restart_local return self def rm_constraints(self, structure, data, **kwargs): @@ -565,7 +572,8 @@ def get_arguments(self): use_fmax_convergence=self.use_fmax_convergence, unc_convergence=self.unc_convergence, use_method_unc_conv=self.use_method_unc_conv, - use_restart=self.use_local_restart, + use_restart=self.use_restart, + use_restart_local=self.use_restart_local, check_unc=self.check_unc, check_energy=self.check_energy, check_fmax=self.check_fmax, diff --git a/catlearn/optimizer/adsorption.py b/catlearn/optimizer/adsorption.py index 6e26703c..1f2d3d78 100644 --- a/catlearn/optimizer/adsorption.py +++ b/catlearn/optimizer/adsorption.py @@ -26,6 +26,7 @@ def __init__( adsorbate, adsorbate2=None, bounds=None, + use_initial_struc=True, opt_kwargs={}, bond_tol=1e-8, parallel_run=False, @@ -51,6 +52,9 @@ def __init__( the center of the adsorbate and 3 rotations. Same boundary conditions can be set for the second adsorbate if chosen. + use_initial_struc: bool + If True, the initial structure is used as one of the drawn + structures. opt_kwargs: dict The keyword arguments for the simulated annealing optimization. bond_tol: float @@ -75,6 +79,7 @@ def __init__( self.setup_bounds(bounds) # Set the parameters self.update_arguments( + use_initial_struc=use_initial_struc, opt_kwargs=opt_kwargs, parallel_run=parallel_run, comm=comm, @@ -82,6 +87,8 @@ def __init__( seed=seed, **kwargs, ) + # Make initial optimizable structure + self.make_initial_structure() def get_structures( self, @@ -267,7 +274,7 @@ def run( **kwargs, ): # Check if the optimization can take any steps - if steps <= 0: + if steps <= 2: return self._converged # Use original constraints self.optimizable.set_constraint(self.constraints_used) @@ -276,12 +283,25 @@ def run( self.best_pos = None self.best_energy_no_crit = inf self.best_pos_no_crit = None + # Calculate the energy of the initial structure if used + if self.use_initial_struc: + # Get the energy of the structure + e = self.optimizable.get_potential_energy() + # Check if the energy is lower than the best energy + self.check_best_structure( + e=e, + pos=self.optimizable.get_positions(), + max_unc=max_unc, + dtrust=dtrust, + **kwargs, + ) + steps -= 1 # Perform the simulated annealing dual_annealing( self.evaluate_value, args=(max_unc, dtrust), bounds=self.bounds, - maxfun=steps, + maxfun=steps - 1, **self.opt_kwargs, ) # Return the best position and number of steps @@ -291,7 +311,9 @@ def run( ) self.best_pos = self.best_pos_no_crit.copy() # Set the positions - self.evaluate_value(self.best_pos) + self.optimizable.set_positions(self.best_pos) + # Get the potential energy + e = self.optimizable.get_potential_energy() # Set the new constraints self.optimizable.set_constraint(self.constraints_new) # Calculate the maximum force to check convergence @@ -317,6 +339,7 @@ def update_arguments( adsorbate=None, adsorbate2=None, bounds=None, + use_initial_struc=None, opt_kwargs=None, bond_tol=None, parallel_run=None, @@ -343,6 +366,9 @@ def update_arguments( the center of the adsorbate and 3 rotations. Same boundary conditions can be set for the second adsorbate if chosen. + use_initial_struc: bool + If True, the initial structure is used as one of the drawn + structures. opt_kwargs: dict The keyword arguments for the simulated annealing optimization. bond_tol: float @@ -389,6 +415,8 @@ def update_arguments( # Create the boundary conditions if bounds is not None: self.setup_bounds(bounds) + if use_initial_struc is not None: + self.use_initial_struc = use_initial_struc return self def set_seed(self, seed=None): @@ -459,11 +487,30 @@ def evaluate_value(self, x, max_unc=None, dtrust=None, **kwargs): # Get the potential energy e = self.optimizable.get_potential_energy() # Check if the energy is lower than the best energy + self.check_best_structure( + e=e, + pos=pos, + max_unc=max_unc, + dtrust=dtrust, + **kwargs, + ) + return e + + def check_best_structure( + self, + e, + pos, + max_unc=None, + dtrust=None, + **kwargs, + ): + "Check if the structure is the best one." + # Check if the energy is lower than the best energy if e < self.best_energy: # Update the best energy and position without criteria if e < self.best_energy_no_crit: self.best_energy_no_crit = e - self.best_pos_no_crit = x.copy() + self.best_pos_no_crit = pos.copy() # Check if criteria are met is_within_crit = True # Check if the uncertainty is above the maximum allowed @@ -472,15 +519,25 @@ def evaluate_value(self, x, max_unc=None, dtrust=None, **kwargs): if unc > max_unc: is_within_crit = False # Check if the structures are within the trust distance - if dtrust is not None and is_within_crit: + if dtrust is not None: within_dtrust = self.is_within_dtrust(dtrust=dtrust) if not within_dtrust: is_within_crit = False # Update the best energy and position if is_within_crit: self.best_energy = e - self.best_pos = x.copy() - return e + self.best_pos = pos.copy() + return self.best_energy, self.best_pos + + def make_initial_structure(self, **kwargs): + "Get the initial structure for the optimization." + # Draw a random structure + x = self.rng.uniform(low=self.bounds[:, 0], high=self.bounds[:, 1]) + # Get the new positions of the adsorption + pos = self.get_new_positions(x, **kwargs) + # Set the positions + self.optimizable.set_positions(pos) + return self def get_arguments(self): "Get the arguments of the class itself." @@ -490,6 +547,7 @@ def get_arguments(self): adsorbate=self.adsorbate, adsorbate2=self.adsorbate2, bounds=self.bounds, + use_initial_struc=self.use_initial_struc, opt_kwargs=self.opt_kwargs, parallel_run=self.parallel_run, comm=self.comm, diff --git a/tests/test_adsorption.py b/tests/test_adsorption.py index 4d9207c0..5d3824f4 100644 --- a/tests/test_adsorption.py +++ b/tests/test_adsorption.py @@ -76,7 +76,7 @@ def test_adsorption_run(self): ads_al.run( fmax=0.05, steps=50, - max_unc=0.050, + max_unc=0.3, ml_steps=4000, ) # Check that Adsorption AL converged diff --git a/tests/test_mlgo.py b/tests/test_mlgo.py index 0aa4eb53..3f4f50a7 100644 --- a/tests/test_mlgo.py +++ b/tests/test_mlgo.py @@ -78,7 +78,7 @@ def test_mlgo_run(self): mlgo.run( fmax=0.05, steps=50, - max_unc=0.050, + max_unc=0.3, ml_steps=4000, ml_steps_local=1000, ) From 913b58b03925ec87550b2cdfe752ca46cf1a9788 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Tue, 12 Aug 2025 15:03:04 +0200 Subject: [PATCH 180/194] Use string or bool for database_reduction --- .../regression/gp/calculator/default_model.py | 27 ++++++++++++++----- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/catlearn/regression/gp/calculator/default_model.py b/catlearn/regression/gp/calculator/default_model.py index 73f114f6..fc778952 100644 --- a/catlearn/regression/gp/calculator/default_model.py +++ b/catlearn/regression/gp/calculator/default_model.py @@ -265,9 +265,14 @@ def get_default_database( Cartesian coordinates are used if it is None. use_derivatives: bool Whether to use derivatives of the targets. - database_reduction: bool + database_reduction: bool or str Whether to used a reduced database after a number of training points. + If a string is given, the database reduction method is created + from the string. + If False, no database reduction is used. + If True, the default database reduction method is used. + The default database reduction method is DatabasePointsInterest. round_targets: int (optional) The number of decimals to round the targets to. If None, the targets are not rounded. @@ -295,7 +300,7 @@ def get_default_database( else: use_fingerprint = True # Make the data base ready - if isinstance(database_reduction, str): + if isinstance(database_reduction, str) or database_reduction is True: # Set the default database arguments data_kwargs = dict( fingerprint=fp, @@ -310,6 +315,13 @@ def get_default_database( include_last=1, ) data_kwargs.update(database_kwargs) + if ( + database_reduction is True + or database_reduction.lower() == "interest" + ): + from .database_reduction import DatabasePointsInterest + + database = DatabasePointsInterest(**data_kwargs) if database_reduction.lower() == "distance": from .database_reduction import DatabaseDistance @@ -334,10 +346,6 @@ def get_default_database( from .database_reduction import DatabaseRestart database = DatabaseRestart(**data_kwargs) - elif database_reduction.lower() == "interest": - from .database_reduction import DatabasePointsInterest - - database = DatabasePointsInterest(**data_kwargs) elif database_reduction.lower() == "each_interest": from .database_reduction import DatabasePointsInterestEach @@ -518,9 +526,14 @@ def get_default_mlmodel( It also can include model_kwargs, prior_kwargs, kernel_kwargs, hpfitter_kwargs, optimizer_kwargs, lineoptimizer_kwargs, and function_kwargs. - database_reduction: bool + database_reduction: bool or str Whether to used a reduced database after a number of training points. + If a string is given, the database reduction method is created + from the string. + If False, no database reduction is used. + If True, the default database reduction method is used. + The default database reduction method is DatabasePointsInterest. round_targets: int (optional) The number of decimals to round the targets to. If None, the targets are not rounded. From be09d362e3c5d7b603e17ae7b9b2a435e693b694 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Wed, 13 Aug 2025 08:58:29 +0200 Subject: [PATCH 181/194] Do not use restart structure for adsorption with annealing as default --- catlearn/activelearning/adsorption.py | 2 +- catlearn/activelearning/mlgo.py | 2 +- catlearn/optimizer/adsorption.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/catlearn/activelearning/adsorption.py b/catlearn/activelearning/adsorption.py index 67a47e43..fb7e3cc9 100644 --- a/catlearn/activelearning/adsorption.py +++ b/catlearn/activelearning/adsorption.py @@ -35,7 +35,7 @@ def __init__( use_fmax_convergence=True, unc_convergence=0.02, use_method_unc_conv=True, - use_restart=True, + use_restart=False, check_unc=True, check_energy=True, check_fmax=False, diff --git a/catlearn/activelearning/mlgo.py b/catlearn/activelearning/mlgo.py index 46e64b02..473cfdb9 100644 --- a/catlearn/activelearning/mlgo.py +++ b/catlearn/activelearning/mlgo.py @@ -42,7 +42,7 @@ def __init__( use_fmax_convergence=True, unc_convergence=0.02, use_method_unc_conv=True, - use_restart=True, + use_restart=False, use_restart_local=True, check_unc=True, check_energy=True, diff --git a/catlearn/optimizer/adsorption.py b/catlearn/optimizer/adsorption.py index 1f2d3d78..de7c2b47 100644 --- a/catlearn/optimizer/adsorption.py +++ b/catlearn/optimizer/adsorption.py @@ -26,7 +26,7 @@ def __init__( adsorbate, adsorbate2=None, bounds=None, - use_initial_struc=True, + use_initial_struc=False, opt_kwargs={}, bond_tol=1e-8, parallel_run=False, From 6eb574242891ab6821ba3a23b1a66270824b35d7 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Wed, 13 Aug 2025 09:14:19 +0200 Subject: [PATCH 182/194] Adapt to new changes in ASE --- catlearn/structures/neb/orgneb.py | 12 ++++++++++++ catlearn/structures/structure.py | 12 ++++++++++++ 2 files changed, 24 insertions(+) diff --git a/catlearn/structures/neb/orgneb.py b/catlearn/structures/neb/orgneb.py index 16ebce27..b5193f41 100644 --- a/catlearn/structures/neb/orgneb.py +++ b/catlearn/structures/neb/orgneb.py @@ -234,6 +234,18 @@ def get_forces(self, **kwargs): forces_new = self.get_climb_forces(forces_new, forces, tangent) return forces_new.reshape(-1, 3) + def get_x(self): + return self.get_positions().ravel() + + def set_x(self, x): + self.set_positions(x.reshape(-1, 3)) + + def get_gradient(self): + return self.get_forces().ravel() + + def get_value(self, *args, **kwargs): + return self.get_potential_energy(*args, **kwargs) + def get_image_positions(self): """ Get the positions of the images. diff --git a/catlearn/structures/structure.py b/catlearn/structures/structure.py index d529cbf5..ee3592c6 100644 --- a/catlearn/structures/structure.py +++ b/catlearn/structures/structure.py @@ -99,6 +99,18 @@ def get_potential_energy(self, *args, **kwargs): self.store_results() return energy + def get_x(self): + return self.get_positions().ravel() + + def set_x(self, x): + self.set_positions(x.reshape(-1, 3)) + + def get_gradient(self): + return self.get_forces().ravel() + + def get_value(self, *args, **kwargs): + return self.get_potential_energy(*args, **kwargs) + def get_uncertainty(self, *args, **kwargs): if self.is_saved: if "uncertainty" in self.results: From f2d593c8bc19e2480cb72e15cdb6a96d84408520 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Wed, 13 Aug 2025 10:02:32 +0200 Subject: [PATCH 183/194] More adoptions to new ASE version --- catlearn/activelearning/activelearning.py | 2 +- catlearn/optimizer/local.py | 19 +++++++++++++++---- catlearn/optimizer/method.py | 2 +- catlearn/structures/neb/orgneb.py | 4 ++++ catlearn/structures/structure.py | 4 ++++ 5 files changed, 25 insertions(+), 6 deletions(-) diff --git a/catlearn/activelearning/activelearning.py b/catlearn/activelearning/activelearning.py index 6073b091..bd299567 100644 --- a/catlearn/activelearning/activelearning.py +++ b/catlearn/activelearning/activelearning.py @@ -352,7 +352,7 @@ def run( self.broadcast_best_structures() return self.converged() - def converged(self): + def converged(self, *args, **kwargs): "Whether the active learning is converged." return self._converged diff --git a/catlearn/optimizer/local.py b/catlearn/optimizer/local.py index cd10aab8..72cf8c5b 100644 --- a/catlearn/optimizer/local.py +++ b/catlearn/optimizer/local.py @@ -125,11 +125,13 @@ def local_optimize( with self.local_opt(atoms, **self.local_opt_kwargs) as optimizer: if max_unc is None and dtrust is None: optimizer.run(fmax=fmax, steps=steps) - converged = optimizer.converged() + forces = atoms.get_forces() + converged = optimizer.converged(forces) self.steps += optimizer.get_number_of_steps() else: converged = self.run_max_unc( optimizer=optimizer, + atoms=atoms, fmax=fmax, steps=steps, max_unc=max_unc, @@ -143,6 +145,7 @@ def local_optimize( def run_max_unc( self, optimizer, + atoms, fmax=0.05, steps=1000, max_unc=None, @@ -155,6 +158,8 @@ def run_max_unc( Parameters: optimizer: ASE optimizer object The optimizer object. + atoms: Atoms instance + The atoms to be optimized. fmax: float The maximum force allowed on an atom. steps: int @@ -177,7 +182,12 @@ def run_max_unc( self.message("The maximum number of steps is reached.") break # Run a local optimization step - _converged = self.run_max_unc_step(optimizer, fmax=fmax, **kwargs) + _converged = self.run_max_unc_step( + optimizer, + atoms=atoms, + fmax=fmax, + **kwargs, + ) # Check if the uncertainty is above the maximum allowed if max_unc is not None: # Get the uncertainty of the atoms @@ -280,7 +290,7 @@ def update_arguments( self.setup_local_optimizer(self.local_opt, local_opt_kwargs) return self - def run_max_unc_step(self, optimizer, fmax=0.05, **kwargs): + def run_max_unc_step(self, optimizer, atoms, fmax=0.05, **kwargs): """ Run a local optimization step. The ASE optimizer is dependent on the ASE version. @@ -290,7 +300,8 @@ def run_max_unc_step(self, optimizer, fmax=0.05, **kwargs): else: optimizer.run(fmax=fmax, steps=self.steps + 1, **kwargs) self.steps += 1 - return optimizer.converged() + forces = atoms.get_forces() + return optimizer.converged(forces) def get_arguments(self): "Get the arguments of the class itself." diff --git a/catlearn/optimizer/method.py b/catlearn/optimizer/method.py index b46b7504..84f6be6c 100644 --- a/catlearn/optimizer/method.py +++ b/catlearn/optimizer/method.py @@ -576,7 +576,7 @@ def get_number_of_steps(self): """ return self.steps - def converged(self): + def converged(self, *args, **kwargs): """ Check if the optimization is converged. """ diff --git a/catlearn/structures/neb/orgneb.py b/catlearn/structures/neb/orgneb.py index b5193f41..d8c82740 100644 --- a/catlearn/structures/neb/orgneb.py +++ b/catlearn/structures/neb/orgneb.py @@ -246,6 +246,10 @@ def get_gradient(self): def get_value(self, *args, **kwargs): return self.get_potential_energy(*args, **kwargs) + def gradient_norm(self, gradient): + forces = gradient.reshape(-1, 3) + return sqrt(einsum("ij,ij->i", forces, forces)).max() + def get_image_positions(self): """ Get the positions of the images. diff --git a/catlearn/structures/structure.py b/catlearn/structures/structure.py index ee3592c6..72ff6a41 100644 --- a/catlearn/structures/structure.py +++ b/catlearn/structures/structure.py @@ -111,6 +111,10 @@ def get_gradient(self): def get_value(self, *args, **kwargs): return self.get_potential_energy(*args, **kwargs) + def gradient_norm(self, gradient): + forces = gradient.reshape(-1, 3) + return sqrt(einsum("ij,ij->i", forces, forces)).max() + def get_uncertainty(self, *args, **kwargs): if self.is_saved: if "uncertainty" in self.results: From 85fc241a1fc862933e3e0efec45a52e7c24fca76 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Wed, 13 Aug 2025 10:17:50 +0200 Subject: [PATCH 184/194] More adoptions to the new ASE version --- catlearn/optimizer/local.py | 4 ++-- catlearn/optimizer/method.py | 17 +++++++++++++++++ catlearn/structures/neb/orgneb.py | 1 + catlearn/structures/structure.py | 1 + 4 files changed, 21 insertions(+), 2 deletions(-) diff --git a/catlearn/optimizer/local.py b/catlearn/optimizer/local.py index 72cf8c5b..ae050f22 100644 --- a/catlearn/optimizer/local.py +++ b/catlearn/optimizer/local.py @@ -126,7 +126,7 @@ def local_optimize( if max_unc is None and dtrust is None: optimizer.run(fmax=fmax, steps=steps) forces = atoms.get_forces() - converged = optimizer.converged(forces) + converged = self.is_fmax_converged(forces, fmax=fmax) self.steps += optimizer.get_number_of_steps() else: converged = self.run_max_unc( @@ -301,7 +301,7 @@ def run_max_unc_step(self, optimizer, atoms, fmax=0.05, **kwargs): optimizer.run(fmax=fmax, steps=self.steps + 1, **kwargs) self.steps += 1 forces = atoms.get_forces() - return optimizer.converged(forces) + return self.is_fmax_converged(forces, fmax=fmax) def get_arguments(self): "Get the arguments of the class itself." diff --git a/catlearn/optimizer/method.py b/catlearn/optimizer/method.py index 84f6be6c..3e03bf8a 100644 --- a/catlearn/optimizer/method.py +++ b/catlearn/optimizer/method.py @@ -582,6 +582,23 @@ def converged(self, *args, **kwargs): """ return self._converged + def is_fmax_converged(self, forces, fmax, **kwargs): + """ + Check if the optimization is converged based on the maximum force. + + Parameters: + forces: (N,3) array + The forces of the optimizable. + fmax: float + The maximum force allowed on an atom. + + Returns: + converged: bool + Whether the optimization is converged. + """ + forces = forces.reshape(-1, 3) + return sqrt(einsum("ij,ij->i", forces, forces)).max() < fmax + def is_energy_minimized(self): """ Check if the optimization method minimizes the energy. diff --git a/catlearn/structures/neb/orgneb.py b/catlearn/structures/neb/orgneb.py index d8c82740..bed65ead 100644 --- a/catlearn/structures/neb/orgneb.py +++ b/catlearn/structures/neb/orgneb.py @@ -533,6 +533,7 @@ def calc(self, calculators): return self.set_calculator(calculators) def converged(self, forces, fmax): + forces = forces.reshape(-1, 3) return sqrt(einsum("ij,ij->i", forces, forces)).max() < fmax def is_neb(self): diff --git a/catlearn/structures/structure.py b/catlearn/structures/structure.py index 72ff6a41..e9970e66 100644 --- a/catlearn/structures/structure.py +++ b/catlearn/structures/structure.py @@ -133,6 +133,7 @@ def get_uncertainty(self, *args, **kwargs): return unc def converged(self, forces, fmax): + forces = forces.reshape(-1, 3) return sqrt(einsum("ij,ij->i", forces, forces)).max() < fmax def is_neb(self): From bd992647a82c5744be9e5e37198bfc51591d38d1 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Wed, 13 Aug 2025 10:23:56 +0200 Subject: [PATCH 185/194] Adopt to new ASE version in NEB --- catlearn/structures/neb/orgneb.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/catlearn/structures/neb/orgneb.py b/catlearn/structures/neb/orgneb.py index bed65ead..c1f00aa3 100644 --- a/catlearn/structures/neb/orgneb.py +++ b/catlearn/structures/neb/orgneb.py @@ -250,6 +250,10 @@ def gradient_norm(self, gradient): forces = gradient.reshape(-1, 3) return sqrt(einsum("ij,ij->i", forces, forces)).max() + def ndofs(self): + "Number of degrees of freedom in the NEB." + return 3 * len(self) + def get_image_positions(self): """ Get the positions of the images. From cb4f90e7ef818ed5a8e85adb586ebdc579f61642 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Wed, 20 Aug 2025 15:53:30 +0200 Subject: [PATCH 186/194] Debug restart of last structures --- catlearn/_version.py | 2 +- catlearn/activelearning/activelearning.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/catlearn/_version.py b/catlearn/_version.py index d438fdba..ba944eb0 100644 --- a/catlearn/_version.py +++ b/catlearn/_version.py @@ -1,3 +1,3 @@ -__version__ = "7.1.0" +__version__ = "7.1.1" __all__ = ["__version__"] diff --git a/catlearn/activelearning/activelearning.py b/catlearn/activelearning/activelearning.py index bd299567..56075d26 100644 --- a/catlearn/activelearning/activelearning.py +++ b/catlearn/activelearning/activelearning.py @@ -2058,6 +2058,7 @@ def restart_optimization( prev_calculations = read(self.trainingset, ":") # Update the method with the structures self.update_method(self.structures) + self.copy_best_structures(allow_calculation=False) # Set the writing mode self.mode = "a" # Load the summary table From d6c6b5543472588d89c9620ab9af88e5c1c5263c Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Tue, 26 Aug 2025 22:21:22 +0200 Subject: [PATCH 187/194] New version --- catlearn/_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/catlearn/_version.py b/catlearn/_version.py index ba944eb0..64809e13 100644 --- a/catlearn/_version.py +++ b/catlearn/_version.py @@ -1,3 +1,3 @@ -__version__ = "7.1.1" +__version__ = "7.2.0" __all__ = ["__version__"] From 78c18392c895c130eb01c5e0be742beb79281582 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Tue, 26 Aug 2025 22:22:40 +0200 Subject: [PATCH 188/194] Use the maximum energy instead of the first as in literature --- catlearn/structures/neb/ewneb.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/catlearn/structures/neb/ewneb.py b/catlearn/structures/neb/ewneb.py index 233606e3..08bd133b 100644 --- a/catlearn/structures/neb/ewneb.py +++ b/catlearn/structures/neb/ewneb.py @@ -1,4 +1,4 @@ -from numpy import where +from numpy import max as max_, where from ase.parallel import world from .improvedneb import ImprovedTangentNEB @@ -95,7 +95,8 @@ def get_spring_constants(self, **kwargs): # Calculate the weighted spring constants k_l = self.k * self.kl_scale if e0 < emax: - a = (emax - energies[:-1]) / (emax - e0) + used_energies = max_([energies[1:], energies[:-1]], axis=0) + a = (emax - used_energies) / (emax - e0) k = where(a < 1.0, (1.0 - a) * self.k + a * k_l, k_l) else: k = k_l From cd25635a270a897731da15b49204beb2344cd3c6 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Tue, 26 Aug 2025 22:24:06 +0200 Subject: [PATCH 189/194] Implement AvgEWNEB as an option for neb_method --- catlearn/activelearning/mlneb.py | 1 + catlearn/optimizer/localcineb.py | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/catlearn/activelearning/mlneb.py b/catlearn/activelearning/mlneb.py index c71065f2..a913181f 100644 --- a/catlearn/activelearning/mlneb.py +++ b/catlearn/activelearning/mlneb.py @@ -86,6 +86,7 @@ def __init__( A string can be used to select: - 'improvedtangentneb' (default) - 'ewneb' + - 'avgewneb' neb_kwargs: dict A dictionary with the arguments used in the NEB object to create the instance. diff --git a/catlearn/optimizer/localcineb.py b/catlearn/optimizer/localcineb.py index 6607e4b2..9e93ea94 100644 --- a/catlearn/optimizer/localcineb.py +++ b/catlearn/optimizer/localcineb.py @@ -4,6 +4,7 @@ from .localneb import LocalNEB from .sequential import SequentialOptimizer from ..structures.neb import ( + AvgEWNEB, EWNEB, ImprovedTangentNEB, OriginalNEB, @@ -55,6 +56,7 @@ def __init__( A string can be used to select: - 'improvedtangentneb' (default) - 'ewneb' + - 'avgewneb' neb_kwargs: dict A dictionary with the arguments used in the NEB object to create the instance. @@ -193,6 +195,8 @@ def setup_neb( neb_method = ImprovedTangentNEB elif neb_method.lower() == "ewneb": neb_method = EWNEB + elif neb_method.lower() == "avgewneb": + neb_method = AvgEWNEB else: raise ValueError( "The NEB method {} is not implemented.".format(neb_method) From 0d4a8ba5109d02973a44217d7efec35371e18ab6 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Tue, 26 Aug 2025 22:25:06 +0200 Subject: [PATCH 190/194] Only use one local step in extra initial data --- catlearn/activelearning/randomadsorption.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/catlearn/activelearning/randomadsorption.py b/catlearn/activelearning/randomadsorption.py index 153947ba..cac02c51 100644 --- a/catlearn/activelearning/randomadsorption.py +++ b/catlearn/activelearning/randomadsorption.py @@ -421,7 +421,7 @@ def extra_initial_data(self, **kwargs): method_extra.set_calculator( MieCalculator(r_scale=1.2, denergy=0.2) ) - method_extra.run(fmax=0.1, steps=25) + method_extra.run(fmax=0.1, steps=21) atoms = method_extra.get_candidates()[0] # Evaluate the structure self.evaluate(atoms) From ca6ee932db638ece902ddd68b99ee37b7873c026 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Tue, 26 Aug 2025 22:25:31 +0200 Subject: [PATCH 191/194] Add one extra argument in example --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 92468fe4..c9dfd75a 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,7 @@ mlneb = MLNEB( neb_method="improvedtangentneb", neb_kwargs={}, neb_interpolation="linear", + start_without_ci=True, reuse_ci_path=True, save_memory=False, parallel_run=False, From fd2c5a6246658858b7170182a86cdc6e16488737 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Mon, 22 Sep 2025 12:29:23 +0200 Subject: [PATCH 192/194] Extra information about the NEB interpolation --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index c9dfd75a..0d66e637 100644 --- a/README.md +++ b/README.md @@ -132,6 +132,8 @@ mlneb.run( ``` +The MLNEB optimization can be restarted from the last predicted path and reusing the training data with the argument `restart=True`. Alternatively, the optimization can be restarted from the last predicted path without reusing the training data by setting the `neb_interpolation="predicted.traj"`. + The obtained NEB band from the MLNEB optimization can be visualized in three ways. The converged NEB band with uncertainties can be visualized by extending the Python code with the following code: From a54709de8183f33ebab0212559a19072fb2c4044 Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Mon, 22 Sep 2025 12:55:12 +0200 Subject: [PATCH 193/194] ASE comment --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 0d66e637..c9e74b8e 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,8 @@ However, it is recommended to install a specific tag to ensure it is a stable ve $ pip install git+https://github.com/avishart/CatLearn.git@v.x.x.x ``` +The dependency of ASE has only been thoroughly tested up to version 3.26.0. + ## Usage The active learning class is generalized to work for any defined optimizer method for ASE `Atoms` structures. The optimization method is executed iteratively with a machine-learning calculator that is retrained for each iteration. The active learning converges when the uncertainty is low (`unc_convergence`) and the energy change is within `unc_convergence` or the maximum force is within the tolerance value set. From 6f9ed830755e6dbde9c7a88a2c1168c31ae4adce Mon Sep 17 00:00:00 2001 From: Andreas Lynge Vishart Date: Mon, 22 Sep 2025 15:24:04 +0200 Subject: [PATCH 194/194] Minor README correction --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index c9e74b8e..562bcab2 100644 --- a/README.md +++ b/README.md @@ -134,7 +134,7 @@ mlneb.run( ``` -The MLNEB optimization can be restarted from the last predicted path and reusing the training data with the argument `restart=True`. Alternatively, the optimization can be restarted from the last predicted path without reusing the training data by setting the `neb_interpolation="predicted.traj"`. +The `MLNEB` optimization can be restarted from the last predicted path and reusing the training data with the argument `restart=True`. Alternatively, the optimization can be restarted from the last predicted path without reusing the training data by setting the `neb_interpolation="predicted.traj"`. The obtained NEB band from the MLNEB optimization can be visualized in three ways.