|
2 | 2 | from __future__ import print_function, annotations |
3 | 3 | import sys, os |
4 | 4 | import warnings |
| 5 | +from copy import deepcopy |
5 | 6 | import numpy as np |
6 | 7 | import time |
7 | 8 | #from scipy.special import tanh, sinh, cosh |
@@ -122,6 +123,29 @@ def Main(self): |
122 | 123 |
|
123 | 124 | __DEBUG_RHO__ = False |
124 | 125 |
|
| 126 | + |
| 127 | +def _import_flare_learners(): |
| 128 | + """Lazy import of the FLARE objects used by the on-the-fly helpers. |
| 129 | +
|
| 130 | + Returns |
| 131 | + ------- |
| 132 | + (FLARE_Atoms, compute_mae, get_env_indices, is_std_in_bound) |
| 133 | +
|
| 134 | + Raises |
| 135 | + ------ |
| 136 | + ImportError: with an actionable message if flare is not installed. |
| 137 | + """ |
| 138 | + try: |
| 139 | + from flare.atoms import FLARE_Atoms |
| 140 | + from flare.io.output import compute_mae |
| 141 | + from flare.learners.utils import get_env_indices, is_std_in_bound |
| 142 | + except ImportError as exc: |
| 143 | + raise ImportError( |
| 144 | + "On-the-fly learning requires the 'flare' package " |
| 145 | + "(https://github.com/mir-group/flare). Original error: {}".format(exc) |
| 146 | + ) from exc |
| 147 | + return FLARE_Atoms, compute_mae, get_env_indices, is_std_in_bound |
| 148 | + |
125 | 149 | """ |
126 | 150 | This source contains the Ensemble class |
127 | 151 | It is used to Load and Save info about the ensemble. |
@@ -3962,6 +3986,14 @@ def split(self, split_mask): |
3962 | 3986 |
|
3963 | 3987 | ens.all_properties = [self.all_properties[x] for x in np.arange(len(split_mask))[split_mask]] |
3964 | 3988 |
|
| 3989 | + # Propagate the on-the-fly (FLARE) state. Without this, the |
| 3990 | + # `computing_ensemble` built by compute_ensemble via get_noncomputed() |
| 3991 | + # would have gp_model=None and OTF would be silently disabled |
| 3992 | + # on any backend that goes through the compute_ensemble dispatcher |
| 3993 | + # (i.e. every Cluster flavor and plain ASE calculators). |
| 3994 | + for attr in self.OTF_STATE_ATTRIBUTES: |
| 3995 | + setattr(ens, attr, getattr(self, attr)) |
| 3996 | + |
3965 | 3997 | return ens |
3966 | 3998 |
|
3967 | 3999 |
|
@@ -4295,6 +4327,275 @@ def set_otf( |
4295 | 4327 | train_hyps[1] = np.inf |
4296 | 4328 | self.train_hyps = train_hyps |
4297 | 4329 |
|
| 4330 | + # ------------------------------------------------------------------ # |
| 4331 | + # On-the-fly (FLARE) helpers — backend-agnostic, shared by all # |
| 4332 | + # clusters (BaseCluster driver) and the AiiDAEnsemble path. # |
| 4333 | + # ------------------------------------------------------------------ # |
| 4334 | + |
| 4335 | + OTF_STATE_ATTRIBUTES = ( |
| 4336 | + "gp_model", "flare_calc", "std_tolerance", "max_atoms_added", |
| 4337 | + "update_style", "update_threshold", "build_mode", "output", |
| 4338 | + "output_name", "checkpt_name", "flare_name", "atoms_name", |
| 4339 | + "checkpt_files", "write_model", "init_atoms", "train_hyps", |
| 4340 | + ) |
| 4341 | + |
| 4342 | + def _otf_setup_defaults(self, number_of_atoms): |
| 4343 | + """Resolve the OTF settings that depend on the system size. |
| 4344 | +
|
| 4345 | + Extracted verbatim from AiiDAEnsemble.compute_ensemble. |
| 4346 | + """ |
| 4347 | + if self.max_atoms_added < 0: |
| 4348 | + self.max_atoms_added = number_of_atoms |
| 4349 | + if self.init_atoms is None: |
| 4350 | + self.init_atoms = list(range(number_of_atoms)) |
| 4351 | + |
| 4352 | + def _otf_predict(self, structures, candidate_indices): |
| 4353 | + """Predict with the ML model the structures within uncertainty. |
| 4354 | +
|
| 4355 | + No-op while the model is empty (first ab-initio batch), mirroring |
| 4356 | + the guard in AiiDAEnsemble.compute_ensemble. |
| 4357 | + """ |
| 4358 | + if len(self.gp_model.training_data) > 0: |
| 4359 | + self._predict_with_model(structures, candidate_indices) |
| 4360 | + |
| 4361 | + def _otf_update_from_structure(self, structure, dft_energy, dft_frcs, |
| 4362 | + dft_stress=None): |
| 4363 | + """Update the GP from a cellconstructor Structure and plain-eV data. |
| 4364 | +
|
| 4365 | + Backend-agnostic wrapper around _update_gp: callers (e.g. Cluster) |
| 4366 | + do not need to import flare at all. |
| 4367 | +
|
| 4368 | + Args: |
| 4369 | + ---- |
| 4370 | + structure: cellconstructor.Structure.Structure that was computed. |
| 4371 | + dft_energy: total energy in eV. |
| 4372 | + dft_frcs: forces, shape (nat, 3), in eV/Angstrom. |
| 4373 | + dft_stress: stress in ASE convention and Voigt order |
| 4374 | + (xx, yy, zz, yz, xz, xy), in eV/Angstrom^3, or None. |
| 4375 | + """ |
| 4376 | + FLARE_Atoms, _, _, _ = _import_flare_learners() |
| 4377 | + self._update_gp( |
| 4378 | + FLARE_Atoms.from_ase_atoms(structure.get_ase_atoms()), |
| 4379 | + dft_frcs=dft_frcs, |
| 4380 | + dft_energy=dft_energy, |
| 4381 | + dft_stress=dft_stress, |
| 4382 | + ) |
| 4383 | + |
| 4384 | + def _otf_maybe_train_and_write(self, dft_counts): |
| 4385 | + """Train the hyperparameters (inside the train_hyps window) and dump. |
| 4386 | +
|
| 4387 | + Exactly the 'TRAIN SECTION' of AiiDAEnsemble.compute_ensemble. |
| 4388 | + """ |
| 4389 | + if dft_counts > 0: |
| 4390 | + if self.train_hyps[0] <= len(self.gp_model.training_data) <= self.train_hyps[1]: |
| 4391 | + self._train_gp() |
| 4392 | + self._write_model() |
| 4393 | + |
| 4394 | + def _predict_with_model(self, structures, candidate_indices): |
| 4395 | + """Predict on all the structures and estimate errors. |
| 4396 | +
|
| 4397 | + Structures whose predicted uncertainty is acceptable are removed from |
| 4398 | + `candidate_indices` (in place) and filled with the ML prediction. |
| 4399 | +
|
| 4400 | + Args: |
| 4401 | + ---- |
| 4402 | + structures: list of cellconstructor.Structure.Structure |
| 4403 | + candidate_indices: indices of the structures still to be computed |
| 4404 | + ab-initio; modified in place. |
| 4405 | + """ |
| 4406 | + FLARE_Atoms, _, get_env_indices, is_std_in_bound = _import_flare_learners() |
| 4407 | + |
| 4408 | + sub_indices = deepcopy(candidate_indices) |
| 4409 | + |
| 4410 | + for index in sub_indices: |
| 4411 | + structure = structures[index] |
| 4412 | + atoms = FLARE_Atoms.from_ase_atoms(structure.get_ase_atoms()) |
| 4413 | + self._compute_properties(atoms) |
| 4414 | + |
| 4415 | + # get max uncertainty atoms |
| 4416 | + if self.build_mode == 'bayesian': |
| 4417 | + env_selection = is_std_in_bound |
| 4418 | + elif self.build_mode == 'direct': |
| 4419 | + env_selection = get_env_indices |
| 4420 | + |
| 4421 | + tic = time.time() |
| 4422 | + |
| 4423 | + std_in_bound, _ = env_selection( |
| 4424 | + self.std_tolerance, |
| 4425 | + self.gp_model.force_noise, |
| 4426 | + atoms, |
| 4427 | + max_atoms_added=self.max_atoms_added, |
| 4428 | + update_style=self.update_style, |
| 4429 | + update_threshold=self.update_threshold, |
| 4430 | + ) |
| 4431 | + |
| 4432 | + self.output.write_wall_time(tic, task='Env Selection') |
| 4433 | + |
| 4434 | + if not std_in_bound: |
| 4435 | + print(f"[AB-INITIO CALLED] For structure with id={index}") |
| 4436 | + else: |
| 4437 | + print(f"[BFFS USED] For structure with id={index}") |
| 4438 | + candidate_indices.remove(index) # remove index computed via ML-FF |
| 4439 | + |
| 4440 | + self.energies[index] = deepcopy(atoms.get_potential_energy()) / Rydberg # eV -> Ry |
| 4441 | + self.forces[index] = deepcopy(atoms.get_forces()) / Rydberg # eV/Ang -> Ry/Ang |
| 4442 | + if self.has_stress: |
| 4443 | + self.stresses[index, :, :] = -1 * deepcopy(atoms.get_stress(voigt=False)) * (Bohr**3 / Rydberg) # -eV/(Ang^3) -> Ry/(Bohr^3) |
| 4444 | + self.stress_computed[index] = True |
| 4445 | + |
| 4446 | + self.force_computed[index] = True |
| 4447 | + |
| 4448 | + sys.stdout.flush() |
| 4449 | + |
| 4450 | + def _compute_properties(self, atoms): |
| 4451 | + """Compute energies, forces, stresses, and their uncertainties.""" |
| 4452 | + tic = time.time() |
| 4453 | + |
| 4454 | + atoms.calc = self.flare_calc |
| 4455 | + atoms.calc.calculate(atoms) |
| 4456 | + |
| 4457 | + self.output.write_wall_time(tic, task='Compute Properties') |
| 4458 | + |
| 4459 | + def _write_model(self): |
| 4460 | + """Write the current model in a JSON file.""" |
| 4461 | + self.flare_calc.write_model(self.flare_name) |
| 4462 | + |
| 4463 | + def _update_gp(self, atoms, dft_frcs, dft_energy=None, dft_stress=None): |
| 4464 | + """Update the current GP model. |
| 4465 | +
|
| 4466 | + Args: |
| 4467 | + ---- |
| 4468 | + atoms: :class:`flare.atoms.FLARE_Atoms` instance whose local |
| 4469 | + environments will be added to the training set. |
| 4470 | + dft_frcs (np.ndarray): ab-initio forces in eV/Angstrom. |
| 4471 | + dft_energy (float): total energy of the structure, in eV. |
| 4472 | + dft_stress (np.ndarray): ab-initio stress, ASE sign convention, |
| 4473 | + eV/Angstrom^3, Voigt order (xx, yy, zz, yz, xz, xy), or None. |
| 4474 | + """ |
| 4475 | + from ase.calculators.singlepoint import SinglePointCalculator |
| 4476 | + _, compute_mae, get_env_indices, is_std_in_bound = _import_flare_learners() |
| 4477 | + |
| 4478 | + tic = time.time() |
| 4479 | + is_empty_model = len(self.gp_model.training_data) == 0 |
| 4480 | + |
| 4481 | + # Skip adding environments if the stds are within the user-defined |
| 4482 | + # boundaries, even if the ab-initio calculation was performed. |
| 4483 | + if is_empty_model: |
| 4484 | + std_in_bound = False |
| 4485 | + train_atoms = self.init_atoms |
| 4486 | + else: |
| 4487 | + self._compute_properties(atoms) |
| 4488 | + |
| 4489 | + if self.build_mode == 'bayesian': |
| 4490 | + env_selection = is_std_in_bound |
| 4491 | + elif self.build_mode == 'direct': |
| 4492 | + env_selection = get_env_indices |
| 4493 | + |
| 4494 | + tic = time.time() |
| 4495 | + |
| 4496 | + std_in_bound, train_atoms = env_selection( |
| 4497 | + self.std_tolerance, |
| 4498 | + self.gp_model.force_noise, |
| 4499 | + atoms, |
| 4500 | + max_atoms_added=self.max_atoms_added, |
| 4501 | + update_style=self.update_style, |
| 4502 | + update_threshold=self.update_threshold, |
| 4503 | + ) |
| 4504 | + |
| 4505 | + self.output.write_wall_time(tic, task='Env Selection') |
| 4506 | + |
| 4507 | + # compute mae and write to output |
| 4508 | + e_mae, e_mav, f_mae, f_mav, s_mae, s_mav = compute_mae( |
| 4509 | + atoms, |
| 4510 | + self.output.basename, |
| 4511 | + atoms.potential_energy, |
| 4512 | + atoms.forces, |
| 4513 | + atoms.stress, |
| 4514 | + dft_energy, |
| 4515 | + dft_frcs, |
| 4516 | + dft_stress, |
| 4517 | + False, |
| 4518 | + ) |
| 4519 | + |
| 4520 | + if not std_in_bound: |
| 4521 | + if not is_empty_model: |
| 4522 | + stds = self.flare_calc.results.get('stds', np.zeros_like(dft_frcs)) |
| 4523 | + self.output.add_atom_info(train_atoms, stds) |
| 4524 | + |
| 4525 | + # Convert ASE stress (xx, yy, zz, yz, xz, xy) to FLARE stress |
| 4526 | + # (xx, xy, xz, yy, yz, zz). |
| 4527 | + flare_stress = None |
| 4528 | + if dft_stress is not None: |
| 4529 | + flare_stress = -np.array([ |
| 4530 | + dft_stress[0], |
| 4531 | + dft_stress[5], |
| 4532 | + dft_stress[4], |
| 4533 | + dft_stress[1], |
| 4534 | + dft_stress[3], |
| 4535 | + dft_stress[2], |
| 4536 | + ]) |
| 4537 | + |
| 4538 | + results = { |
| 4539 | + 'forces': dft_frcs, |
| 4540 | + 'energy': dft_energy, |
| 4541 | + 'free_energy': dft_energy, |
| 4542 | + 'stress': dft_stress, |
| 4543 | + } |
| 4544 | + |
| 4545 | + atoms.calc = SinglePointCalculator(atoms, **results) |
| 4546 | + |
| 4547 | + # update gp model |
| 4548 | + self.gp_model.update_db( |
| 4549 | + atoms, |
| 4550 | + dft_frcs, |
| 4551 | + custom_range=train_atoms, |
| 4552 | + energy=dft_energy, |
| 4553 | + stress=flare_stress, |
| 4554 | + ) |
| 4555 | + |
| 4556 | + self.gp_model.set_L_alpha() |
| 4557 | + self.output.write_wall_time(tic, task='Update GP') |
| 4558 | + |
| 4559 | + def _train_gp(self): |
| 4560 | + """Optimize the hyperparameters of the current GP model.""" |
| 4561 | + tic = time.time() |
| 4562 | + |
| 4563 | + self.gp_model.train(logger_name=self.output_name + 'hyps.dat') |
| 4564 | + |
| 4565 | + self.output.write_wall_time(tic, task='Train Hyps') |
| 4566 | + |
| 4567 | + hyps, labels = self.gp_model.hyps_and_labels |
| 4568 | + if labels is None: |
| 4569 | + labels = self.gp_model.hyp_labels |
| 4570 | + |
| 4571 | + self.output.write_hyps( |
| 4572 | + labels, |
| 4573 | + hyps, |
| 4574 | + tic, # actually here there should be the actual start time of the entire simulation |
| 4575 | + self.gp_model.likelihood, |
| 4576 | + self.gp_model.likelihood_gradient, |
| 4577 | + hyps_mask=self.gp_model.hyps_mask, |
| 4578 | + ) |
| 4579 | + |
| 4580 | + def _clean_runs(self, dft_counts, label="CALCULATIONS"): |
| 4581 | + """Clean the failed runs and print summary. |
| 4582 | +
|
| 4583 | + Args: |
| 4584 | + ---- |
| 4585 | + dft_counts (int): number of performed ab-initio calculations. |
| 4586 | + label (str): identifier printed in the summary header. |
| 4587 | + """ |
| 4588 | + n_calcs = np.sum(self.force_computed.astype(int)) |
| 4589 | + print('=============== SUMMARY {} =============== \n'.format(label)) |
| 4590 | + print('Total structures included: ', n_calcs) |
| 4591 | + print('Structures not included : ', self.N-n_calcs) |
| 4592 | + if self.gp_model is not None: |
| 4593 | + print('Steps using OTF-ML model : ', self.N-dft_counts) |
| 4594 | + print() |
| 4595 | + print('===================== END OF SUMMARY ===================== \n') |
| 4596 | + if n_calcs != self.N: |
| 4597 | + self.remove_noncomputed() |
| 4598 | + |
4298 | 4599 |
|
4299 | 4600 |
|
4300 | 4601 |
|
|
0 commit comments