6  Physics-Based Simulation and Representations

In data-driven chemistry, we start from a 2D molecular graph and make predictions based on available experimental data. A complementary route is to predict chemistry from first principles. We can achieve this by computing the molecular Hamiltonian as a function of the positions and charges of its nuclei and the number of electrons, and from that derive everything else: stable structures, reaction barriers, spectra, solubility, etc. This is the domain of physics-based simulation.

The appeal is obvious. If we can compute a property instead of measuring it, we can replace experiments that are too expensive, too slow, or too dangerous, and we can generate as much (clean, consistent) data as we are willing to pay for. The difficulty is equally obvious: solving the underlying physics exactly is computationally intractable for all but the smallest systems, so the whole field is built around a hierarchy of approximations that trade accuracy for cost.

In this chapter we will explain how simulation works and why it is hard: the potential energy surface, the Schrödinger equation, and the cost–accuracy hierarchy of methods (force fields, ML potentials, semi-empirical, DFT, correlated wavefunction methods). Then we will see how the output of simulation becomes a molecular representation for machine learning: quantum-chemical descriptors (charges, Fukui indices). Finally, we will present some descriptors that do not involve simulations but were inspired by physics to be data-efficient and descriptive (the Coulomb matrix, SOAP), which featurize 3D coordinates and atom types as input for machine-learning models.

6.1 Predicting chemistry from first principles

6.1.1 The potential energy surface

A molecule of \(N\) atoms lives in a \(3N\)-dimensional space of nuclear coordinates. For every geometry \(\mathbf{R} = (\mathbf{r}_1, \dots, \mathbf{r}_N)\) there is a single number — the electronic energy of that arrangement. The function mapping coordinates to energy,

\[ E = E(\mathbf{r}_1, \mathbf{r}_2, \dots, \mathbf{r}_N), \]

is the potential energy surface (PES). Almost all of chemistry can be read off the shape of this surface:

  • Minima are stable structures — conformers, isomers, reactants, products. A molecule at rest sits in a minimum.
  • A first-order saddle point along a reaction coordinate — a maximum along the path but a minimum in every direction perpendicular to it — is a transition state; the energy difference from a minimum to the connecting saddle point is a reaction barrier, which controls reaction rates.
  • Energy differences between minima are reaction or conformational energies, which control equilibria and selectivity.
  • Free energies can be obtained by simulating dynamics on the PES or from static picture under certain approximations.

The fundamental task of any simulation method is therefore to compute the energy at a given geometry — and, ideally, its derivatives. The first derivative with respect to coordinates gives the forces (used to optimize geometries and run molecular dynamics); the second derivatives give the vibrational frequencies (used for thermochemistry and IR spectra). Other properties are derivatives with respect to an external field rather than to the coordinates: the dipole moment and polarizability are the first and second derivatives of the energy with respect to an electric field, and NMR shieldings involve a magnetic field.

Note

The PES is a high-dimensional object, but we usually visualize a 1D or 2D slice along the coordinate(s) that change during a process — e.g. a dihedral angle for a conformational change, or a forming/breaking bond length for a reaction.

Code
import numpy as np
import matplotlib.pyplot as plt

plt.rcParams.update(
    {
        "font.family": "serif",
        "figure.facecolor": "#fafaf7",
        "axes.facecolor": "#fafaf7",
    }
)

np.random.seed(0)
x = np.linspace(0, 10, 500)
# A double-well with a barrier: reactant minimum, TS, product minimum.
# The linear tilt makes the product lie lower than the reactant (exergonic).
y = (
    0.6 * (x - 2.0) ** 2 * np.exp(-0.25 * (x - 2.0) ** 2)
    + 1.0 * np.exp(-1.2 * (x - 5.0) ** 2) * 2.2
    + 0.5 * (x - 8.0) ** 2 * np.exp(-0.25 * (x - 8.0) ** 2)
)
y = y - 0.18 * x
y = y - y.min()

r_i = np.argmin(np.abs(x - 2.0))  # reactant minimum
p_i = np.argmin(np.abs(x - 8.0))  # product minimum
ts_i = np.argmin(np.abs(x - 5.0))  # transition state

fig, ax = plt.subplots(figsize=(6.4, 3.8))
ax.plot(x, y, color="#2b6cb0", lw=2.5)

# Reactant minimum
ax.annotate(
    "Minimum\n(reactant / conformer)",
    xy=(2.0, y[r_i]),
    xytext=(0.2, 3.2),
    fontsize=9,
    color="#276749",
    ha="left",
    arrowprops=dict(arrowstyle="->", color="#276749"),
)
# Product minimum
ax.annotate(
    "Minimum\n(product / isomer)",
    xy=(8.0, y[p_i]),
    xytext=(7.3, 2.1),
    fontsize=9,
    color="#276749",
    ha="left",
    arrowprops=dict(arrowstyle="->", color="#276749"),
)
# Transition state
ax.annotate(
    "Saddle point\n(transition state)",
    xy=(5.0, y[ts_i]),
    xytext=(2.6, 5.6),
    fontsize=9,
    color="#c53030",
    ha="left",
    arrowprops=dict(arrowstyle="->", color="#c53030"),
)
# Barrier arrow (activation energy from the reactant level)
ax.plot([2.0, 5.0], [y[r_i], y[r_i]], ls=":", lw=1.0, color="#888888")
ax.annotate(
    "",
    xy=(5.0, y[ts_i]),
    xytext=(5.0, y[r_i]),
    arrowprops=dict(arrowstyle="<->", color="#888888"),
)
ax.text(
    5.3,
    0.5 * (y[ts_i] + y[r_i]) - 1.2,
    "Energy\nbarrier",
    fontsize=8.5,
    color="#555555",
    va="center",
)

# Reaction energy: difference between reactant and product minima (exergonic here)
de_x = 1.8
ax.plot([2.0, de_x], [y[r_i], y[r_i]], ls=":", lw=1.0, color="#888888")
ax.plot([8.0, de_x], [y[p_i], y[p_i]], ls=":", lw=1.0, color="#888888")
ax.annotate(
    "",
    xy=(de_x, y[p_i]),
    xytext=(de_x, y[r_i]),
    arrowprops=dict(arrowstyle="<->", color="#9b2c2c"),
)
ax.text(
    0.2,
    0.5 * (y[r_i] + y[p_i]),
    "Reaction\nenergy",
    fontsize=8.5,
    color="#9b2c2c",
    ha="left",
    va="center",
)

ax.set_xlabel("Reaction coordinate")
ax.set_ylabel("Energy")
ax.set_title("Slice through a potential energy surface")
ax.set_yticks([])
ax.set_ylim(-0.3, 6.6)
ax.margins(x=0.02)
plt.tight_layout()
plt.show()
Figure 6.1: A 1D slice through a potential energy surface along a reaction coordinate, with the key topographical features labeled.

6.1.2 The Schrödinger equation

Where does the energy come from? In principle, from the time-independent Schrödinger equation,

\[ \hat{H}\,\Psi = E\,\Psi, \]

where \(\hat{H}\) is the Hamiltonian operator (kinetic energy of nuclei and electrons plus all the Coulomb interactions between them), \(\Psi\) is the many-body wavefunction, and \(E\) is the energy. The Born–Oppenheimer approximation lets us separate the fast electrons from the slow nuclei: we clamp the nuclei in place, solve for the electrons, and obtain the electronic energy at that geometry — which is exactly the PES.

The catch is that \(\Psi\) depends on the coordinates of all the electrons simultaneously, and the electron–electron repulsion couples them all together. Solving the equation exactly scales exponentially with the number of electrons, so an exact solution is intractable for anything beyond a handful of atoms. Every practical method is a controlled approximation to this equation — and the choice of approximation is the central decision in any simulation.

6.2 The cost–accuracy hierarchy of methods

Methods for computing the energy form a hierarchy, from cheap-and-crude to expensive-and-exact:

Family Examples Idea Typical scaling
Force fields MMFF94, UFF, GFN-FF Classical springs and charges; no electrons \(\sim N\)
ML potentials ANI-2x, MACE-OFF, AIMNet2 Learn the PES from QM data \(\sim N\)
Semi-empirical GFN2-xTB, PM6, AM1 HF with most integrals replaced by parameters \(\sim N^2\)
DFT B3LYP, r²SCAN-3c, ωB97X-3c Energy as a functional of electron density \(\sim M^3\)
Correlated wavefunction MP2, CCSD(T), Full CI Explicit electron correlation; converges to exact \(M^5\) – \(M^7\)

Here \(N\) is the number of atoms and \(M\) the number of basis functions (roughly proportional to the number of electrons in the system). Force fields ignore electrons entirely: the energy is a sum of classical terms for bond stretches, angle bends, torsions, van der Waals, and electrostatics, with parameters fitted to experiment or to higher-level calculations. Correlated wavefunction methods, at the other extreme, treat electron correlation explicitly and converge to the exact answer (Full CI) as the basis set and the level of electron correlation increase.

6.2.1 Computational scaling is the cost driver

The reason the hierarchy exists at all is computational scaling — how the cost grows with system size. The exponents are brutal for the accurate methods:

  • Hartree–Fock (HF): \(\approx M^4\)
  • MP2: \(\approx M^5\)
  • CISD: \(\approx M^6\)
  • CCSD(T) (the “gold standard”): \(\approx M^7\)
  • Semi-empirical: \(\approx N^2\)
  • DFT (Kohn–Sham): \(\approx M^3\)

An \(M^7\) scaling means that doubling the system size makes the calculation \(2^7 = 128\) times more expensive. This is why CCSD(T) is reserved for small molecules, DFT is the everyday workhorse, and force fields / ML potentials (which scale roughly linearly with the number of atoms thanks to distance cutoffs) are the only option for proteins and materials. Note that these exponents describe how cost grows with system size, not the absolute cost of a given calculation. Force fields and ML potentials share the same \(\sim N\) scaling, but that does not make them equally fast: evaluating a classical force field is orders of magnitude cheaper per atom than evaluating a neural network, so force fields remain the choice when very long simulations are needed.

Code
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.patches import Ellipse

plt.rcParams.update({
    "font.family": "serif",
    "figure.facecolor": "#fafaf7",
    "axes.facecolor": "#fafaf7",
})

fig, ax = plt.subplots(figsize=(7.0, 5.2))
ax.set_xlim(0, 11)
ax.set_ylim(0, 11)

# Trade-off frontier: cost explodes as we demand more accuracy (accuracy on x, cost on y).
A, B = 3.52, 1.16
acc = np.linspace(1.0, 9.55, 200)
ax.plot(acc, np.exp((acc - B) / A), ls="--", lw=1.3, color="#999999", zorder=1)
ax.text(8.85, 5.5, "conventional trade-off", fontsize=8.5, color="#888888",
        rotation=52, rotation_mode="anchor", ha="center", va="center")

# (name, scaling, cx=accuracy, cy=cost, width, height, color)
# Ellipse height = spread in cost: small for FF/semi-empirical, large for DFT (functionals vary widely).
families = [
    ("Force fields",          r"$\sim N$",        1.8, 1.2, 2.0, 1.4, "#9b2c2c"),
    ("Semi-empirical",        r"$\sim N^2$",      4.0, 2.4, 2.0, 1.5, "#276749"),
    ("DFT",                   r"$\sim M^3$",      6.75, 4.9, 2.2, 3.8, "#2b6cb0"),
    ("Correlated\nwavefunction", r"$\sim M^{5\!-\!7}$", 8.8, 9.3, 2.6, 2.6, "#553c9a"),
    ("ML potentials",         r"$\sim N$",        6.3, 1.5, 2.3, 1.9, "#c05621"),
]

for name, scale, cx, cy, w, h, color in families:
    ax.add_patch(Ellipse((cx, cy), w, h, facecolor=color, alpha=0.20,
                         edgecolor=color, lw=1.8, zorder=2))
    ax.text(cx, cy + 0.45, name, ha="center", va="center", fontsize=10.5,
            fontweight="bold", color=color, zorder=3)
    ax.text(cx, cy - 0.6, scale, ha="center", va="center", fontsize=9.5,
            color=color, fontstyle="italic", zorder=3)

# ML potentials sit far below the curve: near-DFT accuracy at a fraction of the cost
ax.annotate("lower cost,\nsame accuracy", xy=(5.5, 1.35), xytext=(4.0, 0.9),
            fontsize=8.5, color="#c05621", ha="center", va="center",
            arrowprops=dict(arrowstyle="->", color="#c05621", lw=1.2))

# Chemical-accuracy guide line (now vertical)
ax.axvline(9.9, ls=":", lw=1.0, color="#555555")
ax.text(9.6, 0.4, "chemical accuracy (~1 kcal/mol)", rotation=90, fontsize=8,
        color="#555555", va="bottom", ha="left")

ax.set_xlabel("Accuracy  →", fontsize=10)
ax.set_ylabel("Computational cost per evaluation  →", fontsize=10)
ax.set_xticks([])
ax.set_yticks([])
for spine in ("top", "right"):
    ax.spines[spine].set_visible(False)
ax.set_title("Cost–accuracy landscape", fontsize=13, fontweight="bold")
plt.tight_layout()
plt.show()
Figure 6.2: The cost–accuracy hierarchy of electronic-structure methods. The classical families trace a trade-off curve along which cost explodes as accuracy improves (note the large cost jump from DFT to coupled cluster, and the wide spread in DFT cost across functionals). ML potentials break the curve, reaching nearly DFT-level accuracy at close to force-field cost.

6.2.2 Bending the scaling curve

The exponents above are only the nominal scalings. Much of modern method development goes into beating them without sacrificing accuracy, which effectively shifts a method leftward in the cost–accuracy landscape.

The most expensive quantities in a calculation are often nearly redundant or strongly local. Density fitting / resolution-of-the-identity (RI) and chain-of-spheres exchange (COSX) rewrite the costly electron-repulsion integrals of DFT in a far cheaper form, usually with negligible error. Local correlation methods such as DLPNO-CCSD(T) exploit the short-rangedness of electron correlation to confine the expensive coupled-cluster work to localized orbital pairs, bringing the nominal \(M^7\) scaling down toward near-linear while staying close to gold-standard accuracy.

6.3 DFT and the functional zoo

Density functional theory (DFT) is the dominant method in practical computational chemistry because its \(\sim M^3\) scaling buys a lot of accuracy per CPU hour. Its foundation is the Hohenberg–Kohn theorem: the ground-state electron density \(\rho(\mathbf{r})\) — a function of just three spatial coordinates, no matter how many electrons — fully determines the energy and all properties of the system. We no longer need the full many-electron wavefunction.

This does not, however, make DFT a purely density-based method in practice. Because the kinetic energy is not known accurately as a functional of the density alone, most of DFT implementations are Kohn–Sham DFT, which reintroduces a set of one-electron orbitals to evaluate it. Those orbitals bring back integrals over basis functions, so the formal cost is \(M^3\)–\(M^4\) rather than anything like the cost of working with a three-dimensional density. In Kohn–Sham DFT the energy is written as

\[ E_{\text{DFT}}[\rho] = T_{\text{S}}[\rho] + E_{\text{ne}}[\rho] + J[\rho] + E_{\text{xc}}[\rho], \]

where \(T_{\text{S}}\) is the kinetic energy of non-interacting electrons, \(E_{\text{ne}}\) the nuclear–electron attraction, and \(J\) the classical Coulomb repulsion of the density with itself. The first three terms are well-behaved and known exactly. The trouble is the last term, the exchange–correlation functional \(E_{\text{xc}}[\rho]\), which sweeps up all the quantum subtleties (exchange and electron correlation) into one object whose exact form is unknown. Every DFT calculation therefore uses an approximation to \(E_{\text{xc}}\) — a density functional approximation (DFA).

6.3.1 Jacob’s ladder

The DFAs are organized into rungs of increasing sophistication, cost, and (on average) accuracy — a hierarchy known as Jacob’s ladder, climbing from the “Hartree hell” of crude approximations toward the “heaven of chemical accuracy”:

  1. LDA — uses only the local density \(\rho(\mathbf{r})\).
  2. GGA — adds the density gradient \(\nabla\rho(\mathbf{r})\).
  3. meta-GGA — adds the kinetic-energy density \(\tau(\mathbf{r})\), or equivalently the density Laplacian \(\nabla^2\rho(\mathbf{r})\).
  4. Hybrid — mixes in a fraction of exact (Hartree–Fock) exchange; often the best accuracy/cost compromise.
  5. Double-hybrid — adds MP2-like correlation; most accurate, most expensive.
Figure 6.3: Jacob’s Ladder of density-functional approximations. Each rung adds a new ingredient: the density \(\rho\), its gradient \(\nabla\rho\), the kinetic-energy density \(\tau\), then information from the occupied orbitals (hybrids) and from the virtual orbitals as well (double-hybrids), climbing in cost and (on average) accuracy from the “Hartree world” toward chemical accuracy, with example functionals on each rung. Source: Riebesell (2022) (MIT license).

Crucially, no one knows the exact functional, and a higher rung is only better on average for lower rungs of the ladder. Hundreds of functionals exist (benchmark studies such as Goerigk and Mehta (2019) compare ~100 of them), and the best choice depends on your system and property. The practical rule is to test for your use case rather than trust a ranking blindly.

6.3.2 Absolute values, error cancellation, and relative ranking

A theme that runs through all of computational chemistry: absolute energies are extremely hard to compute accurately. The total electronic energy of even a small molecule is hundreds or thousands of kcal/mol, and getting it right to “chemical accuracy” (~1 kcal/mol) in absolute terms is essentially hopeless with affordable methods. We almost never need the absolute number, though. Two quite different things rescue us, and it is worth keeping them apart — they are often lumped together under the single label “error cancellation”.

1. Error cancellation (in narrow sense). What matters chemically is usually a difference between two species computed the same way: a reaction energy (product − reactant) or a barrier (transition state − reactant). If both species carry a similar systematic error, that error largely subtracts out of the difference, and the computed difference is far more accurate than either absolute energy behind it. This works best when the two species are genuinely similar: same number and type of atoms, similar bonding, same basis set and functional. It works badly when they are not — which is why reactions that change the number of bonds of a given type, or the spin state, or the charge, are much harder than a conformer gap. So relative energies are more accessible than absolute ones, but they are not automatically easy; the cancellation has to be earned by comparing like with like.

2. Trend preservation, i.e. relative ranking. A separate and weaker claim: even when nothing cancels, a systematic offset may leave the ordering of a series intact. Figure 6.4 shows this: DFT-computed one-electron reduction potentials for 345 quinones sit about 4.5 V away from experiment (the regression intercept is ≈4486 mV), yet fall on a tight line (\(r = 0.96\), mean absolute error only ~83 mV about that line). The offset is dominated by quantities the calculation simply omitted or referenced differently: the absolute electrode reference and the treatment of solvation. This is an inherent tradeoff with modeling: we have to choose what we include in the model and discard the rest, hoping the trend will survive. In this case, what survives is a near-constant shift plus a preserved trend, so the ranking of the 345 quinones is reproduced almost perfectly even though every absolute number is off by volts. In practice this is still extremely useful: a method with a large absolute error can rank a chemical series correctly, which is all a screening campaign needs. But it comes with a different caveat: a constant offset is only constant within a family. Note in panel (b) that each quinone class has its own line. Move to a chemically different series and the offset generally moves with it, so trends should be calibrated per family rather than assumed transferable.

Figure 6.4: DFT-computed vs. experimental one-electron reduction potentials for 345 quinones. (a) The absolute computed potentials are offset from experiment by a large, nearly constant amount (intercept ≈ 4486 mV), while the relative trend is captured almost perfectly (\(r = 0.96\)). (b) The same data split by quinone class, each a tight correlation line. Reproduced from Elhajj and Gozem (2024) under CC BY 4.0.

6.4 Semi-empirical methods: simple ML for quantum chemistry

Between force fields and DFT sit the semi-empirical methods (AM1, PM6, GFN2-xTB). They start from the Hartree–Fock framework but make a radical simplification: instead of computing the expensive two-electron repulsion integrals, they neglect most of them and replace the rest with a small number of fitted parameters.

The integral they want to avoid is the two-electron Coulomb integral

\[ \gamma_{\text{AB}} = (\mu\mu \mid \lambda\lambda) = \iint \phi_\mu(1)\,\phi_\mu(1)\,\frac{1}{r_{12}}\,\phi_\lambda(2)\,\phi_\lambda(2)\, d\mathbf{r}_1\, d\mathbf{r}_2, \]

which is what makes HF scale as \(M^4\). The Pariser–Parr approximation replaces the on-atom version with experimentally available quantities — the ionization potential and electron affinity of the atom:

\[ \gamma_{\text{AA}} = \text{IP}_{\text{A}} - \text{EA}_{\text{A}}. \]

This is exactly the logic of machine learning: rather than computing a quantity from first principles, fit a small number of parameters to reference data and let those parameters carry the missing physics. Electron correlation, which a force field ignores and a correlated wavefunction method computes laboriously, is absorbed implicitly into the parameters. In this precise sense, semi-empirical methods are “simple ML for quantum chemistry” — they form the conceptual bridge from physics-based simulation to the data-driven methods in the rest of the course. The payoff is a method that scales like \(\sim N^2\) and runs in seconds, fast enough to screen thousands of molecules or even handle proteins.

6.5 Quantum-chemical descriptors as model inputs

So far simulation has been the goal. But the output of a quantum-chemical calculation can also be the input to a machine-learning model. The idea is to compute a handful of QM quantities — partial charges, HOMO/LUMO energies, Fukui indices, electrostatic-potential features, NMR shifts — and feed them as features to predict some other property such as reactivity, yield, or selectivity.

Steric descriptors such as buried volume are often used alongside them; these are computed from the 3D geometry rather than from the electronic structure, so they need a structure but not the electronic-structure calculation itself.

These descriptors are richer than 2D graph descriptors because they encode the electronic structure directly. A Morgan fingerprint knows the connectivity of an atom; a partial charge knows whether that atom is electron-rich or electron-poor. For problems where reactivity is governed by electronics, that difference is decisive.

6.5.1 Fukui functions and regioselectivity

A classic example is predicting where a molecule reacts. The Fukui function (Kenichi Fukui, Nobel Prize 1981) measures how the electron density at an atom responds to gaining or losing an electron — i.e. how reactive each site is toward nucleophiles, electrophiles, or radicals. In the practical condensed-to-atom form, the indices are differences of atomic charges \(q_k\) at electron counts \(N\), \(N+1\), and \(N-1\):

\[ f_k^+ = q_k(N+1) - q_k(N) \quad \text{(nucleophilic attack site)} \] \[ f_k^- = q_k(N) - q_k(N-1) \quad \text{(electrophilic attack site)} \] \[ f_k^0 = \frac{q_k(N+1) - q_k(N-1)}{2} \quad \text{(radical attack site)} \]

The signs can be counter-intuitive, so it helps to read \(f^+\) as “where does added electron density go?” — that is the LUMO-like site that an electron-rich nucleophile seeks; conversely \(f^-\) measures where removing electrons costs the least, the HOMO-like site an electrophile attacks. The atom with the largest index is the predicted reactive site. Nucleophilic aromatic substitution (S\(_N\)Ar) is governed by \(f^+\) (where a nucleophile adds); electrophilic aromatic substitution (EAS) by \(f^-\); and radical C–H functionalization of N-heteroarenes can be ranked by \(f^0\) — in published work the highest-\(f^0\) atom matches the experimentally observed site of functionalization (e.g. (Ma et al. 2014)).

Computing true Fukui indices requires three separate single-point calculations — the neutral molecule and its cation and anion at the same geometry (\(N\), \(N{-}1\), \(N{+}1\) electrons) — followed by an atomic-population analysis. The figure below carries out exactly that recipe with a small DFT calculation (B3LYP/6-31G). It uses Mulliken populations, the simplest version of the original prescription, and maps the resulting condensed Fukui indices onto two textbook cases: the nucleophilic index \(f^+\) on pyridine and the electrophilic index \(f^-\) on phenol. Mulliken charges are strongly basis-set dependent and are not recommended for production work; prefer a real-space partition such as Hirshfeld or CM5.

Code
import numpy as np
import matplotlib
import matplotlib.pyplot as plt
from io import BytesIO
from PIL import Image
from rdkit import Chem
from rdkit.Chem import AllChem
from rdkit.Chem.Draw import rdMolDraw2D

# SMILES for each panel.
SMILES = {
    "pyridine_fplus": "c1ccncc1",
    "phenol_fminus": "c1ccccc1O",
}

# Condensed Fukui indices, precomputed once with PySCF (B3LYP/6-31G: three
# single points N, N-1, N+1 at the neutral geometry, Mulliken populations) and
# hard-coded here so the chapter renders instantly — no DFT runs at build time.
fukui = {
    "pyridine_fplus": [
        0.08845432741724579,
        0.013564584745770603,
        0.046503549526534016,
        0.13528518184672045,
        0.04650806247914829,
        0.01354911791494473,
    ],
    "phenol_fminus": [
        0.058103906855269116,
        0.023058290909734325,
        0.07169805427696119,
        0.017245078770147693,
        0.06236494418084071,
        0.02289280907021407,
        0.1543379463571597,
    ],
}
# Ring-position labels (2-, 3-, 4- relative to the heteroatom), so it is
# unambiguous which printed value belongs to which position.
POSITIONS = {
    "pyridine_fplus": {2: "2", 4: "2", 1: "3", 5: "3", 0: "4"},
    "phenol_fminus": {1: "2", 5: "2", 2: "3", 4: "3", 3: "4"},
}


def fukui_image(smiles, weights, cmap_name, positions=None):
    mol = Chem.MolFromSmiles(smiles)
    AllChem.Compute2DCoords(mol)
    norm = matplotlib.colors.Normalize(vmin=0, vmax=max(weights))
    cmap = matplotlib.colormaps[cmap_name]
    atom_colors = {i: cmap(norm(w)) for i, w in enumerate(weights)}
    radii = {i: 0.45 for i in range(len(weights))}
    positions = positions or {}
    for i, w in enumerate(weights):
        label = f"{positions[i]}: {w:.2f}" if i in positions else f"{w:.2f}"
        mol.GetAtomWithIdx(i).SetProp("atomNote", label)
    d = rdMolDraw2D.MolDraw2DCairo(470, 400)
    d.drawOptions().annotationFontScale = 0.75
    rdMolDraw2D.PrepareAndDrawMolecule(
        d,
        mol,
        highlightAtoms=list(range(len(weights))),
        highlightAtomColors=atom_colors,
        highlightAtomRadii=radii,
    )
    d.FinishDrawing()
    return Image.open(BytesIO(d.GetDrawingText())), norm, cmap


plt.rcParams.update({"font.family": "serif", "figure.facecolor": "#fafaf7"})
fig, axes = plt.subplots(1, 2, figsize=(8.4, 4.3))
panels = [
    ("pyridine_fplus", "Blues", "Nucleophilic  $f^+$  (pyridine)"),
    ("phenol_fminus", "Reds", "Electrophilic  $f^-$  (phenol)"),
]
for ax, (key, cmap_name, title) in zip(axes, panels):
    img, norm, cmap = fukui_image(SMILES[key], fukui[key], cmap_name, POSITIONS[key])
    ax.imshow(img)
    ax.axis("off")
    ax.set_title(title, fontsize=10.5)
    cb = fig.colorbar(
        matplotlib.cm.ScalarMappable(norm=norm, cmap=cmap),
        ax=ax,
        fraction=0.046,
        pad=0.02,
    )
    cb.set_label("Fukui index", fontsize=8)
    cb.ax.tick_params(labelsize=7)
plt.tight_layout()
plt.show()
Figure 6.5: Condensed Fukui indices from a small DFT calculation (B3LYP/6-31G, three single points at the neutral geometry). Ring atoms are labelled by their position relative to the heteroatom. Left: among the ring carbons of pyridine the nucleophilic index f+ is largest at the 4- and 2-positions and smallest at the 3-position, matching where nucleophiles add; the nitrogen itself carries the single largest value but is not a substitution site. Right: the electrophilic index f- on phenol is largest on the ortho/para positions, the sites of electrophilic aromatic substitution. The indices do not resolve 2- versus 4- (or ortho versus para): pyridine has two equivalent 2-positions against a single 4-position (phenol, two ortho against one para), a multiplicity the per-atom index does not carry, and the split between them is set mainly by the attacking nucleophile or electrophile. Disc colour and the printed number give the Fukui value at each atom.
Note

These are condensed-to-atom Fukui indices from Mulliken populations at B3LYP/6-31G, evaluated at the fixed neutral geometry (the vertical approximation).1 The chemically meaningful part is the ring-carbon ordering: \(f^+\) peaks on pyridine’s 2- and 4-positions — exactly where nucleophiles add (Chichibabin amination, S\(_N\)Ar) — while \(f^-\) peaks on phenol’s ortho/para carbons, the observed sites of electrophilic aromatic substitution. The heteroatoms (N, O) carry the very largest values, since added or removed electron density localizes there, but they are not the C–H substitution sites.

What the indices cannot do is choose between the 2- and the 4-position (or, on phenol, between ortho and para). Two effects are missing. First, multiplicity: pyridine has two equivalent 2-positions and only one 4-position, and phenol has two ortho carbons and a single para carbon. Symmetry-equivalent atoms carry identical Fukui values, and the condensed index is a per-atom quantity that says nothing about how many such atoms the molecule has — so even for two sites with exactly equal indices the statistical factor of two already tilts the product ratio toward 2- (or ortho). Second, the reagent: which of the two positions wins is largely set by the attacking partner and the conditions, not by the substrate’s ground-state electron density. Chichibabin amination with NaNH\(_2\) goes cleanly to C2, where the counter-ion can coordinate the ring nitrogen and deliver the nucleophile, whereas bulky nucleophiles are pushed toward the sterically open 4-position; on phenol the ortho/para ratio tracks the size of the electrophile, with bulky electrophiles (e.g. tert-butylation) giving almost exclusively the para product. Fukui indices therefore separate reactive sites from unreactive ones — 2-/4- over 3- on pyridine, ortho/para over meta on phenol — but resolving the 2- versus 4- split needs the reaction partner, the solvent, and ultimately the transition state.

Note also that ground-state Fukui indices capture frontier-orbital control well; they do not capture cases dominated by intermediate stability, such as the meta-selectivity of nitrobenzene.

6.6 Physics-inspired descriptors

The QM descriptors above are hand-picked scalar quantities. A more systematic goal is to turn raw 3D coordinates into a single fixed-length vector that an ML model can consume — and to do so by mimicking how physics computes energy. The guiding principle is the many-body expansion, in which the total energy decomposes into one-body, two-body, three-body, … contributions:

\[ E = V_0 + \sum_i V_1(\mathbf{r}_i) + \sum_{i,j} V_2(r_{ij}) + \sum_{i,j,k} V_3(\theta_{ijk}, \dots) + \cdots \]

Two-body terms depend on interatomic distances, three-body terms on angles, and so on, and properties are often built as sums of atomic contributions \(E = \sum_i E_i\). A good descriptor should encode this same hierarchy.

6.6.1 Required properties of a good representation

Before building any descriptor, it is worth stating what makes one good. A representation should be:

  • Permutation-invariant: re-labeling identical atoms must not change the vector.
  • Rotation-invariant: rotating the molecule in space must not change the vector.
  • Translation-invariant: moving the molecule must not change the vector.
  • Complete: different structures must map to different vectors (no two distinct geometries collapse to the same point).
  • Smooth: a small change in geometry produces a small change in the vector (so the learned function is well-behaved).
  • Additive: the representation should support building a total property as a sum of atomic contributions.

The first three are symmetries — physical operations that leave the energy unchanged must leave the descriptor unchanged. The last three control how learnable and expressive the descriptor is. Different descriptors satisfy these to different degrees, and the failures are instructive.

6.6.2 The Coulomb matrix

The simplest physics-inspired descriptor is the Coulomb matrix (Rupp et al. 2012). A model needs a vector, not a matrix, so the matrix is unravelled into one. Since it is symmetric, only the upper triangle and diagonal are read out, in a fixed order, giving a vector of length \(N(N+1)/2\). This is then zero-padded to a common length so molecules of different sizes give vectors of equal size. For a molecule of \(N\) atoms the matrix is built from nuclear charges \(Z_i\) and interatomic distances \(r_{ij}\): \[ C_{ii} = 0.5\, Z_i^{2.4}, \qquad C_{ij} = \frac{Z_i Z_j}{r_{ij}}. \]

The diagonal encodes the atoms (a polynomial fit to free-atom energies); the off-diagonal encodes Coulomb-like two-body interactions. Because it depends only on charges and distances, the Coulomb matrix is automatically rotation- and translation-invariant.

But it is not permutation-invariant: if you re-order the atoms, you permute the rows and columns and get a different matrix — even though it is the same molecule. The figure below builds a Coulomb matrix for ethanol from RDKit 3D coordinates and shows that simply shuffling the atom order produces a visibly different matrix.

Code
import numpy as np
import matplotlib.pyplot as plt
from rdkit import Chem
from rdkit.Chem import AllChem

plt.rcParams.update({
    "font.family": "serif",
    "figure.facecolor": "#fafaf7",
    "axes.facecolor": "#fafaf7",
})

np.random.seed(2)

# Build 3D ethanol
mol = Chem.AddHs(Chem.MolFromSmiles("CCO"))
AllChem.EmbedMolecule(mol, randomSeed=42)  # ETKDG
AllChem.MMFFOptimizeMolecule(mol)
conf = mol.GetConformer()

Z = np.array([a.GetAtomicNum() for a in mol.GetAtoms()], dtype=float)
coords = np.array([[conf.GetAtomPosition(i).x,
                    conf.GetAtomPosition(i).y,
                    conf.GetAtomPosition(i).z]
                   for i in range(mol.GetNumAtoms())])

def coulomb_matrix(Z, coords):
    n = len(Z)
    C = np.zeros((n, n))
    for i in range(n):
        for j in range(n):
            if i == j:
                C[i, j] = 0.5 * Z[i] ** 2.4
            else:
                r = np.linalg.norm(coords[i] - coords[j])
                C[i, j] = Z[i] * Z[j] / r
    return C

C = coulomb_matrix(Z, coords)

# Permute atom order
perm = np.random.permutation(len(Z))
C_perm = coulomb_matrix(Z[perm], coords[perm])

fig, axes = plt.subplots(1, 2, figsize=(9, 4.2))
vmax = C.max()
for ax, M, title in [(axes[0], C, "Original atom order"),
                     (axes[1], C_perm, "Permuted atom order")]:
    im = ax.imshow(M, cmap="viridis", vmin=0, vmax=vmax)
    ax.set_title(title, fontsize=11)
    ax.set_xlabel("atom index")
    ax.set_ylabel("atom index")
fig.colorbar(im, ax=axes, fraction=0.046, pad=0.04, label=r"$C_{ij}$")
fig.suptitle("Coulomb matrix is not permutation-invariant", fontsize=13, fontweight="bold")
plt.show()
Figure 6.6: Coulomb matrix of ethanol (left) and the same molecule with atoms re-ordered (right). The matrices differ, illustrating that the Coulomb matrix is NOT permutation-invariant.

The usual fixes restore permutation invariance exactly: sort the rows by norm (canonicalization), use the eigenvalue spectrum of the matrix (invariant to permutation by construction), or decompose into Bag of Bonds (group entries by atom-pair type). The invariance they achieve is exact, but some information is lost in this transformation: different environments can map to the same sorted or spectral representation, and, for sorting, smoothness is lost, since a small change in geometry can swap two rows.

6.6.3 SOAP: Smooth Overlap of Atomic Positions

A more powerful and widely used descriptor is SOAP (Smooth Overlap of Atomic Positions; (Deringer et al. 2021)). Its construction is a direct implementation of the “smear out the atoms and characterize the local density” idea:

  1. Place a Gaussian on every atom.
  2. Sum them to build a smooth atomic density \(\rho_i(\mathbf{r})\) within a cutoff radius \(r_{\text{cut}}\) around a central atom \(i\).
  3. Expand that density in a basis of radial functions and spherical harmonics (the angular part).
  4. Form the rotationally averaged power spectrum — combining the expansion coefficients so that the result is invariant to rotation. That power spectrum is the SOAP vector.

The power spectrum correlates the density with itself at two points in space around the central atom. Together with the central atom that makes SOAP a 3-body descriptor, but note that only the central atom is an atom: the other two are points at which the neighbour density is evaluated, and the neighbours enter only through the density they build up. and it is permutation-, rotation-, and translation-invariant and smooth by construction. The figure below illustrates step 2 — the “smeared” atomic density that SOAP characterizes — by summing 2D Gaussians placed at a few atom positions.

Code
import numpy as np
import matplotlib.pyplot as plt

plt.rcParams.update({
    "font.family": "serif",
    "figure.facecolor": "#fafaf7",
    "axes.facecolor": "#fafaf7",
})

np.random.seed(3)
# A central atom + a few neighbors (toy 2D positions, Angstrom-like)
central = np.array([0.0, 0.0])
neighbors = np.array([
    [1.1, 0.3],
    [-0.9, 0.8],
    [0.2, -1.2],
    [-1.0, -0.7],
])
atoms = np.vstack([central, neighbors])
sigma = 0.45
r_cut = 2.2

# Grid
g = np.linspace(-3, 3, 300)
X, Y = np.meshgrid(g, g)
density = np.zeros_like(X)
for (ax_, ay_) in atoms:
    density += np.exp(-((X - ax_) ** 2 + (Y - ay_) ** 2) / (2 * sigma ** 2))

fig, ax = plt.subplots(figsize=(6, 5))
im = ax.contourf(X, Y, density, levels=25, cmap="magma")
# Cutoff circle around central atom
circle = plt.Circle(central, r_cut, fill=False, color="#2b6cb0", lw=2, ls="--")
ax.add_patch(circle)
ax.scatter(neighbors[:, 0], neighbors[:, 1], color="#fafaf7", edgecolors="black",
           s=70, zorder=5, label="neighbor atoms")
ax.scatter(*central, color="#276749", edgecolors="white", s=130, zorder=6,
           label="central atom")
ax.text(r_cut * 0.55, r_cut * 0.78, r"$r_{cut}$", color="#2b6cb0", fontsize=12)

ax.set_aspect("equal")
ax.set_xlabel("x (A)")
ax.set_ylabel("y (A)")
ax.set_title("Smeared atomic density (sum of Gaussians)")
ax.legend(fontsize=8, loc="upper right")
fig.colorbar(im, ax=ax, fraction=0.046, pad=0.04, label="density")
plt.tight_layout()
plt.show()
Figure 6.7: The smooth ‘smeared’ atomic density that SOAP characterizes: a sum of Gaussians placed on atom positions within a cutoff. SOAP expands this density in radial and angular basis functions.

The resolution of SOAP is controlled by a few parameters. The number of radial functions \(n_{\max}\) and the maximum angular momentum \(l_{\max}\) trade cost for detail, and the cutoff \(r_{\text{cut}}\) sets how local the environment is. The role of \(l_{\max}\) is especially instructive: with \(l_{\max}=0\) you keep only radial (2-body) information and are completely blind to angles.

We can test this directly. Below we build two geometries with identical C–H bond lengths — tetrahedral CH\(_4\) and a flattened (pyramidal) version with different H–C–H angles — and compute the SOAP vector at the carbon with dscribe, once with \(l_{\max}=0\) and once with \(l_{\max}=4\).

Code
import numpy as np
import matplotlib.pyplot as plt
from ase import Atoms
from dscribe.descriptors import SOAP

d = 1.09  # C-H bond length (Angstrom), identical in both geometries

# Tetrahedral CH4
tet = np.array([[0, 0, 0], [1, 1, 1], [1, -1, -1], [-1, 1, -1], [-1, -1, 1]], float)
tet[1:] = tet[1:] / np.linalg.norm(tet[1:], axis=1, keepdims=True) * d
ch4 = Atoms("CH4", positions=tet)

# Flattened geometry: same bond lengths, different angles
pyr = tet.copy()
pyr[1:, 2] *= 0.5
pyr[1:] = pyr[1:] / np.linalg.norm(pyr[1:], axis=1, keepdims=True) * d
flat = Atoms("CH4", positions=pyr)

dists = []
for lmax in (0, 4):
    soap = SOAP(species=["H", "C"], r_cut=5.0, n_max=4, l_max=lmax, periodic=False)
    v_tet = soap.create(ch4, centers=[0])[0]
    v_flat = soap.create(flat, centers=[0])[0]
    dists.append(np.linalg.norm(v_tet - v_flat))
    print(f"l_max={lmax}: SOAP length={len(v_tet):3d}, "
          f"||tetrahedral - flattened|| = {dists[-1]:.6f}")

fig, ax = plt.subplots(figsize=(5.5, 3.5))
bars = ax.bar(["l_max = 0\n(radial only)", "l_max = 4\n(with angles)"], dists,
              color=["#c53030", "#276749"], edgecolor="white")
ax.set_ylabel("SOAP distance between the two geometries")
ax.set_title("Only angular terms (l > 0) tell the geometries apart")
for b, dval in zip(bars, dists):
    ax.text(b.get_x() + b.get_width()/2, b.get_height() + 0.02,
            f"{dval:.3f}", ha="center", fontsize=11)
ax.set_ylim(0, max(dists) * 1.25 + 0.1)
plt.tight_layout()
plt.show()
l_max=0: SOAP length= 36, ||tetrahedral - flattened|| = 0.000000
l_max=4: SOAP length=180, ||tetrahedral - flattened|| = 2.560935
Figure 6.8: SOAP discrimination test. With l_max=0 (radial/2-body only) two geometries with equal bond lengths but different angles give an identical descriptor (distance 0). Adding angular terms (l_max=4) resolves them.

With \(l_{\max}=0\) the two distinct geometries collapse to exactly the same vector (distance \(0\)); only when angular terms are switched on (\(l_{\max}=4\)) does SOAP tell them apart. This is precisely why a good representation must capture body-orders beyond 2.

6.6.4 Where these descriptors go

These physics-based descriptors are designed to feed classical ML models — in particular kernel methods such as kernel ridge regression and Gaussian process regression, where the descriptor enters a similarity kernel comparing two atomic environments. The same atom-centered, sum-over-atoms philosophy underlies modern machine-learning interatomic potentials (ANI, MACE), which learn \(E_i\) for each atom and sum to the total energy. Those learned representations — and the graph neural networks that generalize them — are the subject of a later chapter.

6.7 Summary

  • PES & the cost–accuracy hierarchy. Chemistry is encoded in the potential energy surface (energy vs. \(3N\) coordinates): minima are structures, saddle points are transition states. Computing the energy means approximating the Schrödinger equation, which is intractable exactly. Methods form a hierarchy — force fields → ML potentials → semi-empirical → DFT → correlated wavefunction — and computational scaling (from \(\sim N\) for force fields up to \(M^7\) for CCSD(T)) is the core cost driver, so accuracy gets steeply more expensive.
  • DFT, Jacob’s ladder & error cancellation. DFT works with the electron density; its unknown exchange–correlation functional is approximated, and the approximations climb Jacob’s ladder (LDA → GGA → meta-GGA → hybrid → double-hybrid). No one knows the exact functional, so test for your use case. Absolute energies are hard; relative energies are easy thanks to systematic error cancellation. - DFT, Jacob’s ladder & error cancellation. DFT works with the electron density; its unknown exchange–correlation functional is approximated, and the approximations climb Jacob’s ladder (LDA → GGA → meta-GGA → hybrid → double-hybrid). No one knows the exact functional, so test for your use case. Absolute energies are hard; relative energies are more accessible thanks to systematic error cancellation — but only when the species compared are genuinely similar. Distinguish this from mere trend preservation, where a large constant offset leaves the ranking of a series intact without anything having cancelled.
  • Semi-empirical ≈ simple ML. Expensive two-electron integrals are replaced by a few fitted parameters (Pariser–Parr, \(\gamma_{\text{AA}} = \text{IP}_{\text{A}} - \text{EA}_{\text{A}}\); AM1, PM6, GFN2-xTB), making these methods the conceptual bridge from physics to ML.
  • QM descriptors & Fukui. Computed quantum quantities (charges, HOMO/LUMO, Fukui indices \(f^+, f^-, f^0\)) serve as electronic-structure-rich input features; the largest Fukui index predicts the reactive site.
  • Physics-based descriptors. Turn 3D coordinates into a vector via the many-body expansion. A good representation is invariant to permutation, rotation, and translation, plus complete, smooth, and additive. The Coulomb matrix is rotation/translation-invariant but not permutation-invariant (fix by sorting, eigenvalues, or Bag of Bonds); SOAP smears atoms into a density and builds a rotationally averaged 3-body power spectrum, needing \(l_{\max}>0\) to capture angles.

  1. We obtain each index as a difference of Mulliken atomic populations between the \(N\) and \(N\pm1\) species — the original condensed-Fukui prescription of Yang and Mortier (1986). A more rigorous route integrates the Fukui function \(f(\mathbf{r})\) over real-space atomic basins (Hirshfeld, or Bader/QTAIM zero-flux basins); for any such real-space partition the population difference equals the basin integral, \(f_k^\pm = \int_{\Omega_k} f^\pm(\mathbf{r})\,d\mathbf{r}\). Mulliken is instead a basis-set-dependent Hilbert-space partition, so it is the simplest but crudest version and does not correspond to integrating over a spatial basin.↩︎