Practical: MACE
In this practical, you’ll explore how to train and evaluate a MACE model. We will use EMT potential for demonstration, practically, you can use DFT or other potentials as well.
Data Generation¶
First, we need to generate the training data. We will use EMT potential running on the ASAP3 package for the Copper system as an example. Then use MACE to train a model on this system. Finally, we will compare our results with the target potential.
Our data will be generated through molecular dynamics with NPT ensemble, at 800 K, and save 40 structures.
from ase.md.velocitydistribution import MaxwellBoltzmannDistribution,Stationary
from ase.md.nvtberendsen import NVTBerendsen
from ase.md.verlet import VelocityVerlet
from ase.md.npt import NPT
from ase.md import MDLogger
from ase import units
from ase.io import write
import os
from time import perf_counter
from ase.calculators.singlepoint import SinglePointCalculator
def run_md(atoms, calculator, ensemble, time_step, temperature, num_md_steps, num_interval, output_filename):
# Set calculator (EMT in this case)
atoms.calc = calculator
# Set the momenta corresponding to the given "temperature"
MaxwellBoltzmannDistribution(atoms, temperature_K=temperature,force_temp=True)
Stationary(atoms) # Set zero total momentum to avoid drifting
# Set output filenames
log_filename = output_filename + ".log"
print("log_filename = ",log_filename)
traj_filename = output_filename + ".traj"
print("traj_filename = ",traj_filename)
xyz_file = output_filename + ".xyz"
print("xyz_filename = ",xyz_file)
# Remove old files if they exist
if os.path.exists(log_filename): os.remove(log_filename)
if os.path.exists(traj_filename): os.remove(traj_filename)
if os.path.exists(xyz_file): os.remove(xyz_file)
# Define the MD dynamics class object
if ensemble == 'nve':
dyn = VelocityVerlet(atoms,
time_step * units.fs,
trajectory = traj_filename,
loginterval=num_interval
)
elif ensemble == 'nvt':
dyn = NVTBerendsen(atoms,
time_step * units.fs,
temperature_K=temperature,
taut=100.0 * units.fs,
trajectory = traj_filename,
loginterval=num_interval
)
elif ensemble == 'npt':
sigma = 1.0 # External pressure in bar
ttime = 20.0 # Time constant in fs
pfactor = 2e6 # Barostat parameter in GPa
dyn = NPT(atoms,
time_step*units.fs,
temperature_K = temperature,
externalstress = sigma*units.bar,
ttime = ttime*units.fs,
pfactor = pfactor*units.GPa*(units.fs**2),
logfile = log_filename,
trajectory = traj_filename,
loginterval=num_interval
)
else:
raise ValueError("Invalid ensemble, must be nve, nvt, or npt")
# Print statements
def print_dyn():
imd = dyn.get_number_of_steps()
etot = atoms.get_total_energy()
temp_K = atoms.get_temperature()
stress = atoms.get_stress(include_ideal_gas=True)/units.GPa
stress_ave = (stress[0]+stress[1]+stress[2])/3.0
elapsed_time = perf_counter() - start_time
print(f" {imd: >3} {etot:.3f} {temp_K:.2f} {stress_ave:.2f} {stress[0]:.2f} {stress[1]:.2f} {stress[2]:.2f} {stress[3]:.2f} {stress[4]:.2f} {stress[5]:.2f} {elapsed_time:.3f}")
def save_xyz():
# Save the current configuration to the trajectory file
atoms_copy = dyn.atoms.copy()
atoms_copy.calc = SinglePointCalculator(atoms_copy,
energy=atoms.get_potential_energy(),
forces = atoms.get_forces(),
stress = atoms.get_stress(include_ideal_gas=True))
# atoms_copy.info['REF_energy']= atoms.get_potential_energy()
# atoms_copy.set_array(forces,'REF_forces')
write(xyz_file, atoms_copy, format='extxyz', append=True)
dyn.attach(print_dyn, interval=num_interval)
dyn.attach(save_xyz, interval=num_interval)
# Set MD logger
dyn.attach(MDLogger(dyn, atoms, log_filename, header=True, stress=True,peratom=True, mode="w"), interval=num_interval)
# Now run MD simulation
print(f" imd Etot(eV) T(K) stress(mean,xx,yy,zz,yz,xz,xy)(GPa) elapsed_time(sec)")
start_time = perf_counter()
dyn.run(num_md_steps)
from asap3 import EMT
from ase.build import bulk
calculator = EMT()
# Set up a fcc-Cu crystal
atoms = bulk("Cu", "fcc", cubic=True, a=3.615)
atoms.pbc = True
atoms *= 2 # 2x2x2 supercell
print("atoms = ",atoms)
# input parameters
time_step = 1.0 # MD step size in fsec
temperature = 800 # Temperature in Kelvin
num_md_steps = 2000 # Total number of MD steps
num_interval = 50 # Print out interval for .log and .traj
output_filename = "./tmp/Cu_fcc_2x2x2"
ensemble = 'npt'
run_md(atoms, calculator, ensemble, time_step, temperature, num_md_steps, num_interval, output_filename)
Compute E0¶
Atomic energies are computed using the EMT potential by putting a single atom in a box. This is essential for the MACE model.
from ase import Atoms
# Create a single Cu atom in a large box (to simulate vacuum conditions)
single_Cu = Atoms('Cu', positions=[[0, 0, 0]], cell=[20, 20, 20], pbc=False)
# Use the already defined calculator (EMT)
single_Cu.calc = calculator
# Compute and print the atomic energy
energy = single_Cu.get_potential_energy()
print("Atomic energy of single Cu atom: {:.4f} eV".format(energy))Atomic energy of single Cu atom: 3.5100 eV
import pandas as pd
log_filename = output_filename + ".log"
df = pd.read_csv(log_filename, delim_whitespace=True, skiprows=1,
names=['Time[ps]','Etot/N[eV]','Epot/N[eV]','Ekin/N[eV]','T[K]','stress(xx)','stress(yy)','stress(zz)','stress(yz)','stress(xz)','stress(xy)'])
dfimport matplotlib.pyplot as plt
fig = plt.figure(figsize=(10, 10))
#color = 'tab:grey'
ax1 = fig.add_subplot(4, 1, 1)
ax1.set_xticklabels([])
ax1.set_ylabel('U+K (eV)')
ax1.plot(df["Time[ps]"], df["Etot/N[eV]"], color="blue",alpha=0.5)
ax2 = fig.add_subplot(4, 1, 2)
ax2.set_xticklabels([])
ax2.set_ylabel('U (eV)')
ax2.plot(df["Time[ps]"], df["Etot/N[eV]"], color="green",alpha=0.5)
ax3 = fig.add_subplot(4, 1, 3)
ax3.set_xticklabels([])
ax3.set_ylabel('K (eV)')
ax3.plot(df["Time[ps]"], df["Etot/N[eV]"], color="orange",alpha=0.5)
ax4 = fig.add_subplot(4, 1, 4)
ax4.set_xlabel('time (ps)')
ax4.set_ylabel('T (K)')
ax4.plot(df["Time[ps]"], df["T[K]"], color="red",alpha=0.5)
plt.show()

Data Splitting¶
We then need to devide the data into training/validation and test sets. The training set is used to train the MACE model, while the test set is used to evaluate its performance. We will use a 90-10 split for this example.
from ase.io import read, write
import random
atoms = read('./tmp/Cu_fcc_2x2x2.xyz', index=":", format='extxyz',parallel=True)
print(f"Number of data: {len(atoms)}")
# Randomly shuffle the data
seed = 123
random.seed(seed)
# determine the index to split an 90% / 10% data
split_idx = int(0.9 * len(atoms))
indices = list(range(len(atoms)))
random.shuffle(indices)
train_atoms = [atoms[i] for i in indices[:split_idx]]
test_atoms = [atoms[i] for i in indices[split_idx:]]
write("tmp/train_valid.xyz", train_atoms, format="extxyz")
write("tmp/test.xyz", test_atoms, format="extxyz")Number of data: 40
Training Configuration¶
You can use the code below to generate training configuration. For more information, please refer to: https://
%%writefile ./tmp/training_config.yml
model: "MACE"
num_channels: 32
max_L: 0
r_max: 4.0
name: "mace01"
model_dir: "tmp/MACE_models"
log_dir: "tmp/MACE_models"
checkpoints_dir: "tmp/MACE_models"
results_dir: "tmp/MACE_models"
train_file: "tmp/train_valid.xyz"
valid_fraction: 0.10
test_file: "tmp/test.xyz"
energy_key: "energy"
forces_key: "forces"
stress_key: "stress"
E0s: "{29:3.5100}"
device: cpu
batch_size: 4
max_num_epochs: 50
swa: True
seed: 123
Overwriting ./tmp/training_config.yml
import warnings
warnings.filterwarnings("ignore")
from mace.cli.run_train import main as mace_run_train_main
import sys
import logging
def train_mace(config_file_path):
logging.getLogger().handlers.clear()
sys.argv = ["program", "--config", config_file_path]
mace_run_train_main()
Training the MACE Model¶
Now we can start training the MACE model. This process might be time-consuming, depending on the size of your dataset and the complexity of the model. The training process involves optimizing the model parameters to minimize the difference between the predicted and target energies and forces.
train_mace("tmp/training_config.yml")Track Training Results¶
You can then check the training curves in tmp/MACE_models/mace01_run-123_train_default_stage_one.png and tmp/MACE_models/mace01_run-123_train_default_stage_two.png to see how the training process goes. The first one is the training curve for stage one, and the second one is for stage two.
from IPython.display import Image, display
display(Image(filename='tmp/MACE_models/mace01_run-123_train_default_stage_one.png'))
display(Image(filename='tmp/MACE_models/mace01_run-123_train_default_stage_two.png'))


Further Test: Equation of State¶
Then we can test the equation of state of the model.
from mace.calculators import MACECalculator
import numpy as np
from ase.eos import EquationOfState
calculator = MACECalculator(
model_paths="tmp/MACE_models/mace01_run-123_stagetwo.model",
device="cpu")
import matplotlib.pyplot as plt
# Define a range of scale factors
scale_factors = np.linspace(0.95, 1.05, 11)
energies_mace = []
energies_emt = []
volumes = []
atoms = bulk("Cu", "fcc", cubic=True, a=3.615)
atoms.pbc = True
for s in scale_factors:
# Make a copy of the original bulk Cu structure
atoms_scaled = atoms.copy()
# Scale the cell and positions accordingly
atoms_scaled.set_cell(atoms_scaled.get_cell() * s, scale_atoms=True)
atoms_scaled_emt = atoms_scaled.copy()
# Assign the MACE calculator
atoms_scaled.calc = calculator
atoms_scaled_emt.calc = EMT()
volumes.append(atoms_scaled.get_volume())
# Compute and store the potential energy
energies_mace.append(atoms_scaled.get_potential_energy())
energies_emt.append(atoms_scaled_emt.get_potential_energy())
eos_mace = EquationOfState(volumes, energies_mace)
eos_emt = EquationOfState(volumes, energies_emt)
# Fit the EOS to the MACE energies
v0_mace, e0_mace, B_mace = eos_mace.fit()
# Fit the EOS to the EMT energies
v0_emt, e0_emt, B_emt = eos_emt.fit()
print(f"MACE: v0 = {v0_mace:.4f}, e0 = {e0_mace:.4f}, B = {B_mace:.4f}")
print(f"EMT: v0 = {v0_emt:.4f}, e0 = {e0_emt:.4f}, B = {B_emt:.4f}")
# Plot the Equation of State (EOS)
plt.figure()
plt.plot(scale_factors, energies_mace, marker="o", label="MACE")
plt.plot(scale_factors, energies_emt, marker="o", label="EMT")
plt.legend()
plt.xlabel("Scale factor")
plt.ylabel("Energy (eV)")
plt.title("Equation of State for Cu")
plt.show()2025-04-16 03:50:10.278 INFO: Using CPU
No dtype selected, switching to float64 to match model dtype.
MACE: v0 = 45.7100, e0 = -0.0468, B = 0.7390
EMT: v0 = 46.3859, e0 = -0.0062, B = 0.8378
