Machine learning

Ramannoodle includes a brand-new machine learning model for predicting polarizabilities. This model uses a graph neural network architecture. The tutorial will demonstrate how to initialize, train, and evaluate this model. This notebook is available on Github.

First, some setup.

[1]:
import numpy as np
from matplotlib import pyplot as plt
import matplotlib_inline

matplotlib_inline.backend_inline.set_matplotlib_formats('png')
plt.rcParams['figure.dpi'] = 300
plt.rcParams['font.family'] = 'sans-serif'
plt.rcParams["mathtext.default"] = 'regular'
plt.rcParams['axes.linewidth'] = 0.5
plt.rcParams['xtick.major.width'] = 0.5
plt.rcParams['xtick.minor.width'] = 0.5
plt.rcParams['lines.linewidth'] = 1.5

import ramannoodle as rn

Constructing the polarizability model

Our final goal is to calculate TiO2’s Raman spectrum.

Datasets

First, we will load in a training and validation set. These datasets (which are not publicly available) consist of polarizability calculations carried out on a variety of molecular dynamics snapshots.

[2]:
import glob

# This data is not publicly available. Sorry!
training_set = rn.io.vasp.outcar.read_polarizability_dataset(
    list(glob.glob("/Volumes/Untitled/TiO2_eps/train/*ps*/scratch/OUTCAR"))
)
validation_set = rn.io.vasp.outcar.read_polarizability_dataset(
    list(glob.glob("/Volumes/Untitled/TiO2_eps/validation/*ps*/scratch/OUTCAR"))
)
# As is best practice, we scale the validation set with respect to the training set.
validation_set.scale_polarizabilities(
    training_set.mean_polarizability, training_set.stddev_polarizability
)

print("Training set size:", len(training_set), "structures")
print("Validation set size:", len(validation_set), "structures")
100%|██████████| 99/99 [00:00<00:00, 123.76 files/s]
100%|██████████| 100/100 [00:00<00:00, 123.65 files/s]
Training set size: 99 structures
Validation set size: 100 structures

Let’s plot the training set.

[3]:
from torch.utils.data import DataLoader

fig = plt.figure(constrained_layout=True, figsize=(8, 2))
gs = fig.add_gridspec(1, 5, wspace=0)
axes = gs.subplots()
loader = DataLoader(training_set, batch_size = 100)

subscripts = ["xx","yy","zz","xy","xz","yz"]
for lattice, atomic_numbers, positions, true_polarizability  in loader:
    for i in range(5):
        axis = axes[i]  # type: ignore
        axis.scatter(true_polarizability[:,i+1].cpu(), true_polarizability[:,0].cpu(), color="black")
        axis.set_xlabel(r"$\alpha_{" + subscripts[i+1] + "}$")
        if i == 0:
            axis.set_ylabel(r"$\alpha_{" + subscripts[0] + "}$")
../_images/notebooks_machine-learning_8_0.png

Note that the dataset internally standard-scales each independent element of the polarizability tensor.

Model initialization

With the dataset loaded, we can initialize the model by specifying a reference structure, several hyperparameters, as well as the mean/standardization used to scale the dataset.

[4]:
import torch
from ramannoodle.pmodel.torch import PotGNN
ref_structure = rn.io.vasp.poscar.read_ref_structure("../../../test/data/TiO2/POSCAR")

model = PotGNN(
    ref_structure,
    size_node_embedding=5,
    size_edge_embedding=14,
    num_message_passes=4,
    cutoff = 2,
    gaussian_filter_start=0,
    gaussian_filter_end=5,
    mean_polarizability=training_set.mean_polarizability,
    stddev_polarizability=training_set.stddev_polarizability
)

# Recommended initialization for weights and biases.
def init_biases(m):
    if isinstance(m,torch.nn.Linear):
        torch.nn.init.uniform_(m.bias,-0.5,0.5)
def init_weights(m):
    if isinstance(m,torch.nn.Linear) or isinstance(m,torch.nn.Embedding):
        torch.nn.init.normal_(m.weight, mean = 0, std = 1)

model = model.apply(init_biases)
model = model.apply(init_weights)

In the first stage of training, we use a fairly large learning rate and a relatively small batch size. During this training, we expect the training loss to decrease fairly nicely. Validation loss will be extremely noisy (due to batch normalization within the model), while the variance of the validation predictions will approach 1 for each polarizability component.

[5]:
from ramannoodle.pmodel.torch import train_single_epoch

loss_function = torch.nn.MSELoss()
optimizer = torch.optim.Adam(model.parameters(), lr=0.02)

from tqdm import trange
with trange(100) as t:
    for epoch in t:
        training_loss, validation_loss, validation_var = train_single_epoch(
            model, training_set, validation_set, 5, optimizer, loss_function
        )
        t.set_postfix(
            training_loss=training_loss,
            validation_loss=validation_loss,
            validation_var=np.array2string(validation_var, precision = 2)
        )
        if training_loss < 0.15:
            break

 92%|█████████▏| 92/100 [01:17<00:06,  1.19it/s, training_loss=0.15, validation_loss=3.52, validation_var=[0.93 1.05 0.4  0.56 0.34 0.21]]

We halt training once the training loss goes below 0.150. However, the validation loss has fluctuated wildly during training. In the second stage of training, we turn down the learning rate to stabilize the batch normalization term (and therefore stabilize the validation loss).

[6]:
from ramannoodle.pmodel.torch.train import train_single_epoch

loss_function = torch.nn.MSELoss()
optimizer = torch.optim.Adam(model.parameters(), lr=0.0001)

from tqdm import trange
with trange(100) as t:
    for epoch in t:
        training_loss, validation_loss, validation_var = train_single_epoch(
            model, training_set, validation_set, 5, optimizer, loss_function
        )
        t.set_postfix(
            training_loss=training_loss,
            validation_loss=validation_loss,
            validation_var=np.array2string(validation_var, precision = 2)
        )
        if validation_loss < 0.165:
            break

100%|██████████| 100/100 [01:25<00:00,  1.17it/s, training_loss=0.121, validation_loss=0.179, validation_var=[0.8  1.19 0.65 0.82 1.16 0.82]]

Now that the model has been roughly trained, we can visualize it’s performance.

[7]:
import torcheval
import torcheval.metrics

fig = plt.figure(constrained_layout=True, figsize=(12, 2))
gs = fig.add_gridspec(1, 6, wspace=0)
axes = gs.subplots()
plot_train_loader = DataLoader(training_set, batch_size = 100)
plot_validation_loader = DataLoader(validation_set, batch_size = 100)

model.eval()

subscripts = ["xx","yy","zz","xy","xz","yz"]
for i in range(6):
    axis = axes[i]  # type: ignore
    for lattice, atomic_numbers, positions, true_polarizability  in plot_train_loader:
        predicted_polarizability = model(lattice, atomic_numbers, positions).detach()
        loss = loss_function(true_polarizability[:,i], predicted_polarizability[:,i])
        metric = torcheval.metrics.R2Score()
        metric.update(predicted_polarizability[:,i], true_polarizability[:,i])
        R2 = metric.compute()
        axis.scatter(
            true_polarizability[:,i], predicted_polarizability[:,i], color="black",
            label = f"Training, R2 = {R2:.3f}"
        )
    for lattice, atomic_numbers, positions, true_polarizability  in plot_validation_loader:
        predicted_polarizability = model(lattice, atomic_numbers, positions).detach()
        loss = loss_function(true_polarizability[:,i], predicted_polarizability[:,i])
        metric = torcheval.metrics.R2Score()
        metric.update(predicted_polarizability[:,i], true_polarizability[:,i])
        R2 = metric.compute()
        axis.scatter(
            true_polarizability[:,i], predicted_polarizability[:,i], color="red",
            label = f"Validation, R2 = {R2:.3f}"
        )

    axis.plot([-2.5,2.5],[-2.5,2.5],color = "blue", zorder = 5)
    axis.set_xlabel(r"True $\alpha_{" + subscripts[i] + "}$")
    axis.set_ylabel(r"Predicted $\alpha_{" + subscripts[i] + "}$")
    l = axis.legend(fontsize ="xx-small")
../_images/notebooks_machine-learning_16_0.png

The model is performing fairly well, though it does struggle somewhat on the zz and xy components. With the model trained, we can use it to compute a Raman spectrum.

Raman spectrum calculation

[8]:
# This trajectory is not publicly available. Sorry!
trajectory = rn.io.vasp.vasprun.read_trajectory(
    f"/Volumes/Untitled/md/TiO2/production.xml"
)
[9]:
# Compute spectrum
spectrum = trajectory.get_raman_spectrum(model)
100%|██████████| 20000/20000 [00:39<00:00, 506.79 configs/s]
[10]:
# Then plot
wavenumbers, total_intensities = spectrum.measure(laser_correction = True,
                                           laser_wavelength = 532,
                                           bose_einstein_correction = True,
                                           temperature = 300)

fig = plt.figure(constrained_layout = True, figsize = (8, 3))
axis = fig.add_subplot(111)
lower_cutoff = 50
axis.plot(
    wavenumbers[wavenumbers > lower_cutoff],
    total_intensities[wavenumbers > lower_cutoff] / np.max(total_intensities[wavenumbers > lower_cutoff]),
    label = "raw", color = 'black', linewidth = 0.5
)
wavenumbers, total_intensities = rn.spectrum.utils.convolve_spectrum(wavenumbers, total_intensities)
axis.plot(
    wavenumbers[wavenumbers > lower_cutoff],
    total_intensities[wavenumbers > lower_cutoff] / np.max(total_intensities[wavenumbers > lower_cutoff]),
    label = "smoothed", color = "red"
)

axis.set_xlim((0,1000))
axis.legend()
axis.set_ylabel("Intensity (a.u.)")
l = axis.set_xlabel(r"Raman shift ($\mathregular{cm^{-1}}$)")
../_images/notebooks_machine-learning_21_0.png

This is a excellent spectrum that closely resembles experimental data.