Slater determinant¶
The Slater determinant component of the wavefunction is implemented in the casino.slater.Slater class.
This class provides methods to compute the value, gradient, Laplacian, Hessian, and Tressian of a multi-determinant Slater wavefunction.
It must be initialized from the configuration files:
from casino.readers import CasinoConfig
from casino.slater import Slater
config_path = <path to a directory containing input file>
config = CasinoConfig(config_path)
config.read()
slater = Slater(config, cusp=None)
Summary of Methods¶
The Slater class has the following methods:
Method |
Output |
Shape |
|---|---|---|
\(A^\uparrow, A^\downarrow\) |
\((MO^\uparrow, N^\uparrow_e), (MO^\downarrow, N^\downarrow_e)\) |
|
\(G^\uparrow, G^\downarrow\) |
\((MO^\uparrow, N^\uparrow_e, 3), (MO^\downarrow, N^\downarrow_e, 3)\) |
|
\(L^\uparrow, L^\downarrow\) |
\((MO^\uparrow, N^\uparrow_e), (MO^\downarrow, N^\downarrow_e)\) |
|
\(H^\uparrow, H^\downarrow\) |
\((MO^\uparrow, N^\uparrow_e, 3, 3), (MO^\downarrow, N^\downarrow_e, 3, 3)\) |
|
\(T^\uparrow, T^\downarrow\) |
\((MO^\uparrow, N^\uparrow_e, 3, 3, 3), (MO^\downarrow, N^\downarrow_e, 3, 3, 3)\) |
|
\(\Psi(r)\) |
\(scalar\) |
|
\(\nabla \Psi(r)/\Psi(r)\) |
\((3N_e,)\) |
|
\(\Delta \Psi(r)/\Psi(r)\) |
\(scalar\) |
|
\(\nabla^2 \Psi(r)/\Psi(r), \nabla \Psi(r)/\Psi(r)\) |
\((3N_e, 3N_e), (3N_e,)\) |
|
\(\nabla^3 \Psi(r)/\Psi(r), \nabla^2 \Psi(r)/\Psi(r), \nabla \Psi(r)/\Psi(r)\) |
\((3N_e, 3N_e, 3N_e), (3N_e, 3N_e), (3N_e,)\) |
|
\(T_B, \nabla^2 \Psi(r)/\Psi(r), \nabla \Psi(r)/\Psi(r)\) |
\((3N_e,), (3N_e, 3N_e), (3N_e,)\) |
value matrix¶
In quantum chemistry, molecular orbitals (MOs) are normally expanded in a set of atom-centered basis functions, or localized atomic orbitals (AOs):
where \(\mathbf{r}=\{r_{1}...r_{N}\}\) are the coordinates of the N spin-up and spin-down electrons, \(\mathbf{R}_\alpha\) denotes the atomic position center of basis function \(\chi_\alpha\), and the expansion coefficients \(c_{\alpha p}\) are known as molecular orbital (MO) coefficients, also to avoid overflows and underflows a normalization coefficient is multiplied:
In the multi-determinant case the \(p\) index should include occupied plus virtual MOs to span excited states. Therefore it is reasonable to
define electrons \(\times\) (occupied + virtual MOs) matrix \(\mathcal{A}\).
For a system described by a spin-independent Hamiltonian, the spatial and spin degrees of freedom are separable and we can split \(\mathcal{A}_{ip}\)
into two: \(\mathcal{A}^\uparrow_{ip}\) for spin-up and \(\mathcal{A}^\downarrow_{ip}\) for spin-down electrons.
For certain electron coordinates, the values of these matrices can be obtained with casino.slater.Slater.value_matrix() method:
import numpy as np
neu, ned = config.input.neu, config.input.ned
ne = neu + ned
r_e = np.random.uniform(-1, 1, ne * 3).reshape((ne, 3))
atom_positions = config.wfn.atom_positions
n_vectors = np.expand_dims(r_e, 0) - np.expand_dims(atom_positions, 1)
A_up, A_down = slater.value_matrix(n_vectors)
note that the returned arrays are indexed as (orbital, electron), i.e. the transpose of \(A_{ip}\) defined above; since all expressions below are traces, they are invariant under this transposition.
the inverse matrix will be needed to calculate the gradient, laplacian, hessian and tressian
(in the single-determinant case the matrices are square; in the multi-determinant case the rows
must first be selected with permutation_up/permutation_down):
inv_A_up = np.linalg.inv(A_up)
inv_A_down = np.linalg.inv(A_down)
gradient matrix¶
Consider the gradient operator for \(i\)-th electron:
It is easy to check that:
hence all non-zero values compose the matrix of vectors: \((x, y, z)\) indexed by \(a \in (x, y, z)\):
In the multi-determinant case the \(p\) index should include occupied plus virtual MOs to span excited states. Therefore it is reasonable to
define electrons \(\times\) (occupied + virtual MOs) matrix \(\mathcal{G}_{ip}\).
For a system described by a spin-independent Hamiltonian, the spatial and spin degrees of freedom are separable and we can split \(\mathcal{G}_{ip}\)
into two: \(\mathcal{G}^\uparrow_{ip}\) for spin-up and \(\mathcal{G}^\downarrow_{ip}\) for spin-down electrons.
For certain electron coordinates, the values of these matrices can be obtained with casino.slater.Slater.gradient_matrix() method:
G_up, G_down = slater.gradient_matrix(n_vectors)
laplacian matrix¶
Consider the laplacian operator for \(i\)-th electron:
It is easy to check that:
hence all non-zero values compose the matrix of scalars:
In the multi-determinant case the \(p\) index should include occupied plus virtual MOs to span excited states. Therefore it is reasonable to
define electrons \(\times\) (occupied + virtual MOs) matrix \(\mathcal{L}_{ip}\).
For a system described by a spin-independent Hamiltonian, the spatial and spin degrees of freedom are separable and we can split \(\mathcal{L}_{ip}\)
into two: \(\mathcal{L}^\uparrow_{ip}\) for spin-up and \(\mathcal{L}^\downarrow_{ip}\) for spin-down electrons.
For certain electron coordinates, the values of these matrices can be obtained with casino.slater.Slater.laplacian_matrix() method:
L_up, L_down = slater.laplacian_matrix(n_vectors)
hessian matrix¶
Consider the hessian operator for \(i\)-th electron:
It is easy to check that:
hence all non-zero values compose the matrix of hessians: \((x, y, z) \otimes (x, y, z)\) indexed by \(a,b \in (x, y, z)\):
In the multi-determinant case the \(p\) index should include occupied plus virtual MOs to span excited states. Therefore it is reasonable to
define electrons \(\times\) (occupied + virtual MOs) matrix \(\mathcal{H}_{ip}\).
For a system described by a spin-independent Hamiltonian, the spatial and spin degrees of freedom are separable and we can split \(\mathcal{H}_{ip}\)
into two: \(\mathcal{H}^\uparrow_{ip}\) for spin-up and \(\mathcal{H}^\downarrow_{ip}\) for spin-down electrons.
For certain electron coordinates, the values of these matrices can be obtained with casino.slater.Slater.hessian_matrix() method:
H_up, H_down = slater.hessian_matrix(n_vectors)
tressian matrix¶
Consider the tressian operator for \(i\)-th electron:
It is easy to check that:
hence all non-zero values compose the matrix of tressians: \((x, y, z) \otimes (x, y, z) \otimes (x, y, z)\) indexed by \(a,b,c \in (x, y, z)\):
In the multi-determinant case the \(p\) index should include occupied plus virtual MOs to span excited states. Therefore it is reasonable to
define electrons \(\times\) (occupied + virtual MOs) matrix \(\mathcal{T}_{ip}\).
For a system described by a spin-independent Hamiltonian, the spatial and spin degrees of freedom are separable and we can split \(\mathcal{T}_{ip}\)
into two: \(\mathcal{T}^\uparrow_{ip}\) for spin-up and \(\mathcal{T}^\downarrow_{ip}\) for spin-down electrons.
For certain electron coordinates, the values of these matrices can be obtained with casino.slater.Slater.tressian_matrix() method:
T_up, T_down = slater.tressian_matrix(n_vectors)
value¶
Consider contribution of single Slater determinant:
we can get the value of multideterminant wavefunction:
and \(\mathbf{r}=\{r_{1}...r_{N}\}\) are the coordinates of the N spin-up and spin-down electrons.
For certain electron coordinates, the value can be obtained with casino.slater.Slater.value() method:
value = slater.value(n_vectors)
gradient¶
Consider Slater determinant gradient by \(i\)-th electron coordinates:
to express the trace through sum using equality:
notice that the \(\nabla_{e_i} A\) has the only one non-zero \(row_i(\nabla_{e_i} A) = row_i(G)\):
expand gradient vector over \(i\):
and get gradient of multideterminant wavefunction:
where \(\mathbf{r}=\{r_{1}...r_{N}\}\) are the coordinates of the N spin-up and spin-down electrons
For certain electron coordinates, the gradient vector can be obtained with casino.slater.Slater.gradient() method:
slater.gradient(n_vectors)
this is equivalent to (continues from):
G_up, G_down = slater.gradient_matrix(n_vectors)
tr_grad_u = np.einsum('ij,jia->ia', inv_A_up, G_up).reshape(neu * 3)
tr_grad_d = np.einsum('ij,jia->ia', inv_A_down, G_down).reshape(ned * 3)
np.concatenate((tr_grad_u, tr_grad_d))
laplacian¶
Consider Slater determinant laplacian by \(i\)-th electron coordinates:
to express the trace through sum using equality:
notice that the \(\Delta_{e_i} A\) has the only one non-zero \(row_i(\Delta_{e_i} A) = row_i(L)\):
sum laplacian over \(i\):
and get laplacian of multideterminant wavefunction:
where \(\mathbf{r}=\{r_{1}...r_{N}\}\) are the coordinates of the N spin-up and spin-down electrons
For certain electron coordinates, the laplacian can be obtained with casino.slater.Slater.laplacian() method:
slater.laplacian(n_vectors)
this is equivalent to (continues from):
L_up, L_down = slater.laplacian_matrix(n_vectors)
lap_up = np.einsum('ij,ji', inv_A_up, L_up)
lap_down = np.einsum('ij,ji', inv_A_down, L_down)
lap_up + lap_down
hessian¶
Consider Slater determinant hessian by \(i\)-th and \(j\)-th electrons coordinates:
to express the trace through sum using equality:
notice that the \(\nabla_{e_i} A\) has the only one non-zero \(row_i(\nabla_{e_i} A) = row_i(G)\) and the \(\nabla_{e_i} \nabla_{e_i} A\) has only non-zero \(row_i(\nabla_{e_i} \nabla_{e_i} A) = row_i(H)\):
expand gradient vectors and hessian tensor over \(i\) and \(j\) (with Kronecker delta \(\delta_{ij}\)):
we can get hessian of multideterminant wavefunction:
where \(\mathbf{r}=\{r_{1}...r_{N}\}\) are the coordinates of the N spin-up and spin-down electrons
For certain electron coordinates, the hessian matrix can be obtained with casino.slater.Slater.hessian() method:
slater.hessian(n_vectors)[0]
this is equivalent to (continues from):
G_up, G_down = slater.gradient_matrix(n_vectors)
tr_grad_u = np.einsum('ij,jia->ia', inv_A_up, G_up).reshape(neu * 3)
tr_grad_d = np.einsum('ij,jib->ib', inv_A_down, G_down).reshape(ned * 3)
mul_grad_u = np.einsum('ij,jka->ika', inv_A_up, G_up)
mul_grad_d = np.einsum('ij,jkb->ikb', inv_A_down, G_down)
grad = np.concatenate((tr_grad_u, tr_grad_d))
H_up, H_down = slater.hessian_matrix(n_vectors)
tr_hess_u = np.einsum('ij,jiab->iab', inv_A_up, H_up)
tr_hess_d = np.einsum('ij,jiab->iab', inv_A_down, H_down)
hess_u = np.einsum('ij,iab->iajb', np.eye(neu), tr_hess_u)
hess_d = np.einsum('ij,iab->iajb', np.eye(ned), tr_hess_d)
hess_u -= np.einsum('ijb,jia->iajb', mul_grad_u, mul_grad_u)
hess_d -= np.einsum('ijb,jia->iajb', mul_grad_d, mul_grad_d)
hess = np.zeros((ne * 3, ne * 3))
hess[:neu * 3, :neu * 3] = hess_u.reshape(neu * 3, neu * 3)
hess[neu * 3:, neu * 3:] = hess_d.reshape(ned * 3, ned * 3)
hess += np.outer(grad, grad)
tressian¶
Consider Slater determinant tressian by \(i\)-th, \(j\)-th and \(k\)-th electrons coordinates:
to express the trace through sum using equalities:
notice that the \(\nabla_i A\) has only non-zero \(row_i(\nabla_i A) = row_i(G)\) and the \(\nabla_i \nabla_i A\) has only non-zero \(row_i(\nabla_i \nabla_i A) = row_i(H)\) and the \(\nabla_i \nabla_i \nabla_i A\) has only non-zero \(row_i(\nabla_i \nabla_i \nabla_i A) = row_i(T)\) and expand gradient vectors, hessian and tressian tensors over \(i\), \(j\), \(k\):
we can get tressian of multideterminant wavefunction:
where \(\mathbf{r}=\{r_{1}...r_{N}\}\) are the coordinates of the N spin-up and spin-down electrons
For certain electron coordinates, the tressian matrix can be obtained with casino.slater.Slater.tressian() method:
slater.tressian(n_vectors)[0]
this is equivalent to (continues from):
G_up, G_down = slater.gradient_matrix(n_vectors)
tr_grad_u = np.einsum('ij,jia->ia', inv_A_up, G_up).reshape(neu * 3)
tr_grad_d = np.einsum('ij,jib->ib', inv_A_down, G_down).reshape(ned * 3)
grad = np.concatenate((tr_grad_u, tr_grad_d))
H_up, H_down = slater.hessian_matrix(n_vectors)
tr_hess_u = np.einsum('ij,jiab->iab', inv_A_up, H_up)
tr_hess_d = np.einsum('ij,jiab->iab', inv_A_down, H_down)
mul_grad_u = np.einsum('ik,kja->ija', inv_A_up, G_up)
mul_grad_d = np.einsum('ik,kjb->ijb', inv_A_down, G_down)
hess_u = np.einsum('ij,iab->iajb', np.eye(neu), tr_hess_u)
hess_d = np.einsum('ij,iab->iajb', np.eye(ned), tr_hess_d)
hess_u -= np.einsum('ijb,jia->iajb', mul_grad_u, mul_grad_u)
hess_d -= np.einsum('ijb,jia->iajb', mul_grad_d, mul_grad_d)
hess = np.zeros((ne * 3, ne * 3))
hess[:neu * 3, :neu * 3] = hess_u.reshape(neu * 3, neu * 3)
hess[neu * 3:, neu * 3:] = hess_d.reshape(ned * 3, ned * 3)
hess += np.outer(grad, grad)
T_up, T_down = slater.tressian_matrix(n_vectors)
tr_tress_u = np.einsum('ij,jiabc->iabc', inv_A_up, T_up)
tr_tress_d = np.einsum('ij,jiabc->iabc', inv_A_down, T_down)
mul_hess_u = np.einsum('ik,kjab->iajb', inv_A_up, H_up)
mul_hess_d = np.einsum('ik,kjab->iajb', inv_A_down, H_down)
tress_u = np.einsum('ij,jk,iabc->iajbkc', np.eye(neu), np.eye(neu), tr_tress_u)
tress_d = np.einsum('ij,jk,iabc->iajbkc', np.eye(ned), np.eye(ned), tr_tress_d)
tress_u -= np.einsum('ij,kajb,jkc->iajbkc', np.eye(neu), mul_hess_u, mul_grad_u)
tress_u -= np.einsum('ki,jaic,ijb->iajbkc', np.eye(neu), mul_hess_u, mul_grad_u)
tress_u -= np.einsum('jk,ibkc,kia->iajbkc', np.eye(neu), mul_hess_u, mul_grad_u)
tress_d -= np.einsum('ij,kajb,jkc->iajbkc', np.eye(ned), mul_hess_d, mul_grad_d)
tress_d -= np.einsum('ki,jaic,ijb->iajbkc', np.eye(ned), mul_hess_d, mul_grad_d)
tress_d -= np.einsum('jk,ibkc,kia->iajbkc', np.eye(ned), mul_hess_d, mul_grad_d)
tress_u += np.einsum('jia,kjb,ikc->iajbkc', mul_grad_u, mul_grad_u, mul_grad_u)
tress_d += np.einsum('jia,kjb,ikc->iajbkc', mul_grad_d, mul_grad_d, mul_grad_d)
tress_u += np.einsum('kia,ijb,jkc->iajbkc', mul_grad_u, mul_grad_u, mul_grad_u)
tress_d += np.einsum('kia,ijb,jkc->iajbkc', mul_grad_d, mul_grad_d, mul_grad_d)
tress = np.zeros((ne * 3, ne * 3, ne * 3))
tress[:neu * 3, :neu * 3, :neu * 3] = tress_u.reshape(neu * 3, neu * 3, neu * 3)
tress[neu * 3:, neu * 3:, neu * 3:] = tress_d.reshape(ned * 3, ned * 3, ned * 3)
tress += (
np.einsum('i,jk->ijk', grad, hess) +
np.einsum('k,ij->ijk', grad, hess) +
np.einsum('j,ki->ijk', grad, hess) -
2 * np.einsum('i,j,k->ijk', grad, grad, grad)
)
Implementation¶
The tressian tensor \(T[a, b, c]\) has shape \((3N_e, 3N_e, 3N_e)\) where \(N_e\) is the number of electrons of a given spin. A naïve implementation iterates over all triples \((e_1, e_2, e_3)\) with conditional checks inside the loop body:
for e1 in range(neu):
for e2 in range(neu):
for e3 in range(neu):
res = 0
if e1 == e2 == e3:
res += tr_tress[e1, ...]
if e1 == e2:
res -= matrix_hess[e3, e1, ...] * matrix_grad[e1, e3, ...]
# ... etc
tress[e1*3+r1, e2*3+r2, e3*3+r3] += c * res
This costs \(O(N_e^6 \cdot 27)\) iterations and the runtime branches prevent LLVM from auto-vectorising the inner loops.
The key structural observation is that the determinant-specific contributions to \(T[a, b, c]\) are sparse in the electron indices: each term is non-zero only when at least two of \(e_1, e_2, e_3\) coincide. Specifically:
\(\mathrm{tr}(A^{-1} \nabla^3 A)\) — non-zero only when \(e_1 = e_2 = e_3\)
\(\mathrm{tr}(A^{-1} \nabla^2_{e_i e_j} A \cdot A^{-1} \nabla_{e_k} A)\) — non-zero only when \(e_i = e_j\) (one constrained pair, \(e_k\) free)
\((A^{-1}G)_{e_3 e_2}(A^{-1}G)_{e_1 e_3}(A^{-1}G)_{e_2 e_1}\) — non-zero for all \((e_1, e_2, e_3)\)
This motivates decomposing the computation into five branch-free loop nests:
Loop nest |
Complexity |
Replaces |
|---|---|---|
\(e_1 = e_2 = e_3 = e\) (tr_tress diagonal) |
\(O(N_e \cdot 27)\) |
|
\(e_1 = e_2\), free \(e_3\) (hess×grad, pair 12) |
\(O(N_e^2 \cdot 27)\) |
|
\(e_1 = e_3\), free \(e_2\) (hess×grad, pair 13) |
\(O(N_e^2 \cdot 27)\) |
|
\(e_2 = e_3\), free \(e_1\) (hess×grad, pair 23) |
\(O(N_e^2 \cdot 27)\) |
|
all \((e_1, e_2, e_3)\) free (triple product) |
\(O(N_e^3 \cdot 27)\) |
always executed |
Because no loop nest carries runtime conditionals, LLVM auto-vectorises all five loops via SIMD instructions. The outer-product term \(\nabla\phi \otimes H + H \otimes \nabla\phi + \nabla\phi \otimes \nabla\phi \otimes \nabla\phi\) involves all \((N_e \cdot 3)^3\) elements and is unchanged.
# tr_tress: e1 == e2 == e3
for e in range(neu):
for r1, r2, r3 in product(range(3), repeat=3):
tress[e*3+r1, e*3+r2, e*3+r3] += c * tr_tress_u[e, r1, r2, r3]
# hess × grad: e1 == e2, free e3
for e12 in range(neu):
for e3 in range(neu):
for r1, r2, r3 in product(range(3), repeat=3):
tress[e12*3+r1, e12*3+r2, e3*3+r3] -= (
c * matrix_hess_u[e3, e12, r1, r2] * matrix_grad_u[e12, e3, r3]
)
# ... similarly for (e1==e3, free e2) and (e2==e3, free e1) ...
# triple product: all (e1, e2, e3) free
for e1 in range(neu):
for e2 in range(neu):
for e3 in range(neu):
for r1, r2, r3 in product(range(3), repeat=3):
tress[e1*3+r1, e2*3+r2, e3*3+r3] += c * (
matrix_grad_u[e3, e2, r2]
* matrix_grad_u[e1, e3, r3]
* matrix_grad_u[e2, e1, r1]
+ matrix_grad_u[e2, e3, r3]
* matrix_grad_u[e3, e1, r1]
* matrix_grad_u[e1, e2, r2]
)
On the He atom (\(N_e = 1\) per spin, ne3 = 6) the refactored implementation
is 5.5× faster than the original (33 µs vs 184 µs per call).
The actual wall-clock speedup depends on system size. The total cost of
casino.slater.Slater.tressian() is:
where \(t_\text{matrix calls}\) is the combined time of
value_matrix + gradient_matrix + hessian_matrix + tressian_matrix
(all scale with system size and basis set), and \(t_\text{index loops}\) is
the time of the electron-index loop nests described above.
For small systems (He, \(N_e = 1\)) the index loops dominate and the 5.5×
speedup is realised. For larger systems such as Ar (\(N_e = 9\) per spin)
the matrix calls dominate — in particular tressian_matrix grows with the number
of orbitals and primitives — and the loop optimisation contributes only ~20% of
the total runtime. The next bottleneck to address for large-\(N_e\) systems
is therefore tressian_matrix itself.
tressian_dot¶
The tressian enters the local-energy parameter derivatives only through its contraction with a symmetric matrix \(B = b_g\,b_g^\top\) over the last two axes:
casino.slater.Slater.tressian_dot() returns this vector of shape (ne3,)
without ever forming the (ne3, ne3, ne3) tensor, so the working set stays
\(O(\text{ne3}^2)\) and cache-resident.
Contracting the dense outer part with \(B\) collapses to a closed form (using \(B = B^\top\)):
with \(\langle \mathrm{PH}, B\rangle = \sum_{bc}\mathrm{PH}_{bc} B_{bc}\). This costs \(O(\text{ne3}^2)\).
The sparse determinant part is contracted inside the same per-spin six-fold loop, accumulating directly into the scalar \((T_B)_a\) instead of storing the tensor. The flop count stays \(O(\text{ne3}^3)\) but the memory footprint drops to \(O(\text{ne3}^2)\).
For certain electron coordinates:
bb = b_g @ b_g.T
t_bb, hess, grad = slater.tressian_dot(n_vectors, bb)
is equivalent to:
tress, hess, grad = slater.tressian(n_vectors)
t_bb = np.tensordot(tress, bb, axes=([1, 2], [0, 1]))
Because the \(\text{ne3}^3\) tensor is never materialised, the speedup over
casino.slater.Slater.tressian() grows with system size as the tensor outgrows
the cache (wall-clock seconds for an equal number of calls):
System |
ne3 |
tressian |
tressian_dot |
speedup |
|---|---|---|---|---|
Ne |
30 |
0.8 |
0.6 |
1.3× |
Ar |
54 |
3.0 |
1.6 |
1.9× |
O3 |
72 |
9.9 |
4.2 |
2.4× |
Kr |
108 |
28.4 |
7.8 |
3.6× |