ajz34 的 jupyter 小仓库#

这是 ajz34 (祝震予,Zhenyu Zhu) 的一些简短的程序文档。由于不适合放置在纯文本的网页中,因此另起一个 sphinx 文档区。(事实上纯文本的原博客已经停止维护了)

这些程序文档都是由 Python 下的 Jupyter Notebook 编写而成,因此在安装足够的依赖环境的条件下,这些文档都是可以作为程序运行的。欢迎大家到 Github 下载这些 Jupyter 文档并亲手运行。

合集 分子的电子结构方法与算法#

是否有可能在一个软件中,高效且漂亮地包含所有电子结构的瑰宝?我想很难。但是否能用强大的前人所提供的有力工具,在较为统一的程序、文档框架下,帮助自己理解这复杂体系的林林总总?或许可以试试。

我会尝试以 PySCF 为主力工具,对现有的一些电子结构方法或算法作补充说明。这些内容未必是基础或常用的;我们不会涉及基础教科书中出现的内容,譬如简单基组、Hartree-Fock 方法等。

这里所指的方法可能是一些 post-HF 方法 (譬如不常见的 CEPA, OO-MP2 等),也可能是矫正方法 (譬如 DFT-D3, 溶剂化等),或是密度泛函的一些贡献分量 (譬如 LT-SOS, VV10 等)。这里所指的算法可能是收敛方法 (譬如 DIIS),也可能是加速算法 (RI, THC, COSX 等)。总之取材应会很宽泛。这本身也是我自己的随笔,如果哪天想起来,也会往里随便翻翻。

Gaussian 中的 PUHF/PMP2 结果的重新实现#

创建时间:2019-08-31,最后修改:2019-09-01

在这一份笔记中,我们将使用 PySCF 的功能与 NumPy 重复 Gaussian 中计算的 PUHF 与 PMP2 能量结果;并对 PUHF 与 PMP2 的推导作简单的说明。

from pyscf import gto, scf, mp

参考结果与体系定义#

Gaussian 结果#

在 Gaussian 中,我们使用以下输入卡可以得到 PUHF/PMP2 能量:

#p UMP2(Full)/6-31G nosymm

H2O

3 4
O  0.  0.  0.
H  1.  0.  0.
H  0.  1.  0.

对于上述分子,其中一些重要的输出结果是

  1. \(E_\mathrm{UHF}\):-73.0451423839

  2. \(E_\mathrm{UMP2, corr}\): -0.02646719276

  3. \(E_\mathrm{UMP2}\): -73.071609576661

  4. \(\langle S_z \rangle\): 1.5

  5. \(\langle S^{2(0)} \rangle\): 3.7531

  6. \(\langle S^{2(0)} \rangle + \langle S^{2(1)} \rangle\): 3.7504

  7. \(E_\mathrm{PUHF}\):-73.046146318

  8. \(E_\mathrm{PMP2}\): -73.072180589

输出文件参见 assets/PUHF_and_PMP2.out;其中,有效的数据可以通过下述的代码获得:

with open("assets/PUHF_and_PMP2.out", "r") as output:
    output_lines = output.read().split("\n")

for line_num, line_text in enumerate(output_lines):
    if any([keyword in line_text for keyword in
            ["SCF Done", "EUMP2", "<S**2>", "(S**2,1)", "E(PMP2)"]]) \
        and "Initial guess" not in line_text:
        print("line {:03d}: {}".format(line_num, line_text))
line 336:  SCF Done:  E(UHF) =  -73.0451423839     A.U. after   11 cycles
line 338:  <Sx>= 0.0000 <Sy>= 0.0000 <Sz>= 1.5000 <S**2>= 3.7531 S= 1.5008
line 370:  (S**2,0)=  0.37531D+01           (S**2,1)=  0.37504D+01
line 371:  E(PUHF)=      -0.73046146318D+02        E(PMP2)=      -0.73072180589D+02
line 373:  E2 =    -0.2646719276D-01 EUMP2 =    -0.73071609576661D+02

我们的目标就是近乎准确无误地重复上述八个结果。

PySCF 体系定义#

为了获得与 Gaussian 相同的结果,我们需要定义相同的分子与电荷、多重度环境:

mol = gto.Mole()
mol.atom = """
O 0. 0. 0.
H 1. 0. 0.
H 0. 1. 0.
"""
mol.charge = 3
mol.spin = 3
mol.basis = "6-31G"
mol.build()
<pyscf.gto.mole.Mole at 0x7fa4e5342160>

通过 PySCF 计算 UHF 能量:

scf_eng = scf.UHF(mol)
scf_eng.conv_tol = 1e-10
scf_eng.run();
converged SCF energy = -73.0451423536459  <S^2> = 3.7530824  2S+1 = 4.0015409

上述结果应当能与 \(E_\mathrm{UHF}\)\(\langle S^{2(0)} \rangle\) 对应。\(\langle S_z \rangle = 1.5\) 几乎是显然的。不过,我们仍然不了解 \(\langle S^{2(0)} \rangle\) 是如何生成的。

通过 PySCF 计算 UMP2 能量:

mp2_eng = mp.UMP2(scf_eng)
mp2_eng.run();
E(UMP2) = -73.0716095455079  E_corr = -0.0264671918619837

上述结果应当能与 \(E_\mathrm{UMP2, corr}\)\(E_\mathrm{UMP2}\) 对应。

因此,当前的问题将是回答:如何重复

  1. \(\langle S^{2(0)} \rangle\): 3.7531

  2. \(\langle S^{2(0)} \rangle + \langle S^{2(1)} \rangle\): 3.7504

  3. \(E_\mathrm{PUHF}\):-73.046146318

  4. \(E_\mathrm{PMP2}\): -73.072180589

部分变量定义#

首先,我们遵从大多数量化文章中的记号

  • \(i, j\) 代表占据分子轨道

  • \(a, b\) 代表非占分子轨道

  • \(p, q\) 代表任意分子轨道

  • \(\alpha, \beta\) 代表任意原子轨道

Table 1. 分子相关变量

变量名

元素记号

意义与注解

标量或区间

nocc_a

\(n_\mathrm{occ}^\alpha\)

\(\alpha\) 自旋电子数

\(5\)

nocc_b

\(n_\mathrm{occ}^\beta\)

\(\beta\) 自旋电子数

\(2\)

N

\(N\)

总电子数

\(7\)

nmo

\(n_\mathrm{MO}\)

分子轨道数

\(13\)

nao

\(n_\mathrm{AO}\)

原子轨道数

\(13\)

S

\(S_{\mu \nu}\)

原子轨道重叠积分

so_a

\(\alpha\) 占据轨道分割

\([0, 5)\)

so_b

\(\beta\) 占据轨道分割

\([0, 2)\)

sv_a

\(\alpha\) 非占轨道分割

\([5, 13)\)

sv_b

\(\beta\) 非占轨道分割

\([2, 13)\)

Sx

\(S_x\)

\(x\) 分量自旋

\(0\)

Sy

\(S_y\)

\(y\) 分量自旋

\(0\)

Sz

\(S_z\)

\(z\) 分量自旋

\(3/2\)

Table 2. UHF 计算相关变量

变量名

元素记号

意义与注解

C_a

\(C_{\mu p}\)

\(\alpha\) 系数矩阵

C_b

\(C_{\mu \bar p}\)

\(\beta\) 系数矩阵

e_a

\(e_{p}\)

\(\alpha\) 轨道能

e_b

\(e_{\bar p}\)

\(\alpha\) 轨道能

eo_a

\(e_{i}\)

\(\beta\) 占据轨道能

eo_b

\(e_{\bar i}\)

\(\alpha\) 占据轨道能

ev_a

\(e_{a}\)

\(\alpha\) 非占轨道能

ev_b

\(e_{\bar a}\)

\(\beta\) 非占轨道能

D2_aa

\(D_{ij}^{ab}\)

\(\alpha, \alpha\) 轨道能差

D2_ab

\(D_{i \bar j}^{a \bar b}\)

\(\alpha, \beta\) 轨道能差

D2_bb

\(D_{\bar i \bar j}^{\bar a \bar b}\)

\(\beta, \beta\) 轨道能差

Table 3. UMP2 计算相关变量

变量名

元素记号

意义与注解

t2_aa

\(t_{ij}^{ab}\)

\(\alpha, \alpha\) MP2 激发系数

t2_ab

\(t_{i \bar j}^{a \bar b}\)

\(\alpha, \beta\) MP2 激发系数

t2_bb

\(t_{\bar i \bar j}^{\bar a \bar b}\)

\(\beta, \beta\) MP2 激发系数

D2_aa

\(D_{ij}^{ab}\)

\(\alpha, \alpha\) MP2 激发系数分母

D2_ab

\(D_{i \bar j}^{a \bar b}\)

\(\alpha, \beta\) MP2 激发系数分母

D2_bb

\(D_{\bar i \bar j}^{\bar a \bar b}\)

\(\beta, \beta\) MP2 激发系数分母

上述需要补充说明的公式有:

\[ S_z = \frac{1}{2} (n_\mathrm{occ}^\alpha - n_\mathrm{occ}^\beta) \]
\[ D_{i \bar j}^{a \bar b} = e_i + e_{\bar j} - e_a - e_{\bar b} \]

对于 MP2 激发系数分母,另外两种自旋情况的 \(D_{ij}^{ab}\)\(D_{\bar i \bar j}^{\bar a \bar b}\) 也可以类似地生成。

# === Molecular
# --- Definition
nocc_a, nocc_b = mol.nelec
N = nocc_a + nocc_b
nmo = nao = mol.nao
S = mol.intor("int1e_ovlp")
# --- Derivative
so_a, so_b = slice(0, nocc_a), slice(0, nocc_b)
sv_a, sv_b = slice(nocc_a, nmo), slice(nocc_b, nmo)
Sx, Sy, Sz = 0, 0, 0.5 * (nocc_a - nocc_b)

# === UHF Calculation
# --- Definition
C_a, C_b = scf_eng.mo_coeff
e_a, e_b = scf_eng.mo_energy
# --- Derivative
eo_a, eo_b = e_a[so_a], e_b[so_b]
ev_a, ev_b = e_a[sv_a], e_b[sv_b]
D2_aa = eo_a[:, None, None, None] + eo_a[None, :, None, None] - ev_a[None, None, :, None] - ev_a[None, None, None, :]
D2_ab = eo_a[:, None, None, None] + eo_b[None, :, None, None] - ev_a[None, None, :, None] - ev_b[None, None, None, :]
D2_bb = eo_b[:, None, None, None] + eo_b[None, :, None, None] - ev_b[None, None, :, None] - ev_b[None, None, None, :]

# === MP2 Calculation
t2_aa, t2_ab, t2_bb = mp2_eng.t2

作为对四脚标张量性质的验证,我们计算 MP2 相关能 \(E_\mathrm{MP2, corr}\) 如下:

\[ E_\mathrm{MP2, corr} = \frac{1}{4} \sum_{ijab} (t_{ij}^{ab})^2 D_{ij}^{ab} + \frac{1}{4} \sum_{\bar i \bar j \bar a \bar b} (t_{\bar i \bar j}^{\bar a \bar b})^2 D_{i \bar j}^{a \bar b} + \sum_{i \bar j a \bar b} (t_{i \bar j}^{a\bar b})^2 D_{i \bar j}^{a \bar b} \]
(+ 0.25 * (t2_aa**2 * D2_aa).sum()
 + 0.25 * (t2_bb**2 * D2_bb).sum()
 + (t2_ab**2 * D2_ab).sum())
-0.02646719186198365

PySCF 所给出的 \(E_\mathrm{MP2, corr}\) 可以给出相同的结果:

mp2_eng.e_corr
-0.02646719186198366

\(\langle S^2 \rangle\) 相关计算#

分子轨道基组重叠矩阵 S_pq \(S_{p \bar q}\)#
\[ S_{p \bar q} = \sum_{\mu \nu} C_{\mu p} S_{\mu \nu} C_{\nu \bar q} \]

若用量子力学记号,上述矩阵元素可能表示为

\[ S_{p \bar q} = \int \phi_p (\boldsymbol{r}) \phi_{\bar q} (\boldsymbol{r}) \, \mathrm{d} \boldsymbol{r} \]

注意上述的积分是空间坐标的积分,不包含自旋部分的积分。

S_pq = C_a.T @ S @ C_b
S_pq.shape
(13, 13)

我们以后还会使用上述矩阵的占据-占据部分 S_ij \(S_{i \bar j}\)、占据-非占部分 S_ia \(S_{i \bar a}\) 与非占-占据部分 S_ai \(S_{a \bar i} = S_{\bar i a}\)

S_ij, S_ia, S_ai = S_pq[so_a, so_b], S_pq[so_a, sv_b], S_pq[sv_a, so_b]
[S_ij.shape, S_ia.shape, S_ai.shape]
[(5, 2), (5, 11), (8, 2)]
S2_0 \(\langle S^{2(0)} \rangle\)#

\(\langle S^{2(0)} \rangle\) 在程序中通常写为 <S^2><S**2>。在 Gaussian 计算 PUHF 处,还写为 (S**2,0)。这意味着是 UHF 波函数的 \(\langle S^2 \rangle_\mathrm{UHF}\)。相对地,UMP2 波函数给出的对 \(\langle S^2 \rangle\) 的矫正将记作 \(\langle S^{2(1)} \rangle\)

参考 Chen and Schlegel [1] Table 1, \(0 \rightarrow 0\) 或等价地,Szabo and Ostlund [2] eq (2.271)

\[ \langle S^{2(0)} \rangle = \langle \Psi_0 | \hat S^2 | \Psi_0 \rangle = S_z (S_z + 1) + n_\mathrm{occ}^\beta - \sum_{i \bar j} (S_{i \bar j})^2 \]
S2_0 = Sz * (Sz + 1) + nocc_b - (S_ij**2).sum()
S2_0
3.7530823820890378

Gaussian 的参考值是 3.7531。

为了以后的记号便利,我们在这里定义 L

\[ L = \sum_{i \bar j} (S_{i \bar j})^2 \]
L = (S_ij**2).sum()
S2_1 \(\langle S^{2(1)} \rangle\)#
\[\begin{split} \begin{align} \langle S^{2(1)} \rangle &= 2 \langle \Psi_0 | \hat S^2 | \Psi^{(1)} \rangle = 2 \sum_{i \bar j a \bar b} t_{i \bar j}^{a \bar b} \langle \Psi_0 | \hat S^2 | \Psi_{i \bar j}^{a \bar b} \rangle \\ &= - 2 \sum_{i \bar j a \bar b} t_{i \bar j}^{a \bar b} \langle i | \bar b \rangle \langle a | \bar j \rangle = - 2 \sum_{i \bar j a \bar b} t_{i \bar j}^{a \bar b} S_{i \bar b} S_{a \bar j} \end{align} \end{split}\]

上式的第一个等号是 Chen and Schlegel [1] eq (5) 所给出的;而第三个等号是 Table 1 \(0 \rightarrow \alpha \beta (i, a: \alpha; j, b: \beta)\) 给出的。

上式的推导中有一处关于 \(| \Psi^{(1)} \rangle\) 的展开的推导省略。我们知道

\[ | \Psi^{(1)} \rangle = \hat T_2 | \Psi_0 \rangle = \frac{1}{4} \sum_{ijab} t_{ij}^{ab} | \Psi_{ij}^{ab} \rangle + \frac{1}{4} \sum_{\bar i \bar j \bar a \bar b} t_{\bar i \bar j}^{\bar a \bar b} | \Psi_{\bar i \bar j}^{\bar a \bar b} \rangle + \sum_{i \bar j a \bar b} t_{i \bar j}^{a \bar b} | \Psi_{i \bar j}^{a \bar b} \rangle \]

但由于利用到 \(\langle 0 | \hat S^2 | \Psi_{ij}^{ab} \rangle = \langle 0 | \hat S^2 | \Psi_{\bar i \bar j}^{\bar a \bar b} \rangle = 0\),因此在第二个等号时只从三个 \(| \Psi^{(1)} \rangle\) 中留下了一项。关于 \(\hat S^2\) 作用在 UHF 波函数与轨道下的性质,可以参考 Schlegel [3] eq (5) 的说明。

S2_1 = - 2 * (t2_ab * S_ia[:, None, None, :] * S_ai.T[None, :, :, None]).sum()
S2_1
-0.002658400959213991

因此,UMP2 矫正过的 \(\langle S^2 \rangle_\mathrm{UMP2} = \langle S^{2(0)} \rangle + \langle S^{2(1)} \rangle\) 的结果是

S2_0 + S2_1
3.7504239811298237

Gaussian 的参考值是 3.7504。

S4SD \(\texttt{S4SD}\)#

S4SD 的表达式较为复杂,我们也直接使用 \(\texttt{S4SD}\) 而不用其他记号表示该项:

\[ \begin{align} \texttt{S4SD} = (n_\mathrm{occ}^\alpha - L) (n_\mathrm{occ}^\beta - L) + 2 L - 2 \sum_{i \bar j k \bar l} S_{i \bar j} S_{\bar j k} S_{k \bar l} S_{\bar l i} + \langle S^{2(0)} \rangle^2 \end{align} \]
S4SD = (nocc_a - L) * (nocc_b - L) + 2 * L - 2 * (S_ij @ S_ij.T @ S_ij @ S_ij.T).trace() + S2_0**2
S4SD
14.101029785519533

该表达式的来源可能是 Amos and Hall [4]。该文的 eq (7·02) 下方公式中,有通过稍高阶的投影而获得的 \(\langle S^2 \rangle\) 的计算方式

\[ \langle S^2 \rangle \simeq \langle S^{2(0)} \rangle + \frac{\texttt{S4SD} - \langle S^{2(0)} \rangle^2}{\langle S^{2(0)} \rangle - (S_z + 1) (S_z + 2)} \]

通过这种方式获得的 \(\langle S^2 \rangle\) 近似值可以相当精确,比 \(\langle S^{2(0)} \rangle + \langle S^{2(1)} \rangle\) 还要接近精确值 \(3.75\)

S2_0 + (S4SD - S2_0**2) / (S2_0 - (Sz + 1) * (Sz + 2))
3.749999998117527

相信 \(\texttt{S4SD}\) 的存在意义是用于计算 Schlegel [3] 式 eq (24) 中的 \(\langle \tilde \Phi_1 | \tilde \Phi_1 \rangle = \langle \Phi_0 | A_{s + 1}^\dagger A_{s + 1} | \Phi_0 \rangle\);但关于这一关系我还不确定是否正确。后面计算 PMP2 能量时会使用上 \(\texttt{S4SD}\)

自旋污染矫正能量计算#

EPUHF \(E_\mathrm{PUHF}\)#

根据 Schlegel [3] eq (22),PUHF 能量可以表达为

\[ E_\mathrm{PUHF} = E_\mathrm{UHF} + \frac{1}{\langle \Psi_0 | \hat P_s | \Psi_0 \rangle} \sum_{i \bar j a \bar b} \langle \Psi_0 | \hat H | \Psi_{i \bar j}^{a \bar b} \rangle \langle \Psi_{i \bar j}^{a \bar b} | \hat P_s | \Psi_0 \rangle \]

其中,\(\hat P_s\) 算符称为 Löwdin 算符 [5] eq (7),

\[ \hat P_s = \prod_{k \neq s}^{N / 2} \frac{\hat S^2 - k (k + 1)}{s (s + 1) - k (k + 1)} \]

相当于将自旋不纯的波函数纯化为自旋量子数为 \(s\) 的态。在实际使用中,通常使用 \(\hat A_{s + 1} \simeq \hat P_s\) 替代;关于这段讨论可以参考 Rossky and Karplus [6] section V.A 的讨论,而下面公式的形式参考 Schlegel [3] eq (14);其中,\(s\) 一般取 \(S_z\)

\[ \hat A_{s + 1} = \frac{\hat S^2 - (s + 1)(s + 2)}{\langle S^{2(0)} \rangle - (s + 1)(s + 2)} \]

关于 \(\hat A_{s + 1}\),一个显然的性质是 \(\langle \Psi_0 | \hat A_{s + 1} | \Psi_0 \rangle = 1\)

为了程序方便,定义下述临时变量 Y

\[ Y = \langle S^{2(0)} \rangle - (S_z + 1) (S_z + 2) \]

那么 D_EPUHF

\[\begin{split} \begin{align} \Delta E_\mathrm{PUHF} &= \sum_{i \bar j a \bar b} t_{i \bar j}^{a \bar b} D_{i \bar j}^{a \bar b} \cdot \langle \Psi_{i \bar j}^{a \bar b} | \frac{\hat S^2}{Y} | \Psi_0 \rangle \\ &= - \frac{1}{Y} \sum_{i \bar j a \bar b} t_{i \bar j}^{a \bar b} D_{i \bar j}^{a \bar b} S_{i \bar b} S_{\bar j a} \end{align} \end{split}\]
Y = S2_0 - (Sz + 1) * (Sz + 2)
D_EPUHF = - 1 / Y * (t2_ab * D2_ab * S_ia[:, None, None, :] * S_ai.T[None, :, :, None]).sum()
D_EPUHF
-0.0010039336320381543

因而 \(E_\mathrm{PUHF} = E_\mathrm{UHF} + \Delta E_\mathrm{PUHF}\)

scf_eng.e_tot + D_EPUHF
-73.04614628727792

Gaussian 的参考值是 -73.046146318。

EPMP2 \(E_\mathrm{PMP2}\)#

根据 Schlegel [3] eq (24),PMP2 能量可以表达为

\[ \begin{align} \Delta E_\mathrm{PMP2} = \Delta E_\mathrm{PUHF} \left( 1 - \frac{\langle \Phi^{(1)} | \hat A_{s + 1} | \Psi_0 \rangle}{\langle \Phi_0 | \hat A_{s + 1}^\dagger \hat A_{s + 1} | \Psi_0 \rangle} \right) \end{align} \]

关于上式的分数项,分子部分可以写为

\[ \begin{align} \langle \Phi^{(1)} | \hat A_{s + 1} | \Psi_0 \rangle = \langle \Phi^{(1)} | \frac{\hat S^2}{Y} - \frac{(s + 1)(s + 2)}{Y} | \Psi_0 \rangle = \frac{1}{2} \frac{\langle S^{2(1)} \rangle}{Y} \end{align} \]

而关于分子项,参考在 \(\texttt{S4SD}\) 的讨论,

\[ \langle \Phi_0 | \hat A_{s + 1}^\dagger \hat A_{s + 1} | \Psi_0 \rangle \simeq \langle S^2 \rangle - \langle S^{2(0)} \rangle = \frac{\texttt{S4SD} - \langle S^{2(0)} \rangle^2}{Y^2} \]

但作者不能断定上述论断的正确性。

将分子、分母的结果代入 \(\Delta E_\mathrm{PMP2}\) 的算式中,可以得到 D_EPMP2

\[ \Delta E_\mathrm{PMP2} = \Delta E_\mathrm{PUHF} \left( 1 - \frac{1}{2} \frac{\langle S^{2(1)} \rangle \cdot Y}{\texttt{S4SD} - \langle S^{2(0)} \rangle^2} \right) \]
D_EPMP2 = D_EPUHF * (1 - 0.5 * S2_1 * Y / (S4SD - S2_0**2))
D_EPMP2
-0.0005710125302116336

因而 \(E_\mathrm{PMP2} = E_\mathrm{UMP2} + \Delta E_\mathrm{PMP2}\)

mp2_eng.e_tot + D_EPMP2
-73.07218055803808

Gaussian 的参考值是 -73.072180589。

至此,我们已经完成了使用 PySCF 的功能与 NumPy 重复 Gaussian 的 PUHF、PMP2 的能量结果了。

修订时间轴#

  • 2019/08/30 写完文档;文档基于 2019/08/13 的一份笔记。

  • 2019/09/01 补充一部分推导。


RMP3 与 RMP4 能量#

创建时间:2019-11-01

这一节我们讨论 RMP3 与 RMP4 的能量计算。

读过 Szabo 第六章的人相信对 MPn 方法有所了解。不过我们这里不关注 MPn 方法的公式推导,并且将眼光局限于 Restricted 方法。事实上写这篇文档时,尽管曾经学习过一般的 MPn 与 CCPT 的推导方式,但并没有尝试推导过 Restricted 情况下的推导,仅仅是将书上出现的公式程序化而已。

这一篇文档的主要参考是 Helgaker et al. 2013 教材 [1] 的 section 14.4。关于 Spin-Orbital MP3 与 RMP3 的另一种实现方式,可以参考以 Szabo [2] 公式为蓝本的 Psi4NumPy 简要代码 [3]

import numpy as np
from pyscf import scf, gto

from functools import partial
np.einsum = partial(np.einsum, optimize=["greedy", 1024 ** 3 * 2 / 8])

import warnings
warnings.filterwarnings("ignore")

import re

np.set_printoptions(5, linewidth=150, suppress=True)

分子体系与标准结果#

我们所使用的分子是非对称的双氧水分子,基组为 6-31G。比较常用但名称不太不常见的变量有

  • so, sv 表示占据轨道、非占轨道的分割 (split)

  • C, e 分别表示分子轨道系数 \(C_{\mu p}\) 与轨道能 \(\varepsilon_p\)

mol = gto.Mole()
mol.atom = """
O  0.0  0.0  0.0
O  0.0  0.0  1.5
H  1.0  0.0  0.0
H  0.0  0.7  1.0
"""
mol.basis = "6-31G"
mol.verbose = 0
mol.build()

natm = mol.natm
nao = nmo = mol.nao
nocc = mol.nelec[0]
nvir = nmo - nocc
so, sv = slice(0, nocc), slice(nocc, nmo)
scf_eng = scf.RHF(mol)
scf_eng.conv_tol = 1e-12
scf_eng.max_cycle = 128
scf_eng.kernel()
-150.5850337808384
C, e = scf_eng.mo_coeff, scf_eng.mo_energy
Co, Cv = C[:, so], C[:, sv]
eo, ev = e[so], e[sv]

我们的参考值来自于 Gaussian 的计算 (输入卡 H2O2-MP4.gjf、输出文件 H2O2-MP4.out)。

with open("H2O2-MP4.out", "r") as f:
    gaussian_output = f.readlines()

def gaussian_line(string):
    for line in gaussian_output:
        if string in line:
            return line[:-1].replace("D+", "E+").replace("D-", "E-")

其中一些结论有:

ref_mp2_corr = float(gaussian_line("E2 =").split()[2])
ref_mp3_corr = float(gaussian_line("E3=").split()[1])
ref_mp4_S = float(gaussian_line("MP4(S)=").split()[1])
ref_mp4_D = float(gaussian_line("MP4(D)=").split()[1])
ref_mp4_Q = float(gaussian_line("MP4(R+Q)=").split()[1])
ref_mp4_T = float(gaussian_line("E4(SDTQ)=").split()[1]) - float(gaussian_line("E4(SDQ)=").split()[1])
print("MP2 Corr: {:16.10f}".format(ref_mp2_corr))
print("MP3 Corr: {:16.10f}".format(ref_mp3_corr))
print("MP4 S   : {:16.10f}".format(ref_mp4_S))
print("MP4 D   : {:16.10f}".format(ref_mp4_D))
print("MP4 T   : {:16.10f}".format(ref_mp4_T))
print("MP4 Q   : {:16.10f}".format(ref_mp4_Q))
MP2 Corr:    -0.2690117593
MP3 Corr:     0.0074517392
MP4 S   :    -0.0043821604
MP4 D   :    -0.0088190269
MP4 T   :    -0.0067126030
MP4 Q   :     0.0011355775

RMP2 相关能量#

这里基本参照了 Helgaker 书的叙述思路,因此可能与我曾经写过的文档记号和变量名有微妙的区别。

变量名

公式表达式

意义

程序的角标顺序

出处

E_iajb

\(\varepsilon_{ij}^{ab}\)

轨道能差

\((i, a, j, b)\)

eq (14.4.18)

g_mo

\(g_{pqrs}\)

分子轨道 ERI 积分

\((p, q, r, s)\)

-

t_iajb

\({t_{ij}^{ab}}^{(1)}\)

一阶激发系数

\((i, a, j, b)\)

eq (14.4.41)

L_mo

\(L_{pqrs}\)

定义量

\((p, q, r, s)\)

eq (13.7.15)

T_iajb

\({\bar t_{ij}^{ab}}^{(1)}\)

定义量

\((i, a, j, b)\)

eq (14.4.42)

上述定义的一些实际表达式为

\[\begin{split} \begin{align} \varepsilon_{ij}^{ab} &= - \varepsilon_i + \varepsilon_a - \varepsilon_j + \varepsilon_b \\ g_{pqrs} &= C_{\mu p} C_{\nu q} (\mu \nu | \kappa \lambda) C_{\kappa r} C_{\lambda s} \\ {t_{ij}^{ab}}^{(1)} &= \frac{g_{iajb}}{\varepsilon_{ij}^{ab}} \\ L_{pqrs} &= 2 g_{pqrs} - g_{psrq} \\ {\bar t_{ij}^{ab}}^{(1)} &= 2 \frac{L_{iajb}}{\varepsilon_{ij}^{ab}} = 4 {t_{ij}^{ab}}^{(1)} - 2 {t_{ij}^{ba}}^{(1)} \end{align} \end{split}\]
eri_ao = mol.intor("int2e")
eri_mo = np.einsum("uvkl, up, vq, kr, ls -> pqrs", eri_ao, C, C, C, C)
E_iajb = - e[so, None, None, None] + e[None, sv, None, None] - e[None, None, so, None] + e[None, None, None, sv]
g_mo = eri_mo
t_iajb = - g_mo[so, sv, so, sv] / E_iajb
L_mo = 2 * g_mo - g_mo.swapaxes(-1, -3)
T_iajb = 4 * t_iajb - 2 * t_iajb.swapaxes(-1, -3)

在这些定义下,我们可以相当容易地写出 RMP2 的能量 (Helgaker, eq (14.4.55))

\[ E_\mathrm{RMP2, corr} = {t_{ij}^{ab}}^{(1)} L_{iajb} \]
energy_mp2_corr = (t_iajb * L_mo[so, sv, so, sv]).sum()
energy_mp2_corr
-0.26901177170795454

RMP3 相关能#

RMP3 能量可以根据 Helgaker, eq (14.4.61-62) 构建:

\[ X_{ij}^{ab} = \frac{1}{2} {t_{ij}^{cd}}^{(1)} g_{acbd} + \frac{1}{2} {t_{kl}^{ab}}^{(1)} g_{kilj} + {t_{ik}^{ac}}^{(1)} L_{bjkc} - {t_{kj}^{ac}}^{(1)} g_{bcki} - {t_{ki}^{ac}}^{(1)} g_{bjkc} \]
mp3_X_iajb = (
    + 0.5 * np.einsum("icjd, acbd -> iajb", t_iajb, g_mo[sv, sv, sv, sv])
    + 0.5 * np.einsum("kalb, kilj -> iajb", t_iajb, g_mo[so, so, so, so])
    + np.einsum("iakc, bjkc -> iajb", t_iajb, L_mo[sv, so, so, sv])
    - np.einsum("kajc, bcki -> iajb", t_iajb, g_mo[sv, sv, so, so])
    - np.einsum("kaic, bjkc -> iajb", t_iajb, g_mo[sv, so, so, sv])
)

关于 Helgaker 书上的 eq (14.4.59),可能是由于我们的实现与之实现方式不同,因此 Helgaker 书中的 \({\tilde t_{ij}^{ab}}^{(1)}\) 可以当作 \({\bar t_{ij}^{ab}}^{(1)}\) 使用。因此,

\[ E_\mathrm{RMP3, corr} = {\bar t_{ij}^{ab}}^{(1)} X_{ij}^{ab} \]
energy_mp3_corr = (T_iajb * mp3_X_iajb).sum()
energy_mp3_corr
0.007451743819516383

RMP4(SDQ) 相关能#

在 RMP4 相关能计算过程中,由于其中涉及到的三激发项从实现上较为复杂,因此我们将会对其分开讨论。

RMP4(S) 相关能#

定义以下变量:

变量名

公式表达式

意义

程序的角标顺序

出处

E_ia

\(\varepsilon_{i}^{a}\)

轨道能差

\((i, a)\)

eq (14.4.18)

t_2_ia

\({t_{i}^{a}}^{(2)}\)

二阶激发系数

\((i, a)\)

eq (14.4.50)

\(\varepsilon_i^a\) 的定义是显然的;\({t_{i}^{a}}^{(2)}\) 的定义为

\[ {t_{i}^{a}}^{(2)} = \frac{1}{\varepsilon_i^a} \left[ {t_{kl}^{ad}}^{(1)} L_{kild} - {t_{ki}^{cd}}^{(1)} L_{adkc} \right] \]

定义以下变量:

变量名

公式表达式

意义

程序的角标顺序

出处

E_ia

\(\varepsilon_{i}^{a}\)

轨道能差

\((i, a)\)

eq (14.4.18)

t_2_ia

\({t_{i}^{a}}^{(2)}\)

二阶激发系数

\((i, a)\)

eq (14.4.50)

E_ia = - e[so, None] + e[None, sv]
t_2_ia = (
    + np.einsum("kald, kild -> ia", t_iajb, L_mo[so, so, so, sv])
    - np.einsum("kcid, adkc -> ia", t_iajb, L_mo[sv, sv, so, sv])
)
t_2_ia /= E_ia

由此,根据 Helgaker eq (14.4.79) 与 eq (14.4.83),有

\[ S_{ij}^{ab} = {t_{j}^{c}}^{(2)} g_{aibc} - {t_{k}^{b}}^{(2)} g_{aikj} \]
mp4_S_iajb = (
    + np.einsum("jc, aibc -> iajb", t_2_ia, g_mo[sv, so, sv, sv])
    - np.einsum("kb, aikj -> iajb", t_2_ia, g_mo[sv, so, so, so])
)

以及

\[ E_\mathrm{RMP4, S} = {\bar t_{ij}^{ab}}^{(1)} S_{ij}^{ab} \]
energy_mp4_S = (T_iajb * mp4_S_iajb).sum()
energy_mp4_S
-0.004382160888639593
RMP4(D) 相关能#

定义以下变量:

变量名

公式表达式

意义

程序的角标顺序

出处

t_2_iajb

\({t_{ij}^{ab}}^{(2)}\)

二阶激发系数

\((i, a, j, b)\)

eq (14.4.51)

\({t_{ij}^{ab}}^{(2)}\) 的定义为

\[ {t_{ij}^{ab}}^{(2)} = \frac{1}{\varepsilon_{ij}^{ab}} \left[ - {t_{ij}^{cd}}^{(1)} g_{acbd} - {t_{kl}^{ab}}^{(1)} g_{kilj} - \hat P_{ij}^{ab} \left( {t_{ik}^{ac}}^{(1)} L_{bjkc} - {t_{kj}^{ac}}^{(1)} g_{bcki} - {t_{ki}^{ac}}^{(1)} g_{bjkc} \right) \right] \]

注意到这里的算符 \(\hat P_{ij}^{ab}\) 是对称化算符,其出处是 Helgaker eq (13.7.13):

\[ \hat P_{ij}^{ab} = A_{ij}^{ab} + A_{ji}^{ba} \]

为此,我们将 \({t_{ij}^{ab}}^{(2)}\) 的后一个关于 \((i, a, j, b)\) 的张量 \(\left( {t_{ik}^{ac}}^{(1)} L_{bjkc} - {t_{kj}^{ac}}^{(1)} g_{bcki} - {t_{ki}^{ac}}^{(1)} g_{bjkc} \right)\) 先使用变量 tmp_symm 储存,随后使用 np.transpose 转置来执行对称化。

tmp_symm = (
    + np.einsum("iakc, bjkc -> iajb", t_iajb, L_mo[sv, so, so, sv])
    - np.einsum("kajc, bcki -> iajb", t_iajb, g_mo[sv, sv, so, so])
    - np.einsum("kaic, bjkc -> iajb", t_iajb, g_mo[sv, so, so, sv])
)
tmp_symm += tmp_symm.transpose((2, 3, 0, 1))
t_2_iajb = (
    - np.einsum("icjd, acbd -> iajb", t_iajb, g_mo[sv, sv, sv, sv])
    - np.einsum("kalb, kilj -> iajb", t_iajb, g_mo[so, so, so, so])
    - tmp_symm
)
t_2_iajb /= E_iajb

由此,根据 Helgaker eq (14.4.80) 与 eq (14.4.83),有

\[ D_{ij}^{ab} = \frac{1}{2} {t_{ij}^{cd}}^{(2)} g_{acbd} + \frac{1}{2} {t_{kl}^{ab}}^{(2)} g_{kilj} + {t_{ik}^{ac}}^{(2)} L_{bjkc} - {t_{kj}^{ac}}^{(2)} g_{bcki} - {t_{ki}^{ac}}^{(2)} g_{bjkc} \]
mp4_D_iajb = (
    + 0.5 * np.einsum("icjd, acbd -> iajb", t_2_iajb, g_mo[sv, sv, sv, sv])
    + 0.5 * np.einsum("kalb, kilj -> iajb", t_2_iajb, g_mo[so, so, so, so])
    + 1.0 * np.einsum("iakc, bjkc -> iajb", t_2_iajb, L_mo[sv, so, so, sv])
    - 1.0 * np.einsum("kajc, bcki -> iajb", t_2_iajb, g_mo[sv, sv, so, so])
    - 1.0 * np.einsum("kaic, bjkc -> iajb", t_2_iajb, g_mo[sv, so, so, sv])
)

以及

\[ E_\mathrm{RMP4, D} = {\bar t_{ij}^{ab}}^{(1)} D_{ij}^{ab} \]
energy_mp4_D = (T_iajb * mp4_D_iajb).sum()
energy_mp4_D
-0.008819028049601586
RMP4(Q) 相关能#

在 Helgaker 的书中,RMP4(Q) 的相关能没有再作额外定义。根据 Helgaker eq (14.4.82) 与 eq (14.4.83),有

\[\begin{split} \begin{align} Q_{ij}^{ab} &= \frac{1}{2} {t_{kl}^{ab}}^{(1)} {t_{ij}^{cd}}^{(1)} g_{kcld} + {t_{ik}^{ac}}^{(1)} {t_{jl}^{bd}}^{(1)} L_{kcld} - {t_{ik}^{ac}}^{(1)} {t_{lj}^{bd}}^{(1)} L_{kcld} \\ & \quad + \frac{1}{2} {t_{ki}^{ac}}^{(1)} {t_{lj}^{bd}}^{(1)} g_{kcld} + \frac{1}{2} {t_{kj}^{ad}}^{(1)} {t_{li}^{bc}}^{(1)} g_{kcld} \\ & \quad - {t_{ik}^{ab}}^{(1)} {t_{lj}^{cd}}^{(1)} L_{lckd} - {t_{ij}^{ac}}^{(1)} {t_{kl}^{bd}}^{(1)} L_{kcld} \end{align} \end{split}\]
mp4_Q_iajb = (
    + 0.5 * np.einsum("kalb, icjd, kcld -> iajb", t_iajb, t_iajb, g_mo[so, sv, so, sv])
    + 1.0 * np.einsum("iakc, jbld, kcld -> iajb", t_iajb, t_iajb, L_mo[so, sv, so, sv])
    - 1.0 * np.einsum("iakc, lbjd, kcld -> iajb", t_iajb, t_iajb, L_mo[so, sv, so, sv])
    + 0.5 * np.einsum("kaic, lbjd, kcld -> iajb", t_iajb, t_iajb, g_mo[so, sv, so, sv])
    + 0.5 * np.einsum("kajd, lbic, kcld -> iajb", t_iajb, t_iajb, g_mo[so, sv, so, sv])
    - 1.0 * np.einsum("iakb, lcjd, lckd -> iajb", t_iajb, t_iajb, L_mo[so, sv, so, sv])
    - 1.0 * np.einsum("iajc, kbld, kcld -> iajb", t_iajb, t_iajb, L_mo[so, sv, so, sv])
)

以及

\[ E_\mathrm{RMP4, Q} = {\bar t_{ij}^{ab}}^{(1)} Q_{ij}^{ab} \]
energy_mp4_Q = (T_iajb * mp4_Q_iajb).sum()
energy_mp4_Q
0.001135577514293328

由此,我们可以计算 RMP4(SDQ) 所贡献的相关能:

energy_mp4SDQ_corr = energy_mp4_S + energy_mp4_D + energy_mp4_Q
energy_mp4SDQ_corr
-0.01206561142394785

RMP4(T) 相关能#

RMP4(T) 从实现上来讲,最简单的做法需要消耗 \(O^3 V^3\) 的内存。我们先从简单的实现入手;随后会简要给出一个中间张量的内存消耗是 \(O^2 V^2\) 的算法。

RMP4(T) 相关能:简单实现#

定义以下变量:

变量名

公式表达式

意义

程序的角标顺序

出处

E_iajbkc

\(\varepsilon_{ijk}^{abc}\)

轨道能差

\((i, a, j, b, k, c)\)

eq (14.4.18)

t_2_iajbkc

\({t_{ijk}^{abc}}^{(2)}\)

二阶激发系数

\((i, a, j, b, k, c)\)

eq (14.4.52)

\(\varepsilon_{ijk}^{abc}\) 的定义是显然的;\({t_{ijk}^{abc}}^{(2)}\) 的定义为

\[ {t_{ijk}^{abc}}^{(2)} = - \frac{1}{\varepsilon_{ijk}^{abc}} \hat P_{ijk}^{abc} \left( {t_{ij}^{ad}}^{(1)} g_{ckbd} - {t_{il}^{ab}}^{(1)} g_{cklj} \right) \]

这里的算符 \(\hat P_{ijk}^{abc}\) 仍然是对称算符,但其形式更复杂 (Helgaker, eq (13.7.14)):

\[ \hat P_{ijk}^{abc} A_{ijk}^{abc} = A_{ijk}^{abc} + A_{ikj}^{acb} + A_{jik}^{bac} + A_{jki}^{bca} + A_{kij}^{cab} + A_{kji}^{cba} \]
E_iajbkc = (
    - e[so, None, None, None, None, None]
    + e[None, sv, None, None, None, None]
    - e[None, None, so, None, None, None]
    + e[None, None, None, sv, None, None]
    - e[None, None, None, None, so, None]
    + e[None, None, None, None, None, sv]
)
t_2_iajbkc = (
    + np.einsum("iajd, ckbd -> iajbkc", t_iajb, g_mo[sv, so, sv, sv])
    - np.einsum("ialb, cklj -> iajbkc", t_iajb, g_mo[sv, so, so, so])
)
t_2_iajbkc = (
    + t_2_iajbkc.transpose((0, 1, 2, 3, 4, 5))
    + t_2_iajbkc.transpose((0, 1, 4, 5, 2, 3))
    + t_2_iajbkc.transpose((2, 3, 0, 1, 4, 5))
    + t_2_iajbkc.transpose((2, 3, 4, 5, 0, 1))
    + t_2_iajbkc.transpose((4, 5, 0, 1, 2, 3))
    + t_2_iajbkc.transpose((4, 5, 2, 3, 0, 1))
)
t_2_iajbkc = - t_2_iajbkc / E_iajbkc

由此,根据 Helgaker eq (14.4.81) 与 eq (14.4.83),有

\[ T_{ij}^{ab} = {t_{ijk}^{acd}}^{(2)} L_{bckd} - {t_{kji}^{acd}}^{(2)} g_{kdbc} - {t_{ikl}^{abc}}^{(2)} L_{kjlc} + {t_{lki}^{abc}}^{(2)} g_{kjlc} \]
mp4_T_iajb = (
    + np.einsum("iajckd, bckd -> iajb", t_2_iajbkc, L_mo[sv, sv, so, sv])
    - np.einsum("kajcid, kdbc -> iajb", t_2_iajbkc, g_mo[so, sv, sv, sv])
    - np.einsum("iakblc, kjlc -> iajb", t_2_iajbkc, L_mo[so, so, so, sv])
    + np.einsum("lakbic, kjlc -> iajb", t_2_iajbkc, g_mo[so, so, so, sv])
)

以及

\[ E_\mathrm{RMP4, T} = {\bar t_{ij}^{ab}}^{(1)} T_{ij}^{ab} \]
energy_mp4_T = (T_iajb * mp4_T_iajb).sum()
energy_mp4_T
-0.006712603570763939

由此,我们可以给出完整的 RMP4 相关能矫正了:

energy_mp4_corr = energy_mp4_S + energy_mp4_D + energy_mp4_T + energy_mp4_Q
energy_mp4_corr
-0.018778214994711787
RMP4(T) 相关能:中间张量 \(O^2 V^2\) 内存实现#

这里我们通过限制 \({t_{ijk}^{abc}}^{(2)}\) 中的 \(b\)\(k\) 并作求和的方式,将中间矩阵的储存大小降为 \(O^2 V^2\) 并给出 RMP4(T) 的能量。

这个严格来说还是至少消耗了 \(O^1 V^3\) 的内存,因为在计算过程中用到了张量 \(g_{aicd}\)

首先指出,像上述代码中使用 np.transpose 的方式来处理 \(\hat P_{ijk}^{abc}\) 在这里可能不适用;因此需要手输所有的中间张量的缩并过程。下述函数可以用来辅助我们进行带有指标转换的张量缩并。

# https://stackoverflow.com/a/11122744/9647779
def substitute_string(string, rule):
    str_lst = list(string)
    rule1, rule2 = rule.replace(" ", "").split("->")
    idx_list = [[i.start() for i in re.finditer(c, string)] for c in rule1]
    for idx, pos_list in enumerate(idx_list):
        for pos in pos_list:
            str_lst[pos] = rule2[idx]
    return "".join(str_lst)

譬如我们现在需要将缩并的目标 \({t_{ij}^{ad}}^{(1)} g_{ckbd}\)\((i, a, j, b, k, c)\) 转换为 \((j, b, k, c, i, a)\),那么我们执行下述代码就可以生成转换后的缩并字符串了:

substitute_string("iajd, ckbd", "iajbkc -> jbkcia")
'jbkd, aicd'

后文中出现的代码就是受这个小函数的帮助而写成的。

事实上,下述程序几乎与上面的程序等价,只是每次处理 \({t_{ijk}^{abc}}^{(2)}\) 张量时,总是先固定 \(b, k\) 指标,而对其它的张量进行正常的运算,求出固定了 \(b, k\) 的 RMP4(T) 相关能;随后我们再对 \(b, k\) 指标进行循环,把所有 \(b, k\) 相关能贡献总和起来,得到最终的 RMP4(T) 相关能。

energy_mp4_T_by_ckiter = 0
for k in range(nocc):
    for b in range(nvir):
        bn = b + nocc
        t_2_bk_iajb = - (
            + np.einsum("iajd, cd -> iajc", t_iajb[:, :, :, :], g_mo[sv,  k, bn, sv])
            - np.einsum("ial, clj -> iajc", t_iajb[:, :, :, b], g_mo[sv,  k, so, so])
            + np.einsum("iad, jcd -> iajc", t_iajb[:, :, k, :], g_mo[bn, so, sv, sv])
            - np.einsum("ialc, jl -> iajc", t_iajb[:, :, :, :], g_mo[bn, so, so,  k])
            + np.einsum("jid, cad -> iajc", t_iajb[:, b, :, :], g_mo[sv,  k, sv, sv])
            - np.einsum("jla, cli -> iajc", t_iajb[:, b, :, :], g_mo[sv,  k, so, so])
            + np.einsum("jd, aicd -> iajc", t_iajb[:, b, k, :], g_mo[sv, so, sv, sv])
            - np.einsum("jlc, ail -> iajc", t_iajb[:, b, :, :], g_mo[sv, so, so,  k])
            + np.einsum("cid, jad -> iajc", t_iajb[k, :, :, :], g_mo[bn, so, sv, sv])
            - np.einsum("cla, jli -> iajc", t_iajb[k, :, :, :], g_mo[bn, so, so, so])
            + np.einsum("cjd, aid -> iajc", t_iajb[k, :, :, :], g_mo[sv, so, bn, sv])
            - np.einsum("cl, ailj -> iajc", t_iajb[k, :, :, b], g_mo[sv, so, so, so])
        )
        t_2_bk_iajb /= (
            - e[so, None, None, None]
            + e[None, sv, None, None]
            - e[None, None, so, None]
            + e[None, None, None, sv]
            - e[k] + e[bn]
        )
        mp4_T_bk_iajb = (
            + np.einsum("iajd, bd -> iajb", t_2_bk_iajb, L_mo[sv, bn,  k, sv])
            - np.einsum("idja, db -> iajb", t_2_bk_iajb, g_mo[ k, sv, sv, bn])
            - np.einsum("ialb, jl -> iajb", t_2_bk_iajb, L_mo[ k, so, so, bn])
            + np.einsum("laib, jl -> iajb", t_2_bk_iajb, g_mo[ k, so, so, bn])
        )
        energy_mp4_T_by_ckiter += (T_iajb * mp4_T_bk_iajb).sum()
energy_mp4_T_by_ckiter
-0.006712603570763938

scsRPA 的频域与绝热耦合系数积分实现#

创建日期:2020-08-24

这篇文档中,我们会回顾 scsRPA 的实现过程 Zhang, Xu [1]。对于其理论推导,我们基本不作的讨论。

我们会大量使用 前一篇文档 的结论与程序。但需要注意,这篇文档中,我们所使用的参考态是 PBE0 而非 PBE,并且分子会选用开壳层分子。

%matplotlib notebook

from pyscf import gto, dft, scf, cc, mcscf, df
import numpy as np
import scipy
from scipy.linalg import fractional_matrix_power
from functools import partial
from matplotlib import pyplot as plt

np.set_printoptions(5, linewidth=120, suppress=True)
np.einsum = partial(np.einsum, optimize=True)

开壳层分子体系与 PBE0 计算#

由于算法的特殊性,我们需要使用上下自旋不定的开壳层分子来描述具体过程。因此,我们使用小体系 cc-pVTZ 基组下的 OH 自由基分子。

mol = gto.Mole()
mol.atom = """
O  0. 0. 0.
H  0. 0. 1.
"""
mol.basis = "cc-pVTZ"
mol.verbose = 0
mol.spin = 1
mol.build()
<pyscf.gto.mole.Mole at 0x7fb1e5206e20>

RI 基组选用 cc-pVTZ-ri 预定义基组。

mol_df = mol.copy()
mol_df.basis = "cc-pVTZ-ri"
mol_df.build()
<pyscf.gto.mole.Mole at 0x7fb2185a00d0>

我们首先计算分子的 PBE0 能量,其目的是得到 PBE0 的分子轨道。该计算实例记在 mf 中。出于便利,我们使用未经过 DF (Density Fitting) 的自洽场。

mf = dft.UKS(mol, xc="PBE0").run()
mf.e_tot
-75.68100138443273

我们随后需要定义与分子或方法有关的变量。大多数情况下,我们仍然使用 \(i, j\) 表示占据分子轨道,\(a, b\) 表示非占分子轨道,\(p, q, r, s\) 表示全部分子轨道。这种表示方法并不是很严格,因为一般情况下开壳层应当使用类似于 \(i_\alpha, j_\beta\) 等记号;但可以比较好地契合程序编写。

\(\mu, \nu, \kappa, \lambda\) 仍然表示原子轨道,\(P, Q\) 仍然表示 DF 轨道。\(\sigma, \gamma\) 在这篇文档中表示 (未指定的) 自旋;\(\alpha, \beta\) 表示上、下自旋。

  • nocc \((n_\mathrm{occ}^\alpha, n_\mathrm{occ}^\beta)\) 占据轨道数

  • nvir \((n_\mathrm{vir}^\alpha, n_\mathrm{vir}^\beta)\) 未占轨道数

  • dim_ov \((n_\mathrm{occ}^\alpha n_\mathrm{vir}^\alpha, n_\mathrm{occ}^\beta n_\mathrm{vir}^\beta)\) 占据乘以未占轨道数

  • nmo \(n_\mathrm{MO}\) 分子轨道数,等于占据轨道数 nao \(n_\mathrm{AO}\)

  • naux \(n_\mathrm{aux}\) Density Fitting 基组轨道数 (或者也称辅助基组 Auxiliary)

  • so, sv, sa 占据、未占、全轨道分割 (用于程序编写),变量形式为 Tuple[slice, slice]

  • eri0_ao \((\mu \nu | \kappa \lambda)\) 原子轨道双电子排斥积分

nocc, nmo, nao = mol.nelec, mol.nao, mol.nao
naux = mol_df.nao
nvir = (nmo - nocc[0], nmo - nocc[1])
dim_ov = (nocc[0] * nvir[0], nocc[1] * nvir[1])
so = slice(0, nocc[0]), slice(0, nocc[1])
sv = slice(nocc[0], nmo), slice(nocc[1], nmo)
eri0_ao = mol.intor("int2e")
  • e, eo, ev \((e_p^\alpha, e_p^\beta)\) 全、占据、未占 PBE 轨道能

  • C, Co, Cv \((C_{\mu p}^\alpha, C_{\mu p}^\beta)\) 全、占据、未占 PBE 轨道系数

e, C = mf.mo_energy, mf.mo_coeff
eo, ev = (e[0][so[0]], e[1][so[1]]), (e[0][sv[0]], e[1][sv[1]])
Co, Cv = (C[0][:, so[0]], C[1][:, so[1]]), (C[0][:, sv[0]], C[1][:, sv[1]])
  • eng_xc PBE0 交换相关能中,仅包含其 GGA 而没有杂化部分的能量 \(E_\mathrm{xc}^\mathsf{GGA}\)

  • eng_exactX 使用 PBE0 轨道所构建的精确交换能 \(E_\mathrm{x}^\mathsf{exact}\)

  • eng_HXX \(E^\mathsf{HXX} = E^\mathsf{xxRPA}_\mathrm{tot} - E^\mathsf{xxRPA}_\mathrm{c}\) 即除去 RPA 的总能量,它在杂化泛函中写为

    \[ E^\mathsf{HXX} = E^\mathsf{hGGA} - E_\mathrm{xc}^\mathsf{GGA} + (1 - c_\mathrm{x}) E_\mathrm{x}^\mathsf{exact} \]
ni = dft.numint.NumInt()
eng_xc = ni.nr_uks(mol, mf.grids, "PBE0", mf.make_rdm1())[1]
eng_xc
-6.671932093325463
eng_exactX = - 0.5 * (mf.get_k(mf.make_rdm1()) * mf.make_rdm1()).sum()
eng_exactX
-8.539252705875096
eng_HXX = mf.e_tot - eng_xc + (1 - ni.hybrid_coeff(mf.xc)) * eng_exactX
eng_HXX
-75.41350882051358
  • V_df_ia \((V_{ia, P}^\alpha, V_{ia, P}^\beta)\) DF 3c-2e 积分的导出结果,其具有下述性质:

    \[ \sum_{P} V_{ia,P}^\sigma V_{jb,P}^\sigma \simeq (ia|jb)^\sigma \]
  • V 上述张量重塑为矩阵,维度为 \((ia, P)\)

int2c2e = mol_df.intor("int2c2e")
int3c2e = df.incore.aux_e2(mol, mol_df)
int2c2e_half = scipy.linalg.cholesky(int2c2e, lower=True)
V_df_mp2 = scipy.linalg.solve_triangular(int2c2e_half, int3c2e.reshape(-1, naux).T, lower=True).reshape(naux, nao, nao).transpose((1, 2, 0))
V_df_ia = (
    np.einsum("uvP, ui, va -> iaP", V_df_mp2, Co[0], Cv[0]),
    np.einsum("uvP, ui, va -> iaP", V_df_mp2, Co[1], Cv[1]))
V = (V_df_ia[0].reshape(dim_ov[0], naux), V_df_ia[1].reshape(dim_ov[1], naux))
  • D_ia \((D_{ia}^\alpha, D_{ia}^\beta)\) 轨道能之差:

    \[ D_{ia}^\sigma = \varepsilon_i^\sigma - \varepsilon_a^\sigma \]

    其是维度为 \((ia, )\) 的向量

D_ia = (
    - eo[0][:, None] + ev[0][None, :],
    - eo[1][:, None] + ev[1][None, :])
D = (D_ia[0].flatten(), D_ia[1].flatten())

dRPA 能量计算回顾#

我们在上一篇文档中,提及了 RI 方法闭壳层 dRPA 相关能计算。开壳层的计算方法也是非常类似的。我们定义函数 Pi_alphaPi_beta,它表示两种自旋下的

\[ \Pi_{PQ}^\sigma (\tilde \omega) = - \sum_{ia \in \sigma} \frac{2 V_{ia, P}^\alpha V_{ia, Q}^\sigma D_{ia}^\sigma}{(D_{ia}^\sigma)^2 + \tilde \omega^2} \]

或者写为矩阵形式

\[ \mathbf{\Pi}^\sigma (\tilde \omega) = - 2 \mathbf{V}^{\sigma \dagger} \mathbf{D}^{\sigma, 1/2} (\mathbf{D}^\sigma + \tilde \omega^2 \mathbf{I})^{-1} \mathbf{D}^{\sigma, 1/2} \mathbf{V}^\sigma \]
Pi_alpha = lambda omega: - 2 * np.einsum("dP, d, dQ -> PQ", V[0], D[0] / (D[0]**2 + omega**2), V[0])
Pi_beta  = lambda omega: - 2 * np.einsum("dP, d, dQ -> PQ", V[1], D[1] / (D[1]**2 + omega**2), V[1])

我们额外定义 Pi_dRPA

\[ \Pi_{PQ}^\mathsf{dRPA} (\tilde \omega) = \sum_{\sigma \in \{ \alpha, \beta \}} \Pi_{PQ}^\sigma (\tilde \omega) = \Pi_{PQ}^\alpha (\tilde \omega) + \Pi_{PQ}^\beta (\tilde \omega) \]

或者写为矩阵形式

\[ \mathbf{\Pi}^\mathsf{dRPA} (\tilde \omega) = \mathbf{\Pi}^\alpha (\tilde \omega) + \mathbf{\Pi}^\beta (\tilde \omega) \]
Pi_dRPA = lambda omega: Pi_alpha(omega) + Pi_beta(omega)

在频域上格点积分的格点 \(\tilde{\omega}_g\) 与权重 \(w (\tilde{\omega}_g)\) 可以通过下述函数获得:

def gen_leggauss_0_inf(ngrid):
    x, w = np.polynomial.legendre.leggauss(ngrid)
    return 0.5 * (1 + x) / (1 - x), w / (1 - x)**2

那么,开壳层的 dRPA 能量 eng_dRPA 可以表述为

\[\begin{split} \begin{align} E_\mathrm{c}^\mathsf{dRPA} &= \frac{1}{2 \pi} \int_{0}^{+ \infty} \big( \log \det \big( \mathbf{1} - \mathbf{\Pi}^\mathsf{dRPA} (\tilde \omega) \big) + \mathrm{tr} \big( \mathbf{\Pi}^\mathsf{dRPA} (\tilde \omega) \big) \big) \, \mathrm{d} \tilde \omega \\ &= \frac{1}{2 \pi} \sum_{g} w(\tilde{\omega}_g) \big( \log \det \big( \mathbf{1} - \mathbf{\Pi}^\mathsf{dRPA} (\tilde \omega_g) \big) + \mathrm{tr} \big( \mathbf{\Pi}^\mathsf{dRPA} (\tilde \omega_g) \big) \big) \end{align} \end{split}\]

下面的计算中,对 \(\tilde{\omega}\) 的积分使用了 40 个格点。

eng_dRPA = 0
for omega, w_omega in zip(*gen_leggauss_0_inf(40)):
    eng_dRPA += 1 / (2 * np.pi) * w_omega * (np.log(np.linalg.det(np.eye(naux) - Pi_RPA(omega))) + Pi_RPA(omega).trace())
eng_dRPA
-0.3287828424653143

因此,dRPA 总能量为

eng_HXX + eng_dRPA
-75.7422916629789

ssRPA 相关能计算#

ssRPA (Same-Spin RPA) 相关能计算式与 dRPA 能量计算式非常相似:

\[ E_\mathrm{c}^\mathsf{ssRPA} = \frac{1}{2 \pi} \sum_{g} \sum_\sigma w(\tilde{\omega}_g) \big( \log \det \big( \mathbf{1} - \mathbf{\Pi}^\sigma (\tilde \omega_g) \big) + \mathrm{tr} \big( \mathbf{\Pi}^\sigma (\tilde \omega_g) \big) \big) \]
eng_ssRPA = 0
for omega, w_omega in zip(*gen_leggauss_0_inf(40)):
    eng_ssRPA += 1 / (2 * np.pi) * w_omega * (np.log(np.linalg.det(np.eye(naux) - Pi_alpha(omega))) + Pi_alpha(omega).trace())
    eng_ssRPA += 1 / (2 * np.pi) * w_omega * (np.log(np.linalg.det(np.eye(naux) - Pi_beta(omega))) + Pi_beta(omega).trace())
eng_ssRPA
-0.19104532917053876

osRPA1 相关能计算#

osRPA1 (RPA-type Opposite-Spin coupling Dyson equation terminated at the First-order) 所贡献的相关能计算式包含对耦合系数的积分。其较为基础的实现方式需要额外对绝热路径耦合系数 \(\lambda\) 作格点积分 (注意到前一篇文档中使用了 \(\alpha\) 作为耦合系数,但在这篇文档中已经被用作自旋向上记号)。

耦合系数 \(\lambda\) 积分下的 dRPA 相关能#

我们再次回顾 dRPA 的计算。事实上,dRPA 在推导过程中,是先有了关于耦合系数 \(\lambda\) 的积分表达式,后面才有 log det 的表达式。我们现在就将关于 \(\lambda\) 重新写出:

\[ E_\mathrm{c}^\mathsf{dRPA} = \frac{1}{2 \pi} \int_{0}^{+ \infty} \mathrm{d} \tilde \omega \, \mathrm{tr} \big( \mathbf{\Pi}^\mathsf{dRPA} (\tilde \omega) \big) - \frac{1}{2 \pi} \int_{0}^{+ \infty} \mathrm{d} \tilde \omega \int_0^1 \mathrm{d} \lambda \, \mathrm{tr} \left( \frac{\mathbf{\Pi}^\mathsf{dRPA} (\tilde \omega)}{\mathbf{1} - \lambda \mathbf{\Pi}^\mathsf{dRPA} (\tilde \omega)} \right) \]

上式第二项中,尽管矩阵除法的写法并不是很好,但由于我们求的是迹,因此实际写程序的时候我们不需要关心矩阵交换律是否满足。

对于 \(\lambda\) 从 0 至 1 的积分可以使用线性缩放后的 Legendre-Gauss 格点实现。下述函数 gen_leggauss_0_1 会输出格点 \(\lambda_{g'}\) 与其权重 \(w(\lambda_{g'})\)

def gen_leggauss_0_1(ngrid):
    x, w = np.polynomial.legendre.leggauss(ngrid)
    return 0.5 * (x + 1), 0.5 * w

因此,若将上述积分表达式更换为格点求和,则

\[ E_\mathrm{c}^\mathsf{dRPA} = \frac{1}{2 \pi} \sum_g w(\tilde{\omega}_g) \, \mathrm{tr} \big( \mathbf{\Pi}^\mathsf{dRPA} (\tilde{\omega}_g) \big) - \frac{1}{2 \pi} \sum_g w(\tilde{\omega}_g) \sum_{g'} w(\lambda_{g'}) \, \mathrm{tr} \left( \frac{\mathbf{\Pi}^\mathsf{dRPA} (\tilde{\omega}_g)}{\mathbf{1} - \lambda_{g'} \mathbf{\Pi}^\mathsf{dRPA} (\tilde{\omega}_g)} \right) \]

下面的计算中,对 \(\lambda_{g'}\) 的积分使用了 10 个格点。

eng_dRPA_ac = 0
for omega, w_omega in zip(*gen_leggauss_0_inf(40)):
    # Term 1
    Pi_dRPA_matrix = Pi_RPA(omega)
    eng_dRPA_ac += 1 / (2 * np.pi) * w_omega * Pi_dRPA_matrix.trace()
    # Term 2
    W_matrix = np.zeros((naux, naux))
    for lambd, w_lambd in zip(*gen_leggauss_0_1(10)):
        W_matrix += w_lambd * np.linalg.inv(np.eye(naux) - lambd * Pi_dRPA_matrix)
    eng_dRPA_ac -= 1 / (2 * np.pi) * w_omega * (W_matrix @ Pi_dRPA_matrix).trace()
eng_dRPA_ac
-0.3287828424662268

留意到上式中使用了中间变量 W_matrix:

\[ \mathbf{W} (\tilde \omega) = \int_0^1 \mathrm{d} \lambda \big( \mathbf{1} - \lambda \mathbf{\Pi}^\mathsf{dRPA} (\tilde \omega) \big)^{-1} \]
osRPA1 相关能#

osRPA1 相关能可以写为

\[\begin{split} E_\mathrm{c}^\mathsf{osRPA1} = \frac{1}{2 \pi} \sum_{\sigma \in \{ \alpha, \beta \}} \int_{0}^{+ \infty} \mathrm{d} \tilde \omega \, \mathrm{tr} \big( \mathbf{\Pi}^\sigma (\tilde \omega) \big) - \frac{1}{2 \pi} \sum_{\substack{\sigma, \gamma \in \{ \alpha, \beta \} \\ \gamma \neq \sigma}} \int_{0}^{+ \infty} \mathrm{d} \tilde \omega \int_0^1 \mathrm{d} \lambda \, \mathrm{tr} \left( \frac{\mathbf{\Pi}^\sigma (\tilde \omega)}{\mathbf{1} - \lambda \mathbf{\Pi}^\gamma (\tilde \omega)} \right) \end{split}\]

程序编写是类似的。对 \(\tilde{\omega}_{g}\) 的积分使用 40 个格点,对 \(\lambda_{g'}\) 的积分使用 10 个格点。

eng_osRPA1 = 0
for omega, w_omega in zip(*gen_leggauss_0_inf(40)):
    # Term 1
    Pi_alpha_matrix, Pi_beta_matrix = Pi_alpha(omega), Pi_beta(omega)
    eng_osRPA1 += 1 / (2 * np.pi) * w_omega * (Pi_alpha_matrix.trace() + Pi_beta_matrix.trace())
    # Term 2
    W_alpha_matrix, W_beta_matrix = np.zeros((naux, naux)), np.zeros((naux, naux))
    for lambd, w_lambd in zip(*gen_leggauss_0_1(10)):
        W_alpha_matrix += w_lambd * np.linalg.inv(np.eye(naux) - lambd * Pi_alpha_matrix)
        W_beta_matrix  += w_lambd * np.linalg.inv(np.eye(naux) - lambd * Pi_beta_matrix)
    eng_osRPA1 += - 1 / (2 * np.pi) * w_omega * ((W_alpha_matrix @ Pi_beta_matrix).trace())
    eng_osRPA1 += - 1 / (2 * np.pi) * w_omega * ((W_beta_matrix @ Pi_alpha_matrix).trace())
eng_osRPA1
-0.17888391093782954

上述过程的总计算复杂度是 \(O(n_g n_\mathrm{aux}^2 n_\mathrm{occ} n_\mathrm{vir} + n_g n_{g'} b n_\mathrm{aux}^3)\),其中第一项是生成 \(\mathbf{\Pi}^\sigma (\tilde \omega)\) 的复杂度,第二项是格点积分外加矩阵求逆的复杂度,\(b\) 代表矩阵求逆复杂度中的 \(O(b n^3)\)

闭壳层下 osRPA1 与 ssRPA 相关能等价的说明#

留意到对于闭壳层的情况,\(\mathbf{\Pi}^\alpha (\tilde \omega) = \mathbf{\Pi}^\beta (\tilde \omega)\),因此可以使用简化式

\[ - \int_0^1 \mathrm{d} \lambda \, \mathrm{tr} \left( \frac{\mathbf{S}}{\mathbf{1} - \lambda \mathbf{S}} \right) = \log \det \big( \mathbf{1} - \mathbf{S} \big) \]

其中,\(\mathbf{S}\) 是正定的对称矩阵。那么 osRPA1 的表达式很容易地会推到与 ssRPA 完全相同的形式。因此,闭壳层下 \(E_\mathrm{c}^\mathsf{osRPA1} = E_\mathrm{c}^\mathsf{ssRPA}\)。但开壳层下两者的数值会有不同。

scsRPA 能量结算#

随后,我们就可以计算得到各种贡献分项。

\[\begin{split} \begin{align} E_\mathrm{c}^\mathsf{osRPA} &= E_\mathrm{c}^\mathsf{dRPA} - E_\mathrm{c}^\mathsf{ssRPA} \\ E_\mathrm{c}^\mathsf{osRPAr} &= E_\mathrm{c}^\mathsf{osRPA} - E_\mathrm{c}^\mathsf{osRPA1} \\ E_\mathrm{c}^\mathsf{ssRPA+} &= E_\mathrm{c}^\mathsf{ssRPA} + E_\mathrm{c}^\mathsf{osRPAr} \end{align} \end{split}\]
eng_osRPA  = eng_dRPA  - eng_ssRPA
eng_osRPAr = eng_osRPA - eng_osRPA1
eng_ssRPAp = eng_ssRPA + eng_osRPAr
print("{:6}{:16.8f}".format(" dRPA", eng_dRPA))
print("{:6}{:16.8f}".format("ssRPA", eng_ssRPA))
print("{:6}{:16.8f}".format("osRPA", eng_osRPA))
print("{:6}{:16.8f}".format("osRPA1", eng_osRPA1))
print("{:6}{:16.8f}".format("osRPAr", eng_osRPAr))
print("{:6}{:16.8f}".format("ssRPA+", eng_ssRPAp))
 dRPA      -0.32878284
ssRPA      -0.19104533
osRPA      -0.13773751
osRPA1     -0.17888391
osRPAr      0.04114640
ssRPA+     -0.14989893

因此,scsRPA 能量可以写为

\[ E_\mathrm{c}^\mathsf{scsRPA} = \frac{6}{5} E_\mathrm{c}^\mathsf{osRPA1} + \frac{3}{4} E_\mathrm{c}^\mathsf{ssRPA+} \]
eng_scsRPA = 6/5 * eng_osRPA1 + 3/4 * eng_ssRPAp
eng_scsRPA
-0.327084891771009

事实上,上式也等价于

\[ E_\mathrm{c}^\mathsf{scsRPA} = \frac{9}{20} E_\mathrm{c}^\mathsf{osRPA1} + \frac{3}{4} E_\mathrm{c}^\mathsf{dRPA} \]
eng_scsRPA = 9/20 * eng_osRPA1 + 3/4 * eng_dRPA
eng_scsRPA
-0.327084891771009

那么 scsRPA 总能量则表示为

eng_HXX + eng_scsRPA
-75.74059371228459

氮气解离曲线#

我们下面将会复现氮气解离曲线 (Zhang, Xu [1] 文献的 Figure 3)。我们会分别使用到闭壳层与开壳层 scsRPA 的计算结果。

def get_UscsRPA(mol, dfbasis="cc-pVTZ-ri"):
    # molecule preparation
    mf = dft.UKS(mol, xc="PBE0").run()
    eng_PBE0 = mf.e_tot
    mol_df = mol.copy()
    mol_df.basis = dfbasis
    mol_df.build()
    # Basic information preparation
    nocc, nmo, nao = mol.nelec, mol.nao, mol.nao
    naux = mol_df.nao
    nvir = (nmo - nocc[0], nmo - nocc[1])
    dim_ov = (nocc[0] * nvir[0], nocc[1] * nvir[1])
    so = slice(0, nocc[0]), slice(0, nocc[1])
    sv = slice(nocc[0], nmo), slice(nocc[1], nmo)
    eri0_ao = mol.intor("int2e")
    e, C = mf.mo_energy, mf.mo_coeff
    eo, ev = (e[0][so[0]], e[1][so[1]]), (e[0][sv[0]], e[1][sv[1]])
    Co, Cv = (C[0][:, so[0]], C[1][:, so[1]]), (C[0][:, sv[0]], C[1][:, sv[1]])
    # eng_HXX calculation
    ni = dft.numint.NumInt()
    eng_xc = ni.nr_uks(mol, mf.grids, mf.xc, mf.make_rdm1())[1]
    eng_exactX = - 0.5 * (mf.get_k(mf.make_rdm1()) * mf.make_rdm1()).sum()
    eng_HXX = mf.e_tot - eng_xc + (1 - ni.hybrid_coeff(mf.xc)) * eng_exactX
    # density fitting 
    int2c2e = mol_df.intor("int2c2e")
    int3c2e = df.incore.aux_e2(mol, mol_df)
    int2c2e_half = scipy.linalg.cholesky(int2c2e, lower=True)
    V_df_mp2 = scipy.linalg.solve_triangular(int2c2e_half, int3c2e.reshape(-1, naux).T, lower=True).reshape(naux, nao, nao).transpose((1, 2, 0))
    V_df_ia = (
        np.einsum("uvP, ui, va -> iaP", V_df_mp2, Co[0], Cv[0]),
        np.einsum("uvP, ui, va -> iaP", V_df_mp2, Co[1], Cv[1]))
    V = (V_df_ia[0].reshape(dim_ov[0], naux), V_df_ia[1].reshape(dim_ov[1], naux))
    D_ia = (
        - eo[0][:, None] + ev[0][None, :],
        - eo[1][:, None] + ev[1][None, :])
    D = (D_ia[0].flatten(), D_ia[1].flatten())
    # dRPA Preparation
    Pi_alpha = lambda omega: - 2 * np.einsum("dP, d, dQ -> PQ", V[0], D[0] / (D[0]**2 + omega**2), V[0])
    Pi_beta  = lambda omega: - 2 * np.einsum("dP, d, dQ -> PQ", V[1], D[1] / (D[1]**2 + omega**2), V[1])
    # dRPA, osRPA1 correlation energy calculation
    eng_dRPA, eng_osRPA1, eng_ssRPA = 0, 0, 0
    for omega, w_omega in zip(*gen_leggauss_0_inf(40)):
        Pi_alpha_matrix, Pi_beta_matrix = Pi_alpha(omega), Pi_beta(omega)
        Pi_dRPA_matrix = Pi_alpha_matrix + Pi_beta_matrix
        eng_dRPA   += 1 / (2 * np.pi) * w_omega * (np.log(np.linalg.det(np.eye(naux) - Pi_dRPA_matrix )) + Pi_dRPA_matrix.trace() )
        eng_ssRPA  += 1 / (2 * np.pi) * w_omega * (np.log(np.linalg.det(np.eye(naux) - Pi_alpha_matrix)) + Pi_alpha_matrix.trace())
        eng_ssRPA  += 1 / (2 * np.pi) * w_omega * (np.log(np.linalg.det(np.eye(naux) - Pi_beta_matrix )) + Pi_beta_matrix.trace() )
        eng_osRPA1 += 1 / (2 * np.pi) * w_omega * (Pi_alpha_matrix.trace() + Pi_beta_matrix.trace())
        W_alpha_matrix, W_beta_matrix = np.zeros((naux, naux)), np.zeros((naux, naux))
        for lambd, w_lambd in zip(*gen_leggauss_0_1(10)):
            W_alpha_matrix += w_lambd * np.linalg.inv(np.eye(naux) - lambd * Pi_alpha_matrix)
            W_beta_matrix  += w_lambd * np.linalg.inv(np.eye(naux) - lambd * Pi_beta_matrix)
        eng_osRPA1 += - 1 / (2 * np.pi) * w_omega * ((W_alpha_matrix @ Pi_beta_matrix).trace())
        eng_osRPA1 += - 1 / (2 * np.pi) * w_omega * ((W_beta_matrix @ Pi_alpha_matrix).trace())
    eng_osRPA  = eng_dRPA  - eng_ssRPA
    eng_scsRPA = 9/20 * eng_osRPA1 + 3/4 * eng_dRPA
    return (
        eng_PBE0,
        eng_HXX + eng_osRPA,
        eng_HXX + eng_dRPA,
        eng_HXX + eng_osRPA1,
        eng_HXX + eng_scsRPA,
    )
def get_RscsRPA(mol, dfbasis="cc-pVTZ-ri"):
    # molecule preparation
    mf = dft.RKS(mol, xc="PBE0").run()
    eng_PBE0 = mf.e_tot
    mol_df = mol.copy()
    mol_df.basis = dfbasis
    mol_df.build()
    # Basic information preparation
    nocc, nmo, nao = mol.nelec[0], mol.nao, mol.nao
    naux = mol_df.nao
    nvir = nmo - nocc
    dim_ov = nocc * nvir
    so, sv = slice(0, nocc), slice(nocc, nmo)
    eri0_ao = mol.intor("int2e")
    e, C = mf.mo_energy, mf.mo_coeff
    eo, ev = e[so], e[sv]
    Co, Cv = C[:, so], C[:, sv]
    # eng_HXX calculation
    ni = dft.numint.NumInt()
    eng_xc = ni.nr_rks(mol, mf.grids, mf.xc, mf.make_rdm1())[1]
    eng_exactX = - 0.25 * (mf.get_k(mf.make_rdm1()) * mf.make_rdm1()).sum()
    eng_HXX = mf.e_tot - eng_xc + (1 - ni.hybrid_coeff(mf.xc)) * eng_exactX
    # density fitting 
    int2c2e = mol_df.intor("int2c2e")
    int3c2e = df.incore.aux_e2(mol, mol_df)
    int2c2e_half = scipy.linalg.cholesky(int2c2e, lower=True)
    V_df_mp2 = scipy.linalg.solve_triangular(int2c2e_half, int3c2e.reshape(-1, naux).T, lower=True).reshape(naux, nao, nao).transpose((1, 2, 0))
    V_df_ia = np.einsum("uvP, ui, va -> iaP", V_df_mp2, Co, Cv)
    V = V_df_ia.reshape(dim_ov, naux)
    D = (- eo[:, None] + ev[None, :]).flatten()
    # dRPA Preparation
    Pi_alpha = lambda omega: - 2 * np.einsum("dP, d, dQ -> PQ", V, D / (D**2 + omega**2), V)
    # dRPA, osRPA1 correlation energy calculation
    eng_dRPA, eng_ssRPA = 0, 0
    for omega, w_omega in zip(*gen_leggauss_0_inf(40)):
        Pi_alpha_matrix = Pi_alpha(omega)
        Pi_dRPA_matrix = 2 * Pi_alpha_matrix
        eng_dRPA   += 1 / (2 * np.pi) * w_omega * (np.log(np.linalg.det(np.eye(naux) - Pi_dRPA_matrix )) + Pi_dRPA_matrix.trace() )
        eng_ssRPA  += 1 / (2 * np.pi) * w_omega * (np.log(np.linalg.det(np.eye(naux) - Pi_alpha_matrix)) + Pi_alpha_matrix.trace()) * 2
    eng_osRPA1 = eng_ssRPA
    eng_osRPA  = eng_dRPA - eng_ssRPA
    eng_scsRPA = 9/20 * eng_osRPA1 + 3/4 * eng_dRPA
    return (
        eng_PBE0,
        eng_HXX + eng_osRPA,
        eng_HXX + eng_dRPA,
        eng_HXX + eng_osRPA1,
        eng_HXX + eng_scsRPA,
    )

下述程序是计算处于解离态的氮原子能量。列表中的数值有 5 项,分别为 PBE0, osRPA, dRPA, osRPA1, scsRPA 的结果。

def get_result_N():
    mol = gto.M(atom="N 0 0 0", basis="cc-pVTZ", spin=3, verbose=0)
    return get_UscsRPA(mol)

result_N = np.array(get_result_N())

下述程序是计算处于解离态的氮气能量。列表中的数值亦有 5 项。

def get_result_N2(bond_length):
    mol = gto.M(atom="N 0 0 0; N 0 0 {:16.8f}".format(bond_length), basis="cc-pVTZ", verbose=0)
    return get_RscsRPA(mol)

bond_length_list = np.logspace(np.log10(0.8), np.log10(4.0), 30)
results_N2 = np.array([get_result_N2(length) for length in bond_length_list])

最后我们就可以对计算所得到的能量作图。

x_list = bond_length_list
y_list = (results_N2 - 2 * result_N).T * 627.5
fig, ax = plt.subplots(figsize=(6, 4))
ax.plot(x_list, y_list[0], c="black", linestyle="-.", label="PBE0")
ax.plot(x_list, y_list[1], c="C2", linestyle="-.", label="osRPA")
ax.plot(x_list, y_list[2], c="C0", linestyle="--", label="dRPA")
ax.plot(x_list, y_list[3], c="C1", linestyle="-", label="osRPA1")
ax.plot(x_list, y_list[4], c="C6", linestyle="-.", label="scsRPA")
ax.plot(x_list, np.zeros_like(x_list), c="black", linewidth=0.5)
ax.set_ylim(-300, 400)
ax.legend()
ax.set_xlabel("Bond Length (Angstrom)")
ax.set_ylabel("Relative energy curves (kcal/mol)")
ax.set_title("$\mathsf{N_2}$ dissociation curve without breaking spin symmetry")
fig.tight_layout()

由于 CCSD 与 CASSCF 的收敛情况似乎容易存在问题,因此这里仅仅计算了这五条曲线。


轨道旋转 MP2 方法 (OO-MP2) 简单理解#

创建日期:2021-01-09

这篇文档会尝试简单介绍轨道旋转 MP2 方法 (Orbital-Optimized Second-Order Møller–Plesset Perturbation, OO-MP2 or OMP2) 的基础概念与 PySCF 上的程序实现和理解。

这篇文档的编写并没有翻阅很多文献,并作测评上的认识。为数不多的文献与参考资料是

Sun, Chan, et al. [1] (PySCF 进展文章)

PySCF 并没有一个完整或独立的 OO-MP2 模块。实现 OO-MP2 可以通过仿 CASSCF 的方式实现。之后使用到的 MP2AsFCISolver class 就是直接从该文章中截取的演示代码。

Psi4NumPy 演示文档 10a_orbital-optimized-mp2.ipynb

这是一份比较不错的基于 Psi4 的程序简要文档,使用的算法与技巧也不复杂。

需要指出,这里的 OO-MP2 程序实现完全是基于 Post-HF 的闭壳层、无冻结轨道 MP2 实现的。更复杂的开壳层、冻结轨道、双杂化泛函方法,都不予以考虑。

import numpy as np
import scipy
from pyscf import gto, mcscf, fci, mp, scf
from functools import partial

np.random.seed(0)
np.einsum = partial(np.einsum, optimize=True)
np.set_printoptions(precision=4, linewidth=150, suppress=True)

这篇文档的程序理解部分,我们都会使用下述水分子。但文档末尾,我们会用氢分子的例子,说明 OO-MP2 的能量未必要比 MP2 能量要低。

mol = gto.Mole()
mol.atom = """
O  0. 0. 0.
H  0. 0. 1.
H  0. 1. 0.
"""
mol.basis = "6-31G"
mol.verbose = 0
mol.build()
<pyscf.gto.mole.Mole at 0x7efdd0fbc8b0>

PySCF 程序实现:高效方式#

这段程序 MP2AsFCISolver class 是直接从 Sun 的 JCP 文章截取的。通过在 CASSCF 中,将活性空间更改为全轨道、更改约化密度矩阵 (1-RDM, 2-RDM) 的生成方式为 MP2 的约化密度矩阵、并且允许活性空间的轨道旋转,就可以实现 OO-MP2。

class MP2AsFCISolver:
    def kernel(self, h1, h2, norb, nelec, ci0=None, ecore=0, **kwargs):
        # Kernel takes the set of integrals from the current set of orbitals
        fakemol = gto.M(verbose=0)
        fakemol.nelectron = sum(nelec)
        fake_hf = fakemol.RHF()
        fake_hf._eri = h2
        fake_hf.get_hcore = lambda *args: h1
        fake_hf.get_ovlp = lambda *args: np.eye(norb)
        
        # Build an SCF object fake_hf without SCF iterations to perform MP2
        fake_hf.mo_coeff = np.eye(norb)
        fake_hf.mo_occ = np.zeros(norb)
        fake_hf.mo_occ[:fakemol.nelectron//2] = 2
        self.mp2 = fake_hf.MP2().run()
        return self.mp2.e_tot + ecore, self.mp2.t2
    
    def make_rdm12(self, t2, norb, nelec):
        dm1 = self.mp2.make_rdm1(t2)
        dm2 = self.mp2.make_rdm2(t2)
        return dm1, dm2

mf_rhf 为 RHF 实例:

mf_rhf = mol.RHF().run()
mf_rhf.e_tot
-75.9697009626036

mf_mp2 为 MP2 实例:

mf_mp2 = mp.MP2(mf_rhf).run()
mf_mp2.e_tot
-76.1040356515777
mf_mp2.e_corr
-0.13433468897410067

mf_cas 在这里是指 OO-MP2 实例:

mf_cas = mcscf.CASSCF(mf_rhf, mol.nao, mol.nelectron)
mf_cas.fcisolver = MP2AsFCISolver()
mf_cas.internal_rotation = True
cas_result = mf_cas.kernel()
cas_result[0]
-76.10510419427318

PySCF 程序实现:大体思路拆解#

在这一段中,我们不会使用 PySCF 的 CASSCF class,而是从 RHF 与 MP2 的结果,了解 OO-MP2 的大体思路。

从结果上,这种实现方式与 PySCF 会相同。但 PySCF 的 CASSCF class 一般会使用二阶方法 (即使用 Orbital Hessian) 加速收敛,而我们这里只使用一阶梯度下降方法 (Orbital Gradient) 进行收敛;一阶收敛方法显然会慢一些,但公式与程序会简单一些。

首先对一些基础变量作声明:

  • nocc \(n_\mathrm{occ}\) 占据轨道数,nvir \(n_\mathrm{vir}\) 非占轨道数;

  • nmo \(n_\mathrm{MO}\) 分子轨道数,一般与原子轨道数相等;

  • so \([0:n_\mathrm{occ}]\) 占据轨道分割 (slice),sv \([n_\mathrm{occ}:n_\mathrm{MO}]\) 非占轨道分割 (slice);

  • mo_occ PySCF 中用于表示轨道占据数的变量。

nocc, nmo = mol.nelec[0], mol.nao
nvir = nmo - nocc
so, sv = slice(0, nocc), slice(nocc, nmo)
mo_occ = mf_rhf.mo_occ

OO-MP2 的大体过程可以拆分为如下循环:

  1. 代入分子轨道系数 \(C_{\mu i}\),得到该系数下 MP2 的激发张量 \(t_{ij}^{ab}\)

  2. 进而生成该情形下的 1-RDM \(\gamma_{pq}\) 与 2-RDM \(\Gamma_{pr}^{qs}\)

  3. 进而生成广义 Fock 矩阵 \(F_{pq}\) 与轨道梯度 \(x_{pq} = F_{pq} - F_{qp}\)

  4. 最后更新分子轨道系数 \(C_{\mu i}\)

最终的收敛条件判据是 \(F_{pq} - F_{qp} = 0\),即广义 Fock 矩阵 \(F_{pq}\) 为对称矩阵。

def oomp2_cycle(C):
    # Generate Psuedo objects, and therefore t_iajb
    mf_prhf = scf.RHF(mol)
    mf_prhf.mo_occ, mf_prhf.mo_coeff = mo_occ, C
    mf_pmp2 = mp.MP2(mf_prhf).run()                                                         # Step 1
    # Generate 1-RDM, 2-RDM and orbital gradient from generalized Fock matrix
    rdm1, rdm2 = mf_pmp2.make_rdm1(), mf_pmp2.make_rdm2()                                   # Step 2
    gfock_grad = mf_cas.unpack_uniq_var(mf_cas.get_grad(C, (rdm1, rdm2), mf_cas.ao2mo(C)))  # Step 3
    # Returned value: Updated MO Coefficient; OO-MP2 Energy (in current cycle); orbital gradient error
    return update_C(C, gfock_grad), mf_pmp2.e_tot, np.linalg.norm(gfock_grad)               # Step 4

而更新轨道系数是通过下述过程实现:

\[\begin{split} \begin{gather} X_{ai} = - X_{ia} = \frac{x_{ai}}{- \varepsilon_a + \varepsilon_i} = \frac{F_{ai} - F_{ia}}{- \varepsilon_a + \varepsilon_i} \\ X_{ij} = 0, \; = X_{ab} = 0 \\ \mathbf{C} \leftarrow \mathbf{C} \exp(\lambda \mathbf{X}) \end{gather} \end{split}\]

其中 \(\lambda\) 是梯度下降率,对应于机器学习,它与梯度下降算法的学习率是类似的。这里取为 \(\lambda = 0.5\)

def update_C(C, gfock_grad):
    # Generate anti-symmetrized rotation matrix
    D = mf_rhf.make_rdm1(C, mo_occ)
    e = (C.T @ mf_rhf.get_fock(dm=D) @ C).diagonal()
    X = np.zeros_like(C)
    X[sv, so] = gfock_grad[sv, so] / (- e[sv, None] + e[None, so])
    X[so, sv] = gfock_grad[so, sv] / (- e[None, sv] + e[so, None])
    # Control rotation by factor
    X *= 0.5
    # Generate rotated MO coefficient
    C_new = C @ scipy.linalg.expm(X)
    return C_new

如果将 RHF 的分子轨道系数 mf_rhf.mo_coeff 作为分子轨道系数的初猜,那么收敛过程可以用下述迭代代码给出:

C_oo = np.copy(mf_rhf.mo_coeff)
print("Cycle | OO-MP2 Energy | G-Fock Gradient Norm")
for i in range(15):
    C_oo, eng, err = oomp2_cycle(C_oo)
    print("{:5d} | {:<13.8f} |  {:<16.8e}".format(i, eng, err))
Cycle | OO-MP2 Energy | G-Fock Gradient Norm
    0 | -76.10403565  |  7.90255467e-02  
    1 | -76.10503066  |  2.20049872e-02  
    2 | -76.10509490  |  7.36831750e-03  
    3 | -76.10510186  |  4.69833400e-03  
    4 | -76.10510336  |  2.78455388e-03  
    5 | -76.10510386  |  1.90302779e-03  
    6 | -76.10510405  |  1.22381869e-03  
    7 | -76.10510413  |  8.22563315e-04  
    8 | -76.10510417  |  5.38178673e-04  
    9 | -76.10510418  |  3.59079689e-04  
   10 | -76.10510419  |  2.36428274e-04  
   11 | -76.10510419  |  1.57182522e-04  
   12 | -76.10510419  |  1.03794108e-04  
   13 | -76.10510419  |  6.88759621e-05  
   14 | -76.10510419  |  4.55466994e-05  

记号区别

在这份文档中,RHF 的 Fock 矩阵记号定义为 \(f_{pq}\),而 Post-HF 方法的 Fock 矩阵记号定义为 \(F_{pq}\)。这两者并非相同,并且非轨道优化的方法下,广义 Fock 矩阵 \(F_{pq}\) 矩阵一般是非对称的。

PySCF 程序实现:理解与分解#

我们会对上面程序中的重要步骤进行说明。

原子轨道电子积分定义#
  • h \(h_{\mu \nu}\),维度 \((\mu, \nu)\),原子轨道基的 Hamiltonian Core 矩阵,即动能与原子核-电子势能积分;

  • S \(S_{\mu \nu}\),维度 \((\mu, \nu)\),原子轨道基的重叠积分;

  • eri \((\mu \nu | \kappa \lambda)\),维度 \((\mu, \nu, \kappa, \lambda)\),原子轨道基的双电子积分。

h = mol.intor("int1e_kin") + mol.intor("int1e_nuc")
S = mol.intor("int1e_ovlp")
eri = mol.intor("int2e")
Canonical MP2#

我们先简单回顾一下在 Canonical RHF 下,MP2 的激发系数 \(t_{ij}^{ab}\) 与能量 \(E_\mathrm{corr}^\mathsf{MP2}\) 的导出方式。我们留意到 PySCF 的自洽场过程给出的是 Canonical 情况,即分子轨道的 Fock 矩阵 \(f_{pq}\) 是对角矩阵。

  • C \(C_{\mu p}\) 为分子轨道系数,e \(e_p\) 为轨道能量;

  • D_iajb \(D_{ij}^{ab}\) MP2 分母项,维度 \((i, a, j, b)\)

    \[ D_{ij}^{ab} = \varepsilon_i - \varepsilon_a + \varepsilon_j - \varepsilon_b \]
  • eri_mo \((pq|rs)\) 分子轨道基下的双电子积分,维度 \((p, q, r, s)\)

    \[ (pq|rs) = C_{\mu p} C_{\nu q} (\mu \nu | \kappa \lambda) C_{\kappa r} C_{\lambda s} \]
  • t_iajb \(t_{ij}^{ab}\) MP2 激发系数:

    \[ t_{ij}^{ab} = \frac{(ia|jb)}{D_{ij}^{ab}} \]
C, e = mf_rhf.mo_coeff, mf_rhf.mo_energy
D_iajb = e[so, None, None, None] - e[None, sv, None, None] + e[None, None, so, None] - e[None, None, None, sv]
eri_mo = np.einsum("up, vq, uvkl, kr, ls -> pqrs", C, C, eri, C, C)
t_iajb = eri_mo[so, sv, so, sv] / D_iajb

因此,MP2 相关能可以写为 (参考值为 -0.134335 a.u.)

\[ E_\mathrm{corr}^\mathsf{MP2} = \big( 2 t_{ij}^{ab} - t_{ij}^{ba} \big) (ia|jb) \]
((2 * t_iajb - t_iajb.swapaxes(-1, -3)) * eri_mo[so, sv, so, sv]).sum()
-0.13433468897410067
Non-Canonical MP2:PySCF 程序#

但对于 OO-MP2 而言,由于产生了轨道旋转,我们需要考察 Non-Canonical RHF 的 MP2。

Non-Canonical 意指 RHF 的 Fock 矩阵 \(f_{pq}\) 是分块对角化的,即占据-非占和非占-占据分块 \(f_{ia}\)\(f_{ai}\) 均为零;而占据和非占分块 \(f_{ij}\)\(f_{ab}\) 的矩阵并非是对角化的。

为了构造这样一个 Non-Canonical RHF 的情形,我们可以对 Canonical RHF 分子轨道系数矩阵 C_rhf 作下述变换,得到 Non-Canonical RHF 分子轨道系数矩阵 C_rot

\[ \mathbf{C} \leftarrow \mathbf{C} \mathbf{U} \]

上述的 \(\mathbf{U}\) 矩阵是分块对角化的正交矩阵。为了构造这样的正交矩阵,我们可以生成一个分块对角化、且反对称的 X \(\mathbf{X}\) 矩阵,并令 \(\mathbf{U} = \exp(\mathbf{X})\)

C_rhf = mf_rhf.mo_coeff
X = np.random.randn(nmo, nmo)
X[sv, so] = X[so, sv] = 0
X -= X.T
X *= 0.02
C_rot = C_rhf @ scipy.linalg.expm(X)

由此构建出的 Non-Canonical 分子轨道 Fock 矩阵 \(f_{pq}\) 是分块对角化的,即不一定要求 \(f_{ij} = \delta_{ij} \varepsilon_i\)\(f_{ab} = \delta_{ab} \varepsilon_a\)

fock_rot = np.einsum("up, uv, vq -> pq", C_rot, mf_rhf.get_fock(), C_rot)
fock_rot
array([[-20.4748,  -0.0683,  -0.3396,  -1.0108,  -0.9682,  -0.    ,  -0.    ,  -0.    ,   0.    ,   0.    ,  -0.    ,  -0.    ,   0.    ],
       [ -0.0683,  -1.3485,  -0.0071,  -0.0425,  -0.0204,   0.    ,  -0.    ,   0.    ,   0.    ,   0.    ,   0.    ,   0.    ,  -0.    ],
       [ -0.3396,  -0.0071,  -0.6668,  -0.0222,  -0.0172,  -0.    ,   0.    ,   0.    ,   0.    ,  -0.    ,  -0.    ,   0.    ,  -0.    ],
       [ -1.0108,  -0.0425,  -0.0222,  -0.6362,  -0.0523,  -0.    ,   0.    ,  -0.    ,   0.    ,   0.    ,  -0.    ,   0.    ,   0.    ],
       [ -0.9682,  -0.0204,  -0.0172,  -0.0523,  -0.5539,  -0.    ,  -0.    ,  -0.    ,  -0.    ,  -0.    ,   0.    ,   0.    ,  -0.    ],
       [ -0.    ,   0.    ,  -0.    ,  -0.    ,  -0.    ,   0.1967,   0.0008,  -0.0182,   0.0501,  -0.002 ,   0.0269,  -0.0106,   0.0776],
       [ -0.    ,  -0.    ,   0.    ,   0.    ,  -0.    ,   0.0008,   0.2872,  -0.0032,   0.011 ,   0.0263,   0.0326,  -0.0324,   0.0407],
       [ -0.    ,   0.    ,   0.    ,  -0.    ,  -0.    ,  -0.0182,  -0.0032,   0.9833,   0.0038,  -0.0097,   0.0066,   0.0088,  -0.0122],
       [  0.    ,   0.    ,   0.    ,   0.    ,  -0.    ,   0.0501,   0.011 ,   0.0038,   1.1572,  -0.0006,  -0.0001,   0.0048,  -0.0293],
       [  0.    ,   0.    ,  -0.    ,   0.    ,  -0.    ,  -0.002 ,   0.0263,  -0.0097,  -0.0006,   1.1616,  -0.0056,  -0.0042,   0.0036],
       [ -0.    ,   0.    ,  -0.    ,  -0.    ,   0.    ,   0.0269,   0.0326,   0.0066,  -0.0001,  -0.0056,   1.2463,  -0.0016,  -0.0138],
       [ -0.    ,   0.    ,   0.    ,   0.    ,   0.    ,  -0.0106,  -0.0324,   0.0088,   0.0048,  -0.0042,  -0.0016,   1.3518,  -0.0047],
       [  0.    ,  -0.    ,  -0.    ,   0.    ,  -0.    ,   0.0776,   0.0407,  -0.0122,  -0.0293,   0.0036,  -0.0138,  -0.0047,   1.7381]])

对于这样的分子轨道系数矩阵 C_rot,PySCF 的程序照样可以给出正确的 MP2 相关能量 -0.134335 a.u. (其中 mf_prhf 是指虚假的 (Pseudo) RHF 实例):

mf_prhf = scf.RHF(mol)
mf_prhf.mo_occ, mf_prhf.mo_coeff = mo_occ, C_rot
mf_pmp2 = mp.MP2(mf_prhf).run()
mf_pmp2.e_corr
-0.13433467530628806
Non-Canonical MP2:激发系数 \(t_{ij}^{ab}\) 迭代更新方式#

首先为程序与公式说明,定义一些变量:

  • RHF Fock 对角线占据部分记为 eo \(\varepsilon_i = f_{ii}\)

  • RHF Fock 对角线非占部分记为 ev \(\varepsilon_a = f_{aa}\)

  • RHF Fock 去除对角线的占据分块记为 fock_oo \(f'_{ij} = (1 - \delta_{ij}) f_{ij}\)

  • RHF Fock 去除对角线的非占分块记为 fock_vv \(f'_{ab} = (1 - \delta_{ab}) f_{ab}\)

  • 双电子积分 eri_mo \((pq|rs)\)

  • 只包含占据-非占分块的双电子积分 eri_iajb \((ia|jb)\)

  • MP2 分母 D_iajb \(D_{ij}^{ab}\)

eo, ev = fock_rot.diagonal()[so], fock_rot.diagonal()[sv]
fock_oo, fock_vv = fock_rot[so, so], fock_rot[sv, sv]
fock_oop, fock_vvp = fock_oo - np.diag(eo), fock_vv - np.diag(ev)
eri_mo = np.einsum("up, vq, uvkl, kr, ls -> pqrs", C_rot, C_rot, eri, C_rot, C_rot)
eri_iajb = eri_mo[so, sv, so, sv]
D_iajb = eo[:, None, None, None] - ev[None, :, None, None] + eo[None, None, :, None] - ev[None, None, None, :]

小心

变量重新定义

上面代码块中 eo, ev, eri_mo, D_iajb 就在 Non-Canonical 的系数矩阵 C_rot 下给出;但我们曾经也在 Canonical 系数矩阵下给出过类似的变量。

由于我们会经常切换各种系数矩阵的旋转方式 (非旋转、Non-Canonical、Non-HF),因此一些变量也会被复用与复写,也暂不区分旋转后与旋转前的分子轨道角标。这可能会对阅读造成困惑。

依据不同的微扰理论定义方式,Non-Canonical RHF 的 MP2 相关能可能与 Canonical RHF 的 MP2 相关能不同。因此这里采用两个相关能相同的定义。此时激发系数 \(t_{ij}^{ab}\) 应当满足

\[ (ia|jb) = t_{kj}^{ab} f_{ki} + t_{ik}^{ab} f_{kj} - t_{ij}^{cb} f_{ca} - t_{ij}^{ac} f_{cb} \]

上式是对等式右的 \(k\) 进行求和的。如果现在用 \(f_{ij} = f'_{ij} + \delta_{ij} \varepsilon_i\)\(f_{ab} = f'_{ab} + \delta_{ab} \varepsilon_a\) 展开,那么上式可以写为

\[ (ia|jb) = t_{ij}^{ab} D_{ij}^{ab} + t_{kj}^{ab} f'_{ki} + t_{ik}^{ab} f'_{kj} - t_{ij}^{cb} f'_{ca} - t_{ij}^{ac} f'_{cb} \]

整理上式,就可以得到迭代关系

\[ t_{ij}^{ab} \leftarrow \frac{(ia|jb) - t_{kj}^{ab} f'_{ki} - t_{ik}^{ab} f'_{kj} + t_{ij}^{cb} f'_{ca} + t_{ij}^{ac} f'_{cb}}{D_{ij}^{ab}} \]

一般来说,如果轨道的旋转并不是很剧烈,那么 \(f'_{ij}\), \(f'_{ab}\) 两者的贡献较小,因此 \(t_{ij}^{ab} \simeq (ia|jb) / D_{ij}^{ab}\) 会是一个比较好的近似。

在此情形下,Non-Canonical MP2 的能量计算方式如下:

\[ E_\mathrm{corr}^\mathsf{MP2} = \big( 2 t_{ij}^{ab} - t_{ij}^{ba} \big) (ia|jb) \]

下面的程序就是实现 Non-Canonical MP2 的流程。

  • update_t_iajb 即使用迭代关系,更新 \(t_{ij}^{ab}\)

  • calculate_noncan_mp2 即计算 Non-Canonical MP2 相关能的函数。

def update_t_iajb(t_iajb):
    t_iajb_new = np.zeros_like(t_iajb)
    t_iajb_new += np.einsum("icjb, ca -> iajb", t_iajb, fock_vvp)
    t_iajb_new += np.einsum("iajc, cb -> iajb", t_iajb, fock_vvp)
    t_iajb_new -= np.einsum("iakb, kj -> iajb", t_iajb, fock_oop)
    t_iajb_new -= np.einsum("kajb, ki -> iajb", t_iajb, fock_oop)
    t_iajb_new += eri_iajb
    t_iajb_new /= D_iajb
    return t_iajb_new
def calculate_noncan_mp2(t_iajb):
    return ((2 * t_iajb - t_iajb.swapaxes(-1, -3)) * eri_iajb).sum()

随后声明初猜 \(t_{ij}^{ab} \simeq (ia|jb) / D_{ij}^{ab}\) 并以此迭代更新;并以 Canonical MP2 的相关能加以验证。在 5 次循环后,几乎收敛到了正确能量。

t_iajb = eri_mo[so, sv, so, sv] / D_iajb
for i in range(10):
    print("Error: {:16.8e}".format(calculate_noncan_mp2(t_iajb) - mf_mp2.e_corr))
    t_iajb = update_t_iajb(t_iajb)
Error:   3.41981239e-03
Error:   2.09994114e-03
Error:   9.08474334e-05
Error:   9.06169148e-05
Error:   3.54576068e-06
Error:   5.43397725e-06
Error:   6.95752296e-08
Error:   4.42378672e-07
Error:  -2.06561550e-08
Error:   4.46581002e-08

事实上,在 PySCF 中,包含占据-非占轨道旋转的 Non-RHF 下的 MP2 方法,也是通过上述过程进行计算的。

MP2 1-RDM#

对于一阶约化密度 1-RDM \(\gamma_{pq}\),其需要通过分块的方式生成:

\[\begin{split} \begin{align} \gamma_{ij}^\mathsf{RHF} &= 2 \delta_{ij} \\ \gamma_{ab}^\mathsf{RHF} &= \gamma_{ia}^\mathsf{RHF} = \gamma_{ai}^\mathsf{RHF} = 0 \\ \gamma_{ij}^\mathrm{corr} &= - 4 t_{ik}^{ab} t_{jk}^{ab} + 2 t_{ik}^{ba} t_{jk}^{ab} \\ \gamma_{ab}^\mathrm{corr} &= 4 t_{ij}^{ac} t_{ij}^{bc} - 2 t_{ij}^{ca} t_{ij}^{bc} \\ \gamma_{ia}^\mathrm{corr} &= \gamma_{ai}^\mathrm{corr} = 0 \\ \gamma_{pq} &= \gamma_{pq}^\mathsf{RHF} + \gamma_{pq}^\mathrm{corr} \end{align} \end{split}\]

这种生成方式无关乎方法是否是 Canonical 的。

首先生成 RHF 的 1-RDM rdm1_rhf \(\gamma_{pq}^\mathsf{RHF}\)

rdm1_rhf = np.zeros((nmo, nmo))
np.fill_diagonal(rdm1_rhf[so, so], 2)

随后给出 MP2 相关部分所给出的 1-RDM rdm1_corr \(\gamma_{pq}^\mathrm{corr}\)

rdm1_corr = np.zeros((nmo, nmo))
rdm1_corr[so, so] = - 4 * np.einsum("iakb, jakb -> ij", t_iajb, t_iajb) + 2 * np.einsum("ibka, jakb -> ij", t_iajb, t_iajb)
rdm1_corr[sv, sv] = 4 * np.einsum("iajc, ibjc -> ab", t_iajb, t_iajb) - 2 * np.einsum("icja, ibjc -> ab", t_iajb, t_iajb)

总 1-RDM rdm1 \(\gamma_{pq}\) 可以通过简单相加获得:

rdm1 = rdm1_rhf + rdm1_corr
np.allclose(rdm1, mf_pmp2.make_rdm1())
True
MP2 2-RDM#

对于二阶约化密度 2-RDM rdm2 \(\Gamma_{pr}^{qs}\) (维度 \((p, q, r, s)\)),其也需要通过分块生成。首先生成 \(\Gamma_{ia}^{jb}\), \(\Gamma_{ai}^{bj}\), \(\Gamma_{ik}^{jl}\), \(\Gamma_{ac}^{bd}\) 部分:

\[ \Gamma_{pr}^{qs} = \left( \gamma_{pq} \gamma_{rs} - \frac{1}{2} \gamma_{ps} \gamma_{rq} \right) - \left( \gamma_{pq}^\mathrm{corr} \gamma_{rs}^\mathrm{corr} - \frac{1}{2} \gamma_{ps}^\mathrm{corr} \gamma_{rq}^\mathrm{corr} \right) \]

其余的部分是 \(\Gamma_{ij}^{ab}\)\(\Gamma_{ab}^{ij}\)

\[ \Gamma_{ij}^{ab} = \Gamma_{ab}^{ij} = 4 t_{ij}^{ab} - 2 t_{ij}^{ba} \]
rdm2 = np.zeros((nmo, nmo, nmo, nmo))
rdm2 = np.einsum("pq, rs -> pqrs", rdm1, rdm1) - 0.5 * np.einsum("ps, rq -> pqrs", rdm1, rdm1)
rdm2 -= np.einsum("pq, rs -> pqrs", rdm1_corr, rdm1_corr) - 0.5 * np.einsum("ps, rq -> pqrs", rdm1_corr, rdm1_corr)
rdm2[so, sv, so, sv] = 4 * np.einsum("iajb -> iajb", t_iajb) - 2 * np.einsum("ibja -> iajb", t_iajb)
rdm2[sv, so, sv, so] = 4 * np.einsum("iajb -> aibj", t_iajb) - 2 * np.einsum("ibja -> aibj", t_iajb)
np.allclose(rdm2, mf_pmp2.make_rdm2(), atol=1e-7)
False

由此,我们可以通过 1-RDM \(\gamma_{pq}\) 与 2-RDM \(\Gamma_{pr}^{qs}\) 验证 MP2 总能量 -76.104036 a.u.:

\[ E_\mathrm{tot}^\mathsf{MP2} = h_{pq} \gamma_{pq} + \frac{1}{2} (pq|rs) \Gamma_{pr}^{qs} + E_\mathrm{nuc} \]

但这里的单电子积分 \(h_{pq}\) 与双电子积分 \((pq|rs)\) 都是在旋转过后的系数轨道矩阵 C_rot \(\mathbf{C}\) 为基给出,因此需要重新生成一下。

h_mo = np.einsum("up, uv, vq -> pq", C_rot, h, C_rot)
eri_mo = np.einsum("up, vq, uvkl, kr, ls -> pqrs", C_rot, C_rot, eri, C_rot, C_rot)
(
    + np.einsum("pq, pq ->", h_mo, rdm1)
    + 0.5 * np.einsum("pqrs, pqrs ->", eri_mo, rdm2)
    + mol.energy_nuc()
)
-76.10403565383504
生成广义 Fock 矩阵#

广义 Fock 矩阵 gfock \(F_{pq}\) 区别于 RHF 的 Fock 矩阵 \(f_{pq}\)。其定义为

\[ F_{pq} = h_{pm} \gamma_{mq} + (pm|rs) \Gamma_{mr}^{qs} \]
gfock = np.einsum("pr, rq -> pq", h_mo, rdm1) + np.einsum("pmrs, mqrs -> pq", eri_mo, rdm2)

事实上,RHF 的 Fock 矩阵中,占据轨道部分也可以用类似的方法定义:

\[\begin{split} \begin{align} 2 f_{ij} &= h_{im} \gamma_{mj}^\mathsf{RHF} + (im|rs) \Gamma_{mr}^{js, \mathsf{RHF}} \\ \Gamma_{pr}^{qs, \mathsf{RHF}} &= \gamma_{pq}^\mathsf{RHF} \gamma_{rs}^\mathsf{RHF} - \frac{1}{2} \gamma_{ps}^\mathsf{RHF} \gamma_{rq}^\mathsf{RHF} \end{align} \end{split}\]
rdm2_rhf = np.einsum("pq, rs -> pqrs", rdm1_rhf, rdm1_rhf) - 0.5 * np.einsum("ps, rq -> pqrs", rdm1_rhf, rdm1_rhf)
np.allclose(
    (np.einsum("pr, rq -> pq", h_mo, rdm1_rhf) + np.einsum("pmrs, mqrs -> pq", eri_mo, rdm2_rhf))[so, so],
    2 * fock_rot[so, so],
)
True

但在 PySCF 的 CASSCF 模块中,似乎没有直接生成广义 Fock 矩阵的方式。但其有广义 Fock 的导数量,被称为轨道梯度 (Orbital Gradient) gfock_grad \(x_{pq}\)

\[ x_{pq} = F_{pq} - F_{qp} \]
gfock_grad = gfock - gfock.T
np.allclose(
    mf_cas.unpack_uniq_var(mf_cas.get_grad(C_rot, (rdm1, rdm2), mf_cas.ao2mo(C_rot))),
    gfock_grad
)
True

至此,所有生成 OO-MP2 所需要的单步复杂计算都已经涵盖到了。

轨道旋转的意义#

讨论到现在,我们仅仅知道了 OO-MP2 的程序实现是如何进行的;但对其根源的合理性问题,我们在这里才开始说明。

出于一般性,我们现在考虑 Non-HF 形式的轨道系数,即相对于 RHF 系数已经一定程度的旋转。该 Non-HF 轨道系数称为 C_base \(C_{\mu p}^\mathrm{base}\)。我们之后的讨论都基于该 Non-HF 轨道系数开始。

X = np.random.randn(nmo, nmo)
X = (X - X.T) * 0.02
C_base = C_rhf @ scipy.linalg.expm(X)

首先需要说明,轨道的旋转矩阵必须是正交矩阵 (酉矩阵)。这是因为轨道系数必须满足

\[ \mathbf{C}^\dagger \mathbf{S} \mathbf{C} = \mathbf{I} \]

旋转矩阵 \(\mathbf{U}\) 通过下式定义:\(\mathbf{C} = \mathbf{C}^\mathrm{base} \mathbf{U}\)。因此,

\[ \mathbf{C}^\dagger \mathbf{S} \mathbf{C} = \mathbf{U}^\dagger \mathbf{C}^\dagger \mathbf{S} \mathbf{C} \mathbf{U} = \mathbf{U}^\dagger \mathbf{I} \mathbf{U} = \mathbf{U}^\dagger \mathbf{U} = \mathbf{I} \]

而任何正交矩阵都可以通过反对称矩阵 \(\mathbf{X} = \mathbf{X}^\dagger\) 的幂次给出 \(\mathbf{U} = \exp(\mathbf{X})\)

现在考察在微扰下,能量随轨道系数的变化情况。令一般情况下轨道系数 \(C_{\mu p}\) 为关于反对称矩阵 \(X_{pq}\) 的函数:

\[ \mathbf{C} = \mathbf{C}^\mathrm{base} \exp (\mathbf{X}) \]

\(C_{\mu p}\) 对应的 MP2 能量写作关于 \(X_{pq}\) 的函数 \(E_\mathrm{tot}^\mathsf{MP2} (\mathbf{X})\)。下面的代码 eng_mp2_pert 即是代入反对称矩阵 \(X_{pq}\),生成 MP2 能量的函数。

def eng_mp2_pert(X):
    C_rot = C_base @ scipy.linalg.expm(X)
    mf_prhf = scf.RHF(mol)
    mf_prhf.mo_occ, mf_prhf.mo_coeff = mo_occ, C_rot
    mf_pmp2 = mp.MP2(mf_prhf).run()
    return mf_pmp2.e_tot

由此,能量关于旋转矩阵的导数关系可以写为矩阵 dX \({\mathrm{d} \mathbf{X}}\),其维度为 \((p, q)\)

\[ {\mathrm{d}X}_{pq} = \frac{\partial E_\mathrm{tot}^\mathsf{MP2}}{\partial X_{pq}} \]

这种导数可以写成三点差分的数值微分的形式:

\[ {\mathrm{d}X}_{pq} \simeq \frac{E_\mathrm{tot}^\mathsf{MP2} (d_{pq}) - E_\mathrm{tot}^\mathsf{MP2} (- d_{pq})}{2 d_{pq}} \]

\(E_\mathrm{tot}^\mathsf{MP2} (d_{pq})\) 的意义是,反对称矩阵 \(\mathbf{X}\) 仅在第 \(p\) 行、第 \(q\) 列上,\(X_{pq} = d_{pq}\);且在第 \(q\) 行、第 \(p\) 列上,\(X_{qp} = - d_{pq}\);其它位置上,\(\mathbf{X}\) 均取到零值。如果 \(p = q\),那么 \(\mathbf{X} = \mathbf{0}\)。生成这种反对称矩阵的函数 gen_pert_X 如下所示:

def gen_pert_X(p, q, interval):
    X = np.zeros((nmo, nmo))
    X[p, q] = interval
    X -= X.T
    return X

那么依据上述反对称矩阵,所求出的 MP2 能量随 \(X_{pq}\) 变化的数值导数 \({\mathrm{d}X}_{pq}\) 的生成函数如下:

def eng_mp2_numdiff(p, q, interval):
    X_positive = gen_pert_X(p, q, interval)
    X_negative = gen_pert_X(p, q, -interval)
    return (eng_mp2_pert(X_positive) - eng_mp2_pert(X_negative)) / (2 * interval)

对角标 \(p, q\) 循环,我们就能求出完整的导数矩阵 dX \({\mathrm{d} \mathbf{X}}\) (这里选取的数值微分的间隙值 interval\(10^{-4}\) a.u.):

dX = np.zeros((nmo, nmo))
for a in range(nmo):
    for i in range(nmo):
        dX[a, i] = eng_mp2_numdiff(a, i, 1e-4)
dX
array([[ 0.    , -0.    , -0.    , -0.    ,  0.    ,  0.243 , -0.6191,  0.7465, -1.7459,  1.0327, -0.5983,  1.3028, -1.8584],
       [ 0.    ,  0.    , -0.    ,  0.    ,  0.    , -0.044 , -0.0219,  0.3123, -0.1693, -0.1755,  0.1931, -0.0509,  0.033 ],
       [ 0.    ,  0.    ,  0.    , -0.    ,  0.    , -0.0894,  0.0924,  0.2349,  0.1175,  0.0443, -0.3922,  0.1505, -0.4868],
       [ 0.    , -0.    ,  0.    ,  0.    , -0.    ,  0.0648, -0.0899, -0.0568, -0.0668, -0.137 ,  0.2291, -0.0017, -0.1029],
       [-0.    , -0.    , -0.    ,  0.    ,  0.    , -0.0091,  0.0252,  0.1021, -0.0093, -0.0796,  0.0327, -0.067 , -0.0761],
       [-0.243 ,  0.044 ,  0.0894, -0.0648,  0.0091,  0.    , -0.    ,  0.    , -0.    ,  0.    ,  0.    ,  0.    , -0.    ],
       [ 0.6191,  0.0219, -0.0924,  0.0899, -0.0252,  0.    ,  0.    ,  0.    , -0.    ,  0.    ,  0.    ,  0.    , -0.    ],
       [-0.7465, -0.3123, -0.2349,  0.0568, -0.1021, -0.    , -0.    ,  0.    ,  0.    ,  0.    ,  0.    ,  0.    , -0.    ],
       [ 1.7459,  0.1693, -0.1175,  0.0668,  0.0093,  0.    ,  0.    , -0.    ,  0.    ,  0.    ,  0.    ,  0.    , -0.    ],
       [-1.0327,  0.1755, -0.0443,  0.137 ,  0.0796, -0.    , -0.    , -0.    , -0.    ,  0.    , -0.    ,  0.    ,  0.    ],
       [ 0.5983, -0.1931,  0.3922, -0.2291, -0.0327, -0.    , -0.    , -0.    , -0.    ,  0.    ,  0.    , -0.    ,  0.    ],
       [-1.3028,  0.0509, -0.1505,  0.0017,  0.067 , -0.    , -0.    , -0.    , -0.    , -0.    ,  0.    ,  0.    , -0.    ],
       [ 1.8584, -0.033 ,  0.4868,  0.1029,  0.0761,  0.    ,  0.    ,  0.    ,  0.    , -0.    , -0.    ,  0.    ,  0.    ]])

注意到这是一个反对称且分块的矩阵;在占据与非占分块值完全为零,有值处仅有 \(\mathrm{d} X_{ai} = - \mathrm{d} X_{ia}\)。这实际上近乎等于 2 倍的轨道梯度矩阵 2 * gfock_grad

\[ \mathrm{d} X_{pq} = 2 x_{pq} = 2 (F_{pq} - F_{qp}) \]
mf_prhf = scf.RHF(mol)
mf_prhf.mo_occ, mf_prhf.mo_coeff = mo_occ, C_base
mf_pmp2 = mp.MP2(mf_prhf).run()
rdm1, rdm2 = mf_pmp2.make_rdm1(), mf_pmp2.make_rdm2()
gfock_grad = mf_cas.unpack_uniq_var(mf_cas.get_grad(C_base, (rdm1, rdm2), mf_cas.ao2mo(C_base)))
np.allclose(2 * gfock_grad, dX, atol=5e-6)
True

因此,可以说 OO-MP2 的意义是,找到一个合适的 \(\mathbf{C}^\mathrm{base}\),使得对于任意的很小的、用于旋转的反对称矩阵 \(\mathbf{X}\),有 \(E_\mathrm{tot}^\mathsf{MP2} (\mathbf{X})\) 不会更改。

OO-MP2 能量并非一定比 MP2 低#

在文档最后,我们会指出,OO-MP2 能量并非 MP2 的下界。尽管 OO-MP2 看起来对轨道进行变分式的优化,但其变分的对象应当认为是 Hylleraas 泛函,而非总 MP2 能量。

对于下述拉长的氢分子,就是一个 OO-MP2 能量比 MP2 能量高的例子。

mol = gto.Mole()
mol.atom = """
H  0. 0. 0.
H  0. 0. 15.
"""
mol.basis = "6-31G"
mol.verbose = 0
mol.build()
<pyscf.gto.mole.Mole at 0x7efd968f4310>

其 MP2 能量为

mol.RHF().run().MP2().run().e_tot
-1.7458592201255043

而其 OO-MP2 能量为

mf_cas = mcscf.CASSCF(mol.RHF().run(), mol.nao, mol.nelectron)
mf_cas.fcisolver = MP2AsFCISolver()
mf_cas.internal_rotation = True
cas_result = mf_cas.kernel()
cas_result[0]
-1.7280760742391805

但即使 OO-MP2 的能量比 MP2 高,它仍然无法解决 MP2 方法在解离两个氢原子所产生的相当大的解离误差。


简单实现 CEPA 方法#

创建时间:2021-09-04

本文档会简单地实现闭壳层 CEPA(\(n\)) 方法。

CEPA(\(n\)) 不太完整地说,是基于 CCSD 与 CISD 的近似。一般认为这类方法是严格的 CCSD 近似,对 MP2 甚至 MP3 有所提升;但依据推导方式不同,可以是 CISD 的近似,也可以是 CISD 的补充。CEPA(\(n\)) 方法的相对完善的综述可以参考 [1]

import numpy as np
from pyscf import gto, scf, ci, lib
from pyscf.cc import ccsd
from opt_einsum import contract as einsum

np.set_printoptions(6, suppress=True, linewidth=120)

CISD 能量#

准备工作#

我们以键长 0.96 Angstrom、键角 104.5° 的水分子作为研究对象。基组是 cc-pVDZ。

mol = gto.Mole(atom="O; H 1 0.96; H 1 0.96 2 104.5", basis="cc-pVDZ", verbose=0).build()
mf_scf = scf.RHF(mol).run()
mf_ci = ci.CISD(mf_scf).run()

我们首先考察 CISD 能量的计算。回顾到 CISD 计算方程组是

\[ \begin{align*} (\hat H - E_\textsf{HF} - E_\mathrm{c}^\textsf{CISD}) | \Psi \rangle = 0 \end{align*} \]

其中,我们规定 \(|\Psi\rangle\) 是半归一化 (Intermediate Normalized) 的波函数:

\[ |\Psi\rangle = |\Phi_0\rangle + \sum_{ia} t_i^a |\Phi_i^a\rangle + \sum_{ijab} t_{ij}^{ab} |\Phi_{ij}^{ab}\rangle \]

\(|\Phi_0\rangle\) 是 HF 基态波函数,\(|\Phi_i^a\rangle\) 是单激发波函数、\(|\Phi_{ij}^{ab}\rangle\) 是双激发波函数。需要强调的是,由于是空间轨道,因此这些波函数并不反映物理实在,特别是我们无法考察自旋算符 \(\hat S{}^2\) 的本征态。它们只是用来计算能量的方便的记号。

若要针对一次激发、二次激发分别给出 CISD 方程,那么

\[\begin{split} \begin{align*} \langle \Phi_i^a | \hat H - E_\textsf{HF} - E_\mathrm{c}^\textsf{CISD} | \Psi \rangle &= 0 \\ \langle \Phi_{ij}^{ab} | \hat H - E_\textsf{HF} - E_\mathrm{c}^\textsf{CISD} | \Psi \rangle &= 0 \\ \end{align*} \end{split}\]
激发系数#

通过 PySCF 的 ci attribute,进而通过 cisdvec_to_amplitudes 函数可以得到 \(|\Psi\rangle\) 的激发系数,但该激发系数并不是归一化的。我们手动将其进行半归一化,得到系数 t1 \(t_i^a\)t2 \(t_{ij}^{ab}\)

civec = mf_ci.ci / mf_ci.ci[0]
_, t1, t2 = mf_ci.cisdvec_to_amplitudes(civec)
t1.shape, t2.shape
((5, 19), (5, 5, 19, 19))
能量表达式验证#

首先我们要给出电子积分。在有足够内存的情况下,可以用 ccsd_make_eris_incore 实现:

eris = ccsd._make_eris_incore(mf_ci, mf_scf.mo_coeff)

我们通篇文档不关心涉及 \(\hat H\) 的计算过程。我们暂时只需要其中的

\[ g_{ij}^{ab} = \langle ij | ab \rangle = (ia|jb) = \iint \phi_i(\boldsymbol{r}_1) \phi_a(\boldsymbol{r}_1) \frac{1}{|\boldsymbol{r}_1 - \boldsymbol{r}_2|} \phi_j(\boldsymbol{r}_2) \phi_b(\boldsymbol{r}_2) \, \mathrm{d} \boldsymbol{r}_1 \, \mathrm{d} \boldsymbol{r}_2 \]

该张量可以通过 eris.ovov 调出,维度是 \((i, a, j, b)\)

eris.ovov.shape
(5, 19, 5, 19)

对于半归一化方法,不论是 MP2, CEPA(\(n\)), CI(S)D 或 CC(S)D,下式在闭壳层下总是成立的:

\[ E_\mathrm{c} = \sum_{ijab} (2 t_{ij}^{ab} - t_{ij}^{ba}) g_{ij}^{ab} \]
2 * einsum("iajb, ijab ->", eris.ovov, t2) - einsum("iajb, ijba ->", eris.ovov, t2)
-0.2053384394211093

我们再次回顾 PySCF 计算得到的 CISD 相关能是下述非常接近的结果:

mf_ci.e_corr
-0.20533844297533488

CEPA(\(n\)) 计算#

CEPA(\(n\)) 原理#

CEPA(\(n\)) 方程组是

\[\begin{split} \begin{align*} \langle \Phi_{i}^{a} | \hat H - E_\textsf{HF} - B_{i} | \Psi \rangle &= 0 \\ \langle \Phi_{ij}^{ab} | \hat H - E_\textsf{HF} - A_{ij} | \Psi \rangle &= 0 \end{align*} \end{split}\]

其中,

\[\begin{split} \begin{align*} A_{ij} = \left\{ \begin{matrix} 0 & \textsf{CEPA(0)} \\ e_{ij} & \textsf{CEPA(2)} \\ \frac{1}{2} \sum_k (e_{ik} + e_{kj}) & \textsf{CEPA(1)} \\ \sum_k (e_{ik} + e_{kj}) - e_{ij} & \textsf{CEPA(3)} \\ E_\mathrm{c}^\textsf{CISD} = \sum_{kl} e_{kl} & \textsf{CISD} \end{matrix} \right. \end{align*} \end{split}\]
\[\begin{split} \begin{align*} B_{i} = \left\{ \begin{matrix} 0 & \textsf{CEPA(0)} \\ \textsf{NaN} & \textsf{CEPA(2)} \\ \sum_k e_{ik} & \textsf{CEPA(1)} \\ 2 \sum_k e_{ik} - e_{ii} & \textsf{CEPA(3)} \\ E_\mathrm{c}^\textsf{CISD} = \sum_{kl} e_{kl} & \textsf{CISD} \end{matrix} \right. \end{align*} \end{split}\]

对电子能定义为

\[ e_{ij} = \sum_{ab} (2 t_{ij}^{ab} - t_{ij}^{ba}) g_{ij}^{ab} \]
def pair_energy(eris_ovov, t2):
    return 2 * einsum("iajb, ijab -> ij", eris_ovov, t2) - einsum("iajb, ijba -> ij", eris_ovov, t2)

CEPA(2) 由于不确定其 \(B_i\) 的定义,因此这里不作实现。当然,CEPA(3) 的定义也可能存在疑问。

def cepa_shift(cepa_n, t1, t2, e_ij):
    e_i, e_j = e_ij.sum(axis=1), e_ij.sum(axis=0)
    A_ij, B_i = np.zeros_like(e_ij), np.zeros_like(e_i)
    if cepa_n == 0:
        pass
    elif cepa_n == 1:
        A_ij = 0.5 * (e_i[:, None] + e_j[None, :])
        B_i = e_i
    elif cepa_n == 3:
        A_ij = e_i[:, None] + e_j[None, :] - e_ij
        B_i = - e_ij.diagonal() + 2 * e_i
    else:
        raise ValueError("cepa_n value error for " + cepa_n)
    return A_ij, B_i
迭代法求取 CISD 系数#

我们首先考虑二次激发系数 \(t_{ij}^{ab}\) 的求取。

\[ \langle \Phi_{ij}^{ab} | \hat H - E_\textsf{HF} | \Psi \rangle = A_{ij} \langle \Phi_{ij}^{ab} | \Psi \rangle = A_{ij} t_{ij}^{ab} \]

如果我们记等式左为 \(\mathscr{v}_{ij}^{ab}\),那么该式整理为

\[ \mathscr{v}_{ij}^{ab} = A_{ij} t_{ij}^{ab} \quad \Rightarrow \quad t_{ij}^{ab} = \frac{\mathscr{v}_{ij}^{ab}}{A_{ij}} \]

但需要注意,这不是一个好的激发系数 \(t_{ij}^{ab}\) 的更新策略。这里指出,由于 \(\langle \Phi_{ij}^{ab} | \hat H - E_\textsf{HF} | \Phi_0 \rangle = -D_{ij}^{ab}\)\(\mathscr{v}_{ij}^{ab}\) 的重要贡献项,因此不妨将上式写为

\[ \mathscr{v}_{ij}^{ab} - D_{ij}^{ab} t_{ij}^{ab} + D_{ij}^{ab} t_{ij}^{ab} = A_{ij} t_{ij}^{ab} \quad \Rightarrow \quad t_{ij}^{ab} = t_{ij}^{ab} + \frac{\mathscr{v}_{ij}^{ab} - A_{ij} t_{ij}^{ab}}{D_{ij}^{ab}} \]

上式是我们实际会使用到的激发系数 \(t_{ij}^{ab}\) 的更新策略。

在迭代计算激发系数时,我们需要给定初始激发系数。一般来说使用 MP2 激发系数即可:

\[ \tilde t_i^a = 0, \quad \tilde t_{ij}^{ab} = g_{ij}^{ab} / D_{ij}^{ab} \]

其中,d1 \(D_i^a = \varepsilon_i - \varepsilon_a\)d2 \(D_{ij}^{ab} = \varepsilon_i + \varepsilon_j - \varepsilon_a - \varepsilon_b\)

def corr_cepa(cepa_n):
    # Prepare D_i^a, D_ij^ab
    d0, d1, d2 = mf_ci.cisdvec_to_amplitudes(mf_ci.make_diagonal(eris))
    d1 = d0 - d1; d2 = d0 - d2
    # Prepare initial t_i^a, t_ij^ab
    t0, t1, t2 = 1, np.zeros_like(d1), eris.ovov.swapaxes(1, 2) / d2
    civec = mf_ci.amplitudes_to_cisdvec(t0, t1, t2)
    # Iteration
    for it in range(100):
        t1_old, t2_old = t1, t2
        _, v1, v2 = mf_ci.cisdvec_to_amplitudes(mf_ci.contract(civec, eris))
        e_ij = pair_energy(eris.ovov, t2)
        A_ij, B_i = cepa_shift(cepa_n, t1, t2, e_ij)
        t1 = t1_old + (v1 - B_i[:, None]           * t1_old) / d1
        t2 = t2_old + (v2 - A_ij[:, :, None, None] * t2_old) / d2
        e_corr = pair_energy(eris.ovov, t2).sum()
        civec = mf_ci.amplitudes_to_cisdvec(t0, t1, t2)
        if np.linalg.norm(t1 - t1_old) + np.linalg.norm(t2 - t2_old) < 1e-5:
            print("Total Iterations: ", it)
            print("Correlation Energy: ", e_corr)
            break
corr_cepa(0)
Total Iterations:  26
Correlation Energy:  -0.2167752177602909
corr_cepa(1)
Total Iterations:  38
Correlation Energy:  -0.21352333911480398
corr_cepa(3)
Total Iterations:  53
Correlation Energy:  -0.21129784897010107

参考文献#


简单理解 Density Fitting RHF 自洽场#

创建时间:2019-10-16

这一篇文章我们简单讨论 PySCF 下的 Density Fitting (DF) 的实现过程。

%matplotlib notebook

import numpy as np
import scipy
from functools import partial
import matplotlib.pyplot as plt

from pyscf import gto, scf, df

np.einsum = partial(np.einsum, optimize=["greedy", 1024 ** 3 * 2 / 8])
np.set_printoptions(8, linewidth=150, suppress=True)

在开始这篇文章前,我们需要定义用于将以向量储存的下三角矩阵转为普通对称矩阵的函数 tril_to_symm。若同时传入多个下三角矩阵,那么我们也会同样地传出多个对应的对称矩阵。

def tril_to_symm(tril: np.ndarray):
    tril = np.asarray(tril)
    if len(tril.shape) > 1:
        return np.array([tril_to_symm(mat) for mat in tril])
    dim = int(np.floor(np.sqrt(tril.size * 2)))
    if dim * (dim + 1) / 2 != tril.size:
        raise ValueError("Size " + str(tril.size) + " is probably not a valid lower-triangle matrix.")
    indices_tuple = np.tril_indices(dim)
    iterator = zip(*indices_tuple)
    symm = np.empty((dim, dim))
    for it, (row, col) in enumerate(iterator):
        symm[row, col] = tril[it]
        symm[col, row] = tril[it]
    return symm

我们也需要指定我们的分子体系。这里使用一个非常不对称的双氧水作为例子:

mol = gto.Mole()
mol.atom = """
O  0.0  0.0  0.0
O  0.0  0.0  1.5
H  1.0  0.0  0.0
H  0.0  0.7  1.0
"""
mol.basis = "def2-tzvp"
mol.verbose = 0
mol.build()
<pyscf.gto.mole.Mole at 0x7f660d4fabe0>

PySCF 自洽场#

PySCF 普通 RHF#

在我们跑 DF RHF 前,我们先跑一下普通的 RHF,以作对比。

scf_normal = scf.RHF(mol)
scf_normal.run()
scf_normal.e_tot
-150.73664182977006
PySCF DF RHF#

在 PySCF 中,设置 DF 一般至少需要两步:首先需要让 RHF 方法成为 DF 的;其次需要定义 DF 基组。

### PySCF 普通 RHF
scf_df = scf.RHF(mol).density_fit()
scf_df.with_df.auxbasis = "def2-tzvp-jkfit"
scf_df.run()
scf_df.e_tot
-150.73658270520568

我们应当能发现 DF RHF 与普通 RHF 的能量还是有所差别的;尽管差别不大但 \(10^{-4}\) 级别的差别在小体系的量化计算中应当认为是不可忽略的。

scf_df.e_tot - scf_normal.e_tot
5.912456438750269e-05

重现 PySCF DF RHF 能量#

这里我们会通过上面 DF RHF 已经收敛的结果,来计算上述双氧水体系的总能量。

DF 双电子积分#

我们需要先定义 DF 基组下的 PySCF 分子类 auxmol;它将为我们提供我们所需要的 DF 双电子积分。

auxmol = mol.copy()
auxmol.basis = "def2-tzvp-jkfit"
auxmol.build()
<pyscf.gto.mole.Mole at 0x7f660a96a128>

备注

事实上,PySCF 也提供了 scf_df.with_df.auxmol 给出 DF 的双电子积分,并且这才是最合适的用法。但在我的理解范围内,上述的直接定义 auxmol 的方法几乎是等价的。

在 DF RHF 的过程中,需要使用到 DF 生成的双电子积分的地方只有库伦矩阵 (J 矩阵) 与交换矩阵 (K 矩阵)。我们知道普通 RHF 的计算耗时受制于四中心双电子 (4c2e) ERI 积分,因此计算复杂度为 \(O(N^4)\)。而 DF RHF 之所以快,就是因为它能将 ERI 积分转化为三中心双电子 (3c2e) 积分与双中心双电子 (2c2e) 积分,从而将计算复杂度转为 \(O(N^3)\)

记号定义

下面这里采用我自己习惯的记号,以及 Sherrill [1] 的记号。大致但重要的记号定义如下。

  • \(\mu, \nu, \kappa, \lambda\) 代表原子轨道

  • \(i, j, k, l\) 代表占据轨道

  • \(a, b, c, d\) 代表非占轨道

  • \(p, q, r, s\) 代表任意分子轨道

  • \(P, Q\) 代表 DF 轨道

  • 在表达轨道函数形式时,原子、分子轨道用 \(\phi\) 表达,而 DF 轨道用 \(\chi\) 表达

  • \(J_{\mu \nu} [R_{\kappa \lambda}]\) 表示在电子密度为 \(R_{\kappa \lambda}\) 下的 J 积分。类似地,还有 K 积分 \(K_{\mu \nu} [R_{\kappa \lambda}]\)

  • \((\mu \nu | P)\) 代表 3c2e 积分,\(J_{PQ}\) 代表 2c2e 积分。2c2e 积分的符号与 J 积分相同;但通过角标应当可以区分两者,免去歧义。

我们先描述 2c2e 积分 \(J_{PQ}\) int2c2e。2c2e 积分从定义上是

\[ J_{PQ} = \iint \chi_P (\boldsymbol{r}_1) \frac{1}{r_{12}} \chi_Q (\boldsymbol{r}_2) \, \mathrm{d} \boldsymbol{r}_1 \, \mathrm{d} \boldsymbol{r}_2 \]

尽管下面的类似于 Dirac 的记号并不严谨,但在简单分析问题时,下述记号仍然是方便的:

\[ J_{PQ} = ( P | \frac{1}{r_{12}} | Q ) \]

之所以说是不严格的,是因为在多轨道下,Dirac 记号在算符左右的部分应当需要在电子坐标上是一一对应的,因此 \(( P 1 | r_{12}^{-1} | 1 Q )\) 可能更合理一些。

在 PySCF 中,2c2e 积分 int2c2e 可以很容易地通过 intor 生成;这个函数也可以生成绝大多数普通的 SCF 计算过程中所需要使用的积分。需要注意的是,这里使用的基组是 DF 基组,因此分子类也应当使用的是 auxmol

int2c2e = auxmol.intor("int2c2e")
int2c2e.shape
(190, 190)

对于 3c2e 积分 \((\mu \nu | P)\) int3c2e,其定义也是类似的:

\[ (\mu \nu | P) = \iint \phi_\mu (\boldsymbol{r}_1) \phi_\nu (\boldsymbol{r}_1) \frac{1}{r_{12}} \chi_P (\boldsymbol{r}_2) \, \mathrm{d} \boldsymbol{r}_1 \, \mathrm{d} \boldsymbol{r}_2 \]

但对于 3c2e 积分 int3c2e 而言,由于其中混合了普通基组与 DF 基组,因此不能通过简单的 intor 来生成积分。我们使用 df.incore.aux_e2 来生成 \((\mu \nu | P)\)

int3c2e = df.incore.aux_e2(mol, auxmol)

该积分也可以不借助 pyscf.df 模块生成,但实现起来稍复杂一些。

DF 积分模拟 4c2e ERI 积分#

备注

这篇文档仅在这一段不使用 Einstein Summation Convention。

我们指出,这些积分是通过下述过程来模拟 ERI 积分 \((\mu \nu | \kappa \lambda)\)

\[ (\mu \nu | \kappa \lambda) \simeq (\mu \nu | P) (\mathbf{J}^{-1})_{PQ} (Q | \kappa \lambda) \]

我们用程序生成 4c2e 的 ERI 积分 \((\mu \nu | \kappa \lambda)\) eri 与 DF 积分生成的 ERI 积分 df_eri

eri = mol.intor("int2e")
df_eri = np.einsum("uvP, PQ, klQ -> uvkl", int3c2e, np.linalg.inv(int2c2e), int3c2e)

之所以我们会用上式模拟 4c2e 的 ERI 积分,可以从下面不太严格的公式来看:

\[ \langle \mu \nu | r_{12}^{-1} | \kappa \lambda \rangle \simeq \sum_{PQ} \langle \mu \nu | r_{12}^{-1} | P \rangle \langle P | r_{12} | Q \rangle \langle Q | r_{12}^{-1} | \kappa \lambda \rangle \]

上式利用到两次 Resolution of Identity (RI) 关系式

\[ 1 \simeq \sum_P | P \rangle \langle P | \]

我们目前还没有 \(\langle P | r_{12} | Q \rangle\);但它也可以通过使用了一次 RI 关系式的表达式

\[ \delta_{PR} \simeq \langle P | R \rangle = \sum_{Q} \langle P | r_{12} | Q \rangle \langle Q | r_{12}^{-1} | R \rangle \]

给出。实施上,\(J_{QR} = \langle Q | r_{12}^{-1} | R \rangle\);若我们定义矩阵 \(X_{PQ} = \langle P | r_{12} | Q \rangle\),那么上述公式的矩阵表达式将是

\[ \mathbf{I} \simeq \mathbf{X} \mathbf{J} \]

其中,\(\mathbf{I}\) 表示单位矩阵。因此,\(\mathbf{X} \simeq \mathbf{J}^{-1}\)。这就完成了近似表达式 \((\mu \nu | \kappa \lambda) \simeq (\mu \nu | P) (\mathbf{J}^{-1})_{PQ} (Q | \kappa \lambda)\) 的说明了。

之所以上面式子中都采用近似相等记号 \(\simeq\),而不采用相等记号 \(=\),是因为受计算能力影响,实践中的 RI 展开的函数基是有限的。若 RI 是使用无限函数基展开,原则上等号成立。

上面是理论上的推演;现在我们考察在双氧水体系下上述近似的有效性。我们将绘制一幅 ERI 概率分布图来表明这个问题。横坐标的含义是 ERI 积分数值的以 10 为底的对数;纵坐标的意义是 ERI 积分中,处在某一数量级时的概率密度 (PDF)。

bins =np.arange(-12, 1., 0.1)
histo_eri = np.histogram(np.log10(np.abs(eri).ravel() + 1e-20), bins=bins)
histo_df_eri = np.histogram(np.log10(np.abs(df_eri).ravel() + 1e-20), bins=bins)
histo_dev = np.histogram(np.log10(np.abs(df_eri - eri).ravel() + 1e-20), bins=bins)
prob_eri = histo_eri[0] / eri.size
prob_df_eri = histo_df_eri[0] / eri.size
prob_dev = histo_dev[0] / eri.size
fig, ax = plt.subplots()

ax.plot(histo_eri[1][:-1], prob_eri, label="Original ERI")
ax.plot(histo_df_eri[1][:-1], prob_df_eri, label="DF ERI")
ax.plot(histo_dev[1][:-1], prob_dev, label="Deviation")

ax.set_xlabel("$\mathrm{log}_{10} \mathrm{ERI}$")
ax.set_ylabel("Probability Distribution")
ax.set_title("ERI Distribution Status")
ax.legend()
<matplotlib.legend.Legend at 0x7f660ad8d4e0>

需要作补充说明的是,事实上不论是 DF ERI 或是 4c2e ERI,它们都有许多值为零,因此上述 PDF 图的积分在 \([-N, +N] \, (\forall N \in \mathscr{N}^{*})\) 区间的积分面积总小于 1;但这并不影响我们讨论。

我们能发现,在 ERI 积分中大于 \(10^{-4}\) 大小的数值部分,DF ERI 积分与普通的 4c2e ERI 积分的分布是几乎相同的,意味着 DF ERI 把握住 4c2e ERI 积分的主要部分。但对于小于该数值的次要部分,DF ERI 并不能很好地对 4c2e ERI 积分作出近似。

重复 DF J 积分#

首先,我们指出,我们目标的 DF J 积分并不与普通的 J 积分相同:

X = np.random.randn(mol.nao, mol.nao)
X += X.T
np.allclose(scf_normal.get_j(dm=X), scf_df.get_j(dm=X))
False

根据 J 积分的定义 \(J_{\mu \nu} [X_{\kappa \lambda}] = (\mu \nu | \kappa \lambda) X_{\kappa \lambda}\),我们应当很容易地给出 DF J 积分的定义:

\[ J_{\mu \nu} [X_{\kappa \lambda}] \simeq (\mu \nu | P) (\mathbf{J}^{-1})_{PQ} (Q | \kappa \lambda) X_{\kappa \lambda} \]
np.allclose(
    np.einsum("uvP, PQ, klQ, kl -> uv", int3c2e, np.linalg.inv(int2c2e), int3c2e, X),
    scf_df.get_j(dm=X)
)
True

但求逆运算一般来说是需要避免的。在 PySCF 默认的 DF 程序实践中,通常是采用 Cholesky 分解与线性方程组求解得到三角标张量 \(V_{\mu \nu, P}\),随后再进行缩并。具体的过程大致如下:首先通过 Cholesky 分解给出 \((\mathbf{J}^{1/2})_{PQ}\) int2c2e_half

\[ J_{PQ} = (\mathbf{J}^{1/2})_{PR} (\mathbf{J}^{1/2})_{QR} \]
int2c2e_half = scipy.linalg.cholesky(int2c2e, lower=True)
np.allclose(np.einsum("PR, QR -> PQ", int2c2e_half, int2c2e_half), int2c2e)
True

随后通过求解 \((\mathbf{J}^{1/2})_{PQ} V_{\mu \nu, Q} = (\mu \nu | P)\),得到 \(V_{\mu \nu, Q} = (\mathbf{J}^{-1/2})_{PQ} (\mu \nu | P)\) V_df;但注意到 \((\mu \nu)\) 一般是用作双下标的,但在求解方程的时候需要转换为同一维度。

V_df = scipy.linalg.solve_triangular(int2c2e_half, int3c2e.reshape(-1, auxmol.nao).T, lower=True)\
           .reshape(auxmol.nao, mol.nao, mol.nao).transpose((1, 2, 0))

由此,J 积分可以通过下式给出:

\[ J_{\mu \nu} [X_{\kappa \lambda}] = V_{\mu \nu, P} V_{\kappa \lambda, P} X_{\kappa \lambda} \]
np.allclose(
    np.einsum("uvP, klP, kl -> uv", V_df, V_df, X),
    scf_df.get_j(dm=X)
)
True

K 积分也可以类似地给出。

PySCF 的三角标张量 \(V_{\mu \nu, P}\)#

事实上,PySCF 会储存三角标张量 \(V_{\mu \nu, P}\)。根据其 文档,三角标张量储存于

scf_df.with_df._cderi.shape
(190, 2775)

储存下来的张量是两个维度,第一个维度是 DF 基组大小,第二个则是 \((\mu \nu)\) 双下标的下三角矩阵所给出的向量。现在我们将其展开为对称张量 V_df_pyscf

V_df_pyscf = tril_to_symm(scf_df.with_df._cderi).transpose((1, 2, 0))

我们可以验证 PySCF 与我们所给出的 \(V_{\mu \nu, P}\) 是等价的:

np.allclose(V_df, V_df_pyscf)
True

DF RHF 自洽场#

既然我们已经重复出 DF J/K 积分,那么我们就可以写一个简单但完整的 RHF 计算程序。

体系定义#
mol = gto.Mole()
mol.atom = """
O  0.0  0.0  0.0
O  0.0  0.0  1.5
H  1.0  0.0  0.0
H  0.0  0.7  1.0
"""
mol.basis = "def2-tzvp"
mol.verbose = 0
mol.build()

auxmol = mol.copy()
auxmol.basis = "def2-tzvp-jkfit"
auxmol.build()
<pyscf.gto.mole.Mole at 0x7f660d2729b0>
natm = mol.natm
nao = nmo = mol.nao
nocc = mol.nelec[0]
nvir = nmo - nocc
so, sv, sa = slice(0, nocc), slice(nocc, nmo), slice(0, nmo)

nao_df = auxmol.nao
积分与能量定义#
T = mol.intor("int1e_kin")
Vnuc = mol.intor("int1e_nuc")
H = T + Vnuc
S = mol.intor("int1e_ovlp")

int2c2e = auxmol.intor("int2c2e")
int2c2e.shape
int3c2e = df.incore.aux_e2(mol, auxmol)

int2c2e_half = scipy.linalg.cholesky(int2c2e, lower=True)
V_df = scipy.linalg.solve_triangular(int2c2e_half, int3c2e.reshape(-1, nao_df).T, lower=True)\
           .reshape(nao_df, nao, nao).transpose((1, 2, 0))
def gen_J(X):
    return np.einsum("uvP, klP, kl -> uv", V_df, V_df, X)
    
def gen_K(X):
    return np.einsum("ukP, vlP, kl -> uv", V_df, V_df, X)
    
def gen_F(X):
    return H + gen_J(X) - 0.5 * gen_K(X)

def gen_energy_elec(D):
    return ((H + 0.5 * gen_J(D) - 0.25 * gen_K(D)) * D).sum()
Z_A = mol.atom_charges()
A_t = mol.atom_coords()
r_AB = np.linalg.norm(A_t[:, None, :] - A_t[None, :, :], axis=-1)
r_AB += np.diag(np.ones(natm) * np.inf)
E_nuc = 0.5 * (Z_A[None, :] * Z_A[:, None] / r_AB).sum()
自洽场循环#
C = e = NotImplemented
D = np.zeros((nao, nao))
D_old = np.zeros((nao, nao)) + 1e-4
count = 0

while (not np.allclose(D, D_old, atol=1e-8, rtol=1e-6)):
    if count > 500:
        raise ValueError("SCF not converged!")
    count += 1
    D_old = D
    F = gen_F(D)
    e, C = scipy.linalg.eigh(F, S)  # Solve FC = SCe
    D = 2 * C[:, so] @ C[:, so].T
    if count > 1:
        D = 0.3 * D + 0.7 * D_old             # For convergence

E_elec = gen_energy_elec(D)
E_tot = E_elec + E_nuc

print("SCF Converged in           ", count, " loops")
print("Electronic energy (DF_RHF) ", E_elec, " a.u.")
print("Total energy      (DF_RHF) ", E_tot, " a.u.")
SCF Converged in            130  loops
Electronic energy (DF_RHF)  -188.62125711384974  a.u.
Total energy      (DF_RHF)  -150.73658270520846  a.u.

我们可以验证上述 SCF 过程得到的能量与 PySCF DF 所给出的能量几乎完全一样:

E_tot - scf_df.e_tot
-2.7853275241795927e-12

但该能量与普通自洽场能量多少有些差距:

E_tot - scf_normal.e_tot
5.912456160217516e-05

简单理解 Density Fitting RMP2#

创建时间:2019-10-16

这一节的内容承接上一节对 DF RHF 的理解,只是简单地近一步得到 RMP2 的能量。

%matplotlib notebook

import numpy as np
import scipy
from functools import partial
import matplotlib.pyplot as plt

from pyscf import gto, scf, mp, df

np.einsum = partial(np.einsum, optimize=["greedy", 1024 ** 3 * 2 / 8])
np.set_printoptions(8, linewidth=150, suppress=True)

我们使用的体系仍然是不对称的双氧水分子:

mol = gto.Mole()
mol.atom = """
O  0.0  0.0  0.0
O  0.0  0.0  1.5
H  1.0  0.0  0.0
H  0.0  0.7  1.0
"""
mol.basis = "def2-tzvp"
mol.verbose = 0
mol.build()

natm = mol.natm
nao = nmo = mol.nao
nocc = mol.nelec[0]
nvir = nmo - nocc
so, sv, sa = slice(0, nocc), slice(nocc, nmo), slice(0, nmo)

PySCF RMP2#

PySCF 普通 RMP2#

在我们跑 DF RMP2 前,我们先跑一下普通的 RMP2,以作对比。

scf_normal = scf.RHF(mol).run()
mp2_normal = mp.MP2(scf_normal).run()
mp2_normal.e_corr
-0.5378068680460026
PySCF DF RHF#

在 PySCF 中,即使自洽场已经经过 DF 优化过,也不需要特别地对 MP2 的代码作改变:

scf_df = scf.RHF(mol).density_fit().run()
mp2_df = mp.MP2(scf_df).run()
mp2_df.e_corr
-0.5370249637959694

在相关能上,两者的结果还是稍有差距的。

mp2_df.e_corr - mp2_normal.e_corr
0.0007819042500332163

对于 PySCF 而言,一种更好的做法是,在自洽场过程中使用对 J/K 积分优化较好的 jkfit 型 DF 基组,而在 MP2 或后自洽场中使用对 MP2 优化较好的 ri 型 DF 基组:

scf_df = scf.RHF(mol).density_fit(auxbasis="def2-tzvp-jkfit").run()
mp2_df = mp.MP2(scf_df).density_fit(auxbasis="def2-tzvp-ri").run()
mp2_df.e_corr
-0.5377072684090778

现在相关能的误差就相对来说小了不少。

mp2_df.e_corr - mp2_normal.e_corr
9.959963692485196e-05

重现 PySCF DF RMP2 相关能#

DF 基组定义与必要张量#

我们首先生成计算 DF RMP2 所需要的张量:

  • V_df_mp2 \(V_{\mu \nu, P}^\mathrm{MP2}\):经过 Cholesky 处理的三中心双电子张量

  • C, Co, Cv \(C_{\mu p}\):分子轨道系数 C,以及其在占据轨道 (Co)、非占轨道 (Cv) 的分割

  • D_iajb \(D_{ij}^{ab}\):即 \(\varepsilon_i - \varepsilon_a + \varepsilon_j - \varepsilon_b\)

我们指出,这里计算的 V_df_mp2 \(V_{\mu \nu, P}^\mathrm{MP2}\) 是由 RI 基组 def2-tzvp-ri 产生;与上一篇文档提到的由 def2-tzvp-jkfit 所生成的 \(V_{\mu \nu, P}\) 是不同的。因此,我们需要使用对应的 def2-tzvp-ri 基组来产生 DF 分子类 auxmol

auxmol = mol.copy()
auxmol.basis = "def2-tzvp-ri"
auxmol.build()

nao_df = auxmol.nao
int2c2e = auxmol.intor("int2c2e")
int2c2e.shape
int3c2e = df.incore.aux_e2(mol, auxmol)

int2c2e_half = scipy.linalg.cholesky(int2c2e, lower=True)
V_df_mp2 = scipy.linalg.solve_triangular(int2c2e_half, int3c2e.reshape(-1, nao_df).T, lower=True)\
               .reshape(nao_df, nao, nao).transpose((1, 2, 0))
C, e = scf_df.mo_coeff, scf_df.mo_energy
Co, Cv = C[:, so], C[:, sv]
eo, ev = e[so], e[sv]
D_iajb = eo[:, None, None, None] - ev[None, :, None, None] + eo[None, None, :, None] - ev[None, None, None, :]
ERI 积分转换#

MP2 中最为耗时的一步是原子轨道 4c2e 的 ERI 积分 \((\mu \nu | \kappa \lambda)\) 转换为分子轨道 \((ia|jb)\) 的过程。我们在 DF MP2 中仍然没有避开生成 \((ia|jb)\),但我们避开了直接生成 \((\mu \nu | \kappa \lambda)\);恰恰是这一步成为 DF MP2 的优势。其具体的过程大致如下。

首先,我们生成分子轨道下的三中心积分 \(V_{ia, P}^\mathrm{MP2}\) V_df_ia (\(O(N^4)\) 复杂度):

\[ V_{ia, P}^\mathrm{MP2} = V_{\mu \nu, P}^\mathrm{MP2} C_{\mu i} C_{\nu p} \]
V_df_ia = np.einsum("uvP, ui, va -> iaP", V_df_mp2, Co, Cv)

尽管我们使用了与 jkfit 不同的基组,但我们仍然认为 \((\mu \nu | \kappa \lambda) \simeq V_{\mu \nu, P}^\mathrm{MP2} V_{\kappa \lambda, P}^\mathrm{MP2}\) 近似成立。因此,我们可以很容易地给出 (\(O(N^5)\) 复杂度)

\[\begin{split} \begin{align*} (ia|jb) &= C_{\mu i} C_{\nu a} (\mu \nu | \kappa \lambda) C_{\kappa j} C_{\lambda b} \\ &\simeq C_{\mu i} C_{\nu a} V_{\mu \nu, P}^\mathrm{MP2} V_{\kappa \lambda, P}^\mathrm{MP2} C_{\kappa j} C_{\lambda b} \\ &\simeq V_{ia, P}^\mathrm{MP2} V_{jb, P}^\mathrm{MP2} \end{align*} \end{split}\]
eri_df_iajb = np.einsum("iaP, jbP -> iajb", V_df_ia, V_df_ia)

当然,我们也可以使用下述代码,一次性地从 \(V_{\mu \nu, P}^\mathrm{MP2}\) 生成 DF 近似的 \((ia|jb)\) eri_df_iajb,而结果和效率没有任何影响:

np.allclose(
    np.einsum("ui, va, uvP, klP, kj, lb -> iajb", Co, Cv, V_df_mp2, V_df_mp2, Co, Cv),
    eri_df_iajb
)
True

最后,我们通过图像指出,DF 近似的转换后的分子轨道 ERI 与普通的分子轨道 ERI 非常接近。

eri = mol.intor("int2e")
eri_iajb = np.einsum("ui, va, uvkl, kj, lb -> iajb", Co, Cv, eri, Co, Cv)
bins =np.arange(-12, 1., 0.1)
histo_eri = np.histogram(np.log10(np.abs(eri_iajb).ravel() + 1e-20), bins=bins)
histo_df_eri = np.histogram(np.log10(np.abs(eri_df_iajb).ravel() + 1e-20), bins=bins)
histo_dev = np.histogram(np.log10(np.abs(eri_df_iajb - eri_iajb).ravel() + 1e-20), bins=bins)

prob_eri = histo_eri[0] / eri_iajb.size
prob_df_eri = histo_df_eri[0] / eri_iajb.size
prob_dev = histo_dev[0] / eri_iajb.size
fig, ax = plt.subplots()

ax.plot(histo_eri[1][:-1], prob_eri, label="Original MO ERI", linestyle="-.")
ax.plot(histo_df_eri[1][:-1], prob_df_eri, label="DF MO ERI", linestyle=":")
ax.plot(histo_dev[1][:-1], prob_dev, label="Deviation")

ax.set_xlabel("$\mathrm{log}_{10} \mathrm{MO\, ERI}$")
ax.set_ylabel("Probability Distribution")
ax.set_title("MO ERI Distribution Status")
ax.legend()
<matplotlib.legend.Legend at 0x7fa3bc1348d0>

上图中,蓝色线是普通的 MO ERI,它与橙色线几乎重合,意味着两者数值上极为相近。同时,作为两者之差的绿色线的分布明显在更小的数量级上。

积分转换的复杂度简单分析#

上述的复杂度是不难分析的。尽管 DF 与普通 MP2 所进行的积分转换都是 \(O(N^5)\) 的复杂度;但细致地分析会发现,普通 MP2 的复杂度是 \(O(n_\mathrm{occ} n_\mathrm{AO}^4)\),而 DF 的复杂度是 \(O(n_\mathrm{occ}^2 n_\mathrm{vir}^2 n_\mathrm{aux})\),两者的计算次数是远不相同的。

我们有 einsum_path 的工具;通过该工具的 Optimized FLOP count 输出,我们能知道实际上 NumPy 将会计算多少次浮点计算。同时,Largest intermediate 会告诉我们在当前的张量缩并路径下,内存中需要使用的最大的中间张量大小。

下述的缩并过程是普通 MP2 的分子轨道基组 ERI 计算过程

\[ (ia|jb) = C_{\mu i} C_{\nu a} (\mu \nu | \kappa \lambda) C_{\kappa j} C_{\lambda b} \]

若使用朴素的 (Naive) 张量乘积 (\(O(N^8)\) 复杂度),那么需要进行 \(5.131 \times 10^{13}\) 次浮点计算。我们知道通常的 MP2 是 \(O(N^5)\) 复杂度;其对应的浮点计算次数在当前的双氧水体系下是 \(7.137 \times 10^8\) 次。

print(np.einsum_path("ui, va, uvkl, kj, lb -> iajb", Co, Cv, mol.intor("int2e"), Co, Cv, optimize=True)[1])
  Complete contraction:  ui,va,uvkl,kj,lb->iajb
         Naive scaling:  8
     Optimized scaling:  5
      Naive FLOP count:  5.131e+13
  Optimized FLOP count:  7.137e+08
   Theoretical speedup:  71892.409
  Largest intermediate:  3.647e+06 elements
--------------------------------------------------------------------------
scaling                  current                                remaining
--------------------------------------------------------------------------
   5               uvkl,ui->iklv                      va,kj,lb,iklv->iajb
   5               iklv,kj->ijlv                         va,lb,ijlv->iajb
   5               ijlv,va->ijal                            lb,ijal->iajb
   5               ijal,lb->iajb                               iajb->iajb

而在 DF 近似于优化下,

\[ (ia|jb) \simeq C_{\mu i} C_{\nu a} V_{\mu \nu, P}^\mathrm{MP2} V_{\kappa \lambda, P}^\mathrm{MP2} C_{\kappa j} C_{\lambda b} \]

其对应的浮点计算次数则是 \(1.920 \times 10^8\)。可以看到,DF 速度提升大约 3.72 倍。除此之外,积分转换对内存空间的需求还是普通 MP2 的 1/10.66 倍,因此可以说 DF MP2 是在损失比较小的精度的前提下,同时提升计算效率与内存利用,是一个很好的 Balance。

print(np.einsum_path("ui, va, uvP, klP, kj, lb -> iajb", Co, Cv, V_df_mp2, V_df_mp2, Co, Cv, optimize=True)[1])
  Complete contraction:  ui,va,uvP,klP,kj,lb->iajb
         Naive scaling:  9
     Optimized scaling:  5
      Naive FLOP count:  1.121e+16
  Optimized FLOP count:  1.920e+08
   Theoretical speedup:  58377026.800
  Largest intermediate:  3.422e+05 elements
--------------------------------------------------------------------------
scaling                  current                                remaining
--------------------------------------------------------------------------
   4                 uvP,ui->ivP                   va,klP,kj,lb,ivP->iajb
   4                 kj,klP->jlP                      va,lb,ivP,jlP->iajb
   4                 ivP,va->iaP                         lb,jlP,iaP->iajb
   4                 jlP,lb->jbP                            iaP,jbP->iajb
   5               jbP,iaP->iajb                               iajb->iajb
DF MP2 相关能#

既然我们已经获得了 DF 近似的 \((ia|jb)\),那么后面的过程与普通的 MP2 并无差异:

  • \(t_{ij}^{ab} = (ia|jb) / D_{ij}^{ab}\)

  • \(T_{ij}^{ab} = 2 t_{ij}^{ab} - t_{ij}^{ba}\)

  • \(E_\mathrm{MP2, corr} = T_{ij}^{ab} t_{ij}^{ab} D_{ij}^{ab}\)

t_iajb = eri_df_iajb / D_iajb
T_iajb = (2 * t_iajb - t_iajb.swapaxes(-1, -3))
mp2_df_corr = (T_iajb * t_iajb * D_iajb).sum()
mp2_df_corr
-0.5377072684090779

我们可以看到计算得到的 DF MP2 能量与 PySCF 所给出的 DF MP2 能量几乎完全相等。

mp2_df_corr - mp2_df.e_corr
-1.1102230246251565e-16

简单理解 LT-DF SOS RMP2#

创建时间:2019-10-17;最后修改:2021-06-12

这一节承接上一节对 DF-MP2 的讨论。我们知道 DF-MP2 的计算耗时尽管比传统 MP2 低但仍然是 \(O(N^5)\);这一节作的 LT-DF SOS 近似则可以确实地将计算复杂度降低为 \(O(N^4)\)

备注

LT-DF SOS MP2 的写法并不是目前通用的写法;可能目前也没有通用的写法。

该名称的含义是 Laplace-Transformation Density-Fitting Scaled Opposite-Spin Second Order Moller-Plesset。

不要被这么长的名称吓到。除了 LT 之外,其它的过程或者非常容易实现 (SOS),或者是我们已经理解的内容 (DF, MP2)。

%matplotlib notebook

import numpy as np
import scipy
import quadpy
from functools import partial
import matplotlib.pyplot as plt

from pyscf import gto, scf, mp, df

np.einsum = partial(np.einsum, optimize=["greedy", 1024 ** 3 * 2 / 8])
np.set_printoptions(8, linewidth=150, suppress=True)

在这篇文档中,我们将会将测评体系更换为乙烯分子。这主要是为了与 Jung et al. [1] 文章中 Table I 的数据作比较。

冻核近似说明

这篇文章采用了 MP2 的冻核近似 (Frozen Core);因此我们之后的 MP2 计算也需要相应地采用冻核近似。据我所知 PySCF 没有明确地写出冻核近似代码。因此,我们需要手动指定冻核。对于乙烯分子,两个碳原子有需要被冻结的 1s 轨道,因此被冻结的占据轨道数为 2;而非占轨道的冻结数为零。

程序中使用 sf 表示冻结后的占据轨道分割,Cfef 表示冻结后的分子轨道系数与轨道能量。为了符号简便起见,其它的程序符号基本沿用上一篇文档。

分子结构说明

目前使用的分子结构是在 MP2/6-31G* 下优化的乙烯分子。优化使用 Gaussian 09 rev B01 实现。尽管能重复 Jung et al. [1] 文章中乙烯分子的结果,但会在相关能的第 4 位上有些微差距。我认为这并不影响讨论。

MP2 结构优化输入卡 assets/C2H4-opt.gjf QCISD(T) 能量输入卡 assets/C2H4-eng.gjf

mol = gto.Mole()
mol.atom = """
C   -0.668188207     0.000000075    -0.0000028526
C    0.668188207     0.0000000727    0.0000028526
H   -1.2382848349   -0.0000000262   -0.9232789581
H   -1.2382926466   -0.0000000483    0.9232684342
H    1.2382848349   -0.0000000255    0.9232789581
H    1.2382926466   -0.0000000477   -0.9232684342
"""
mol.basis = "cc-pVTZ"
mol.verbose = 0
mol.build()

natm = mol.natm
nao = nmo = mol.nao
nocc = mol.nelec[0]
nvir = nmo - nocc
so, sv, sa = slice(0, nocc), slice(nocc, nmo), slice(0, nmo)
sf = slice(2, nocc)

参考值#

参考值与 SOS-MP2#

根据 Jung et al. [1] 文章的结果,我们给出下述参考值字典 ref;每个键值对应的数值单位是 mH (milli-Hartree)。

ref = {
    "QCISDT": -375.4,
    "SS": -35.5,
    "OS": -264.7,
    "MP2": -375.4 * 0.894,
    "SCS": -375.4 * 0.909,
    "SOS": -375.4 * 0.917,
}

其中,键值 MP2 (Second Order Moller-Plesset), SCS (Spin-Component Scaled) 与 SOS (Scaled Opposite-Spin) 原则上都可以通过 SS (Same-Spin) 与 OS (Opposite-Spin) 对相关能的贡献给出:

\[ E_\mathrm{MP2, corr} = 2 E_\mathrm{SS} + E_\mathrm{OS} \]
print(ref["MP2"])
print(ref["SS"] * 2 + ref["OS"] * 1)
-335.6076
-335.7
\[ E_\text{SCS-MP2, corr} = 2 \times \frac{1}{3} E_\mathrm{SS} + \frac{6}{5} E_\mathrm{OS} \]
print(ref["SCS"])
print(ref["SS"] * 2 * 1/3 + ref["OS"] * 6/5)
-341.23859999999996
-341.3066666666667
\[ E_\text{SOS-MP2, corr} = 1.3 E_\mathrm{OS} \]
print(ref["SOS"])
print(ref["OS"] * 1.3)
-344.2418
-344.11

关于这些方法的细节,我们不在这里细究。在这篇文档中,我们只关心到 SOS 方法只使用了 MP2 相关能中 OS 部分的贡献。这对我们后面的讨论至关重要。我们在这篇文档将要重复的就是 SOS-MP2。

SCF 计算#

在继续文档以前,我们需要给出乙烯分子的各种计算结果。大多数代码记号与公式都可以与前两篇文档比对。补充出来的记号除了冻结轨道近似的部分之外,还有

  • \(D_i^a\) D_ia\(\varepsilon_i - \varepsilon_a\)

由于我们关心的是 MP2 部分的 DF,因此自洽场即使不使用 DF 也不影响我们的讨论。

scf_normal = scf.RHF(mol).run()
mp2_normal = mp.MP2(scf_normal).run()
C, e = scf_normal.mo_coeff, scf_normal.mo_energy
Cf, Cv = C[:, sf], C[:, sv]
ef, ev = e[sf], e[sv]
D_iajb = ef[:, None, None, None] - ev[None, :, None, None] + ef[None, None, :, None] - ev[None, None, None, :]
D_ia = ef[:, None] - ev[None, :]
SS/OS 计算#

MP2 能量除了使用上一份文档的 \(E_\mathrm{MP2, corr} = T_{ij}^{ab} t_{ij}^{ab} D_{ij}^{ab}\) 之外,还可以使用下述的公式计算:

\[ E_\mathrm{MP2, corr} = \frac{1}{D_{ij}^{ab}} (ia|jb) \big[ 2 (ia|jb) - (ib|ja) \big] \]

上式可以拆分为

\[\begin{split} \begin{align} E_\mathrm{SS} &= \frac{1}{2} \frac{1}{D_{ij}^{ab}} (ia|jb) \big[ (ia|jb) - (ib|ja) \big] \\ E_\mathrm{OS} &= \frac{1}{D_{ij}^{ab}} (ia|jb)^2 \\ E_\mathrm{MP2, corr} &= 2 E_\mathrm{SS} + E_\mathrm{OS} \end{align} \end{split}\]

其拆分依据是通过自旋轨道下的 MP2 表达式产生而来,这里不作展开。为了计算 \(E_\mathrm{OS}\),除了 \(D_{ij}^{ab}\) (D_iajb) 之外,我们还需要 \((ia|jb)\)。该变量储存于 eri_iajb 中:

eri_iajb = np.einsum("ui, va, uvkl, kj, lb -> iajb", Cf, Cv, mol.intor("int2e"), Cf, Cv)

随后我们就可以计算 \(E_\mathrm{SS}\) E_SS_normal\(E_\mathrm{OS}\) E_OS_normal 了:

E_SS_normal = 0.5 * (eri_iajb * (eri_iajb - eri_iajb.swapaxes(-1, -3)) / D_iajb).sum()
E_OS_normal = (eri_iajb ** 2 / D_iajb).sum()
print("E_SS       in mH: ", E_SS_normal * 1000)
print("E_OS       in mH: ", E_OS_normal * 1000)
print("E_MP2_corr in mH: ", (E_OS_normal + 2 * E_SS_normal) * 1000)
E_SS       in mH:  -35.46668203417094
E_OS       in mH:  -264.8091129392841
E_MP2_corr in mH:  -335.742477007626

这篇文档的主要目标并非重复 \(E_\mathrm{MP2, corr}\),而是重复 \(E_\text{SOS-MP2, corr}\) E_SOS_normal

E_SOS_normal = 1.3 * E_OS_normal
print("E_SOS_corr in mH: ", E_SOS_normal * 1000)
E_SOS_corr in mH:  -344.25184682106936

Laplace 变换 (LT) 原理#

\(x^{-1}\) LT 数值积分#

在这里,我们不讨论更为广泛的 LT 的原理,只讨论与 MP2 计算相关的 \(x^{-1} = (D_{ij}^{ab})^{-1}\) 的计算问题。对于 LT 中涉及到 MP2 的问题会放在下一小节中讨论;这里我们只需要知道乙烯分子 \(x\) 的取值范围大约会是

print("|D_iajb| max in Hartree: ", np.abs(D_iajb).max())
print("|D_iajb| min in Hartree: ", np.abs(D_iajb).min())
|D_iajb| max in Hartree:  31.40203636576501
|D_iajb| min in Hartree:  1.0534258481856735

\(x^{-1}\) 可以通过下述方式获得:

\[ x^{-1} = \int_0^{+ \infty} e^{- x t} \, \mathrm{d} t \]

根据格点积分的原理,我们可以将上式在格点 \(\{g\}\) 下重新写为

\[ x^{-1} \simeq w_g e^{- x t_g} \]

现在我们从程序的角度实现上述格点积分。我们知道,\(e^{-xt}\) 函数在零点处的值较大,但在远离零点处的值较小。同时,由于 \(e^{-xt}\) 本身就是一种指数形式,因此我们容易想到使用指数的方式构造格点。令坐标变量 \(t_g\) grid_points

\[ t_g = a^g \]

那么对应的格点权重 \(w_g\) grid_weights 应当接近

\[ w_g \simeq \frac{\partial}{\partial g} t_g = \log a \cdot a^g \]

在这里,我们取 \(a = 2.5\),而 \(g \in \left[ -10, 5 \right)\)。这种取法一定程度上是任意的,但考虑到我们希望对 \(x \in (0.1, 100)\) 的区间,特别是靠近 \(x \in (0.1, 2)\) 的区域有比较良好的近似 (MP2 中主要产生贡献的部分在分母 \(x\) 值偏小的区域),因此这些参数并不是彻底任意地选取的。

grid_points = 2.5 ** np.arange(-12, 6)
grid_weights = np.log(2.5) * grid_points
grid_points
array([ 0.00001678,  0.00004194,  0.00010486,  0.00026214,  0.00065536,  0.0016384 ,  0.004096  ,  0.01024   ,  0.0256    ,  0.064     ,  0.16      ,
        0.4       ,  1.        ,  2.5       ,  6.25      , 15.625     , 39.0625    , 97.65625   ])

举例来说,如果我们要用格点近似 \(2^{-1} = 0.5 \simeq w_g e^{- 2 t_g}\),那么程序可以编写如下:

(grid_weights * np.exp(- 1 * grid_points)).sum()
1.0001747291147214

这应该是一个比较有效的近似了。

LT 数值积分精度#

对于更广泛的数值精度分析,我们在上述 LT 格点的条件下,给出如下的图像:

fig, ax = plt.subplots()

x_axis = np.logspace(-2, 3, 100)
y_axis = (grid_weights * np.exp(- x_axis[:, None] * grid_points)).sum(axis=-1) * x_axis
ax.plot(x_axis, y_axis)
ax.plot([1e-2, 1e3], [1.001, 1.001], color="C1", linestyle=":")
ax.plot([1e-2, 1e3], [1, 1], color="black", linestyle=":")
ax.plot([1e-2, 1e3], [0.999, 0.999], color="C1", linestyle=":")
ax.plot([1e-1, 1e-1], [0, 2], color="C2", linestyle=":")
ax.plot([1e2, 1e2], [0, 2], color="C2", linestyle=":")
ax.set_ylim(0.99,1.002)
ax.set_xscale("log")

ax.set_xlabel("$x$")
ax.set_ylabel("Relative error")
ax.set_title("Ratio of $\sum_g w_g e^{-x t_g}$ to $1/x$")
Text(0.5, 1.0, 'Ratio of $\\sum_g w_g e^{-x t_g}$ to $1/x$')

上图表示的是在不同 \(x\) 取值下,我们所给出的格点积分 \(w_g e^{-x t_g}\) 的精度;在 \(x \in (0.1, 100)\) 的区间内我们仍然能保证 \(0.1\%\) 的精度,但超过这些区域之后,结果就会迅速地变差。

我们知道,若分子呈现近简并的情形,那么 \(x = D_{ij}^{ab}\) 会非常小;在这种情况下,LT 所提供的格点积分就很可能无法正确地描述分母 \(D_{ij}^{ab}\) 的行为,使得本来在近简并就无法正常计算的 MP2 的情况雪上加霜。但若分子的 HOMO/LUMO 的能级差足够大并且大于 \(0.1\) 个 Hartree,那么我们应当预期当前格点下的 LT 近似可以几乎精确地给出相关能。

LT SOS MP2#

现在我们就可以用上刚才的技术,对 LT SOS MP2 进行计算。这并不会提升任何计算效率;这里只是用来验证我们方才作的 LT 近似的有效性。

在 LT 近似下,SOS 能量可以表达为

\[\begin{split} \begin{align} E_\text{SOS-MP2, corr} &= 1.3 \frac{1}{D_{ij}^{ab}} (ia|jb)^2 \\ &= - 1.3 (ia|jb)^2 \int_0^{+\infty} e^{D_{ij}^{ab} t} \, \mathrm{d} t \\ &\simeq -1.3 w_g (ia|jb)^2e^{D_{ij}^{ab} t_g} \end{align} \end{split}\]

这里的公式的正负号可能不太直观。这是因为在这篇文档的记号下,\(D_{ij}^{ab} < 0\)。我们通过如下代码,给出 LT SOS MP2 能量:

E_SOS_LT = - 1.3 * np.einsum("g, iajb, giajb ->", grid_weights, eri_iajb**2, np.exp(D_iajb * grid_points[:, None, None, None, None]))
print("LT-SOS-MP2 corr      in mH: ", E_SOS_LT * 1000)
print("Deviation to SOS-MP2 in mH: ", (E_SOS_LT - E_SOS_normal) * 1000)
LT-SOS-MP2 corr      in mH:  -344.2429384155852
Deviation to SOS-MP2 in mH:  0.00890840548417593

我们可以发现这是一个有效的近似。

LT-DF SOS MP2#

DF 环境设置#

在继续文档之前,我们需要对 DF 的环境作一些简单的设置。

auxmol = mol.copy()
auxmol.basis = "cc-pvtz-ri"
auxmol.build()

nao_df = auxmol.nao
int2c2e = auxmol.intor("int2c2e")
int2c2e.shape
int3c2e = df.incore.aux_e2(mol, auxmol)

int2c2e_half = scipy.linalg.cholesky(int2c2e, lower=True)
V_df_mp2 = scipy.linalg.solve_triangular(int2c2e_half, int3c2e.reshape(-1, nao_df).T, lower=True)\
               .reshape(nao_df, nao, nao).transpose((1, 2, 0))

尽管这里的 \(V_{ia, P}\) V_df_ia 与前一篇文档中的 \(V_{ia, P}^\mathrm{MP2}\) 是相同的,但需要注意到这里的轨道 \(i\) 是价层的占据轨道,而不能是冻结的占据轨道。

V_df_ia = np.einsum("uvP, ui, va -> iaP", V_df_mp2, Cf, Cv)
LT-DF SOS MP2 表达式#

我们对上面提到的 LT SOS MP2 的近似表达式再作 DF 近似:

\[\begin{split} \begin{align} E_\text{SOS-MP2, corr} &\simeq -1.3 w_g (ia|jb)^2 e^{D_{ij}^{ab} t_g} \\ &\simeq -1.3 w_g V_{ia, P} V_{jb, P} V_{ia, Q} V_{jb, Q} e^{D_{ij}^{ab} t_g} \\ &= -1.3 w_g V_{ia, P} V_{jb, P} V_{ia, Q} V_{jb, Q} e^{D_i^a t_g} e^{D_j^b t_g} \end{align} \end{split}\]

注意到最后一个等号利用到 \(D_{ij}^{ab} = D_i^a + D_j^b\)

E_SOS_LTDF = - 1.3 * np.einsum("g, iaP, jbP, iaQ, jbQ, gia, gjb ->",
                               grid_weights, V_df_ia, V_df_ia, V_df_ia, V_df_ia,
                               np.exp(D_ia * grid_points[:, None, None]),
                               np.exp(D_ia * grid_points[:, None, None]))
print("LT-DF-SOS-MP2 corr   in mH: ", E_SOS_LTDF * 1000)
print("Deviation to SOS-MP2 in mH: ", (E_SOS_LTDF - E_SOS_normal) * 1000)
LT-DF-SOS-MP2 corr   in mH:  -344.1316103156453
Deviation to SOS-MP2 in mH:  0.12023650542408726

我们认为这基本重复出了原始的 SOS MP2 能量了。

简化公式表达#

显然上面的公式表达太过于繁杂。在实际实现时,一般先将其中的三个张量缩并为 X_gPQ

\[ X_{gPQ} = V_{ia, P} V_{ia, Q} e^{D_i^a t_g} \]
X_gPQ = np.einsum("iaP, iaQ, gia -> gPQ", V_df_ia, V_df_ia, np.exp(D_ia * grid_points[:, None, None]))

那么 LT-DF 近似后的 SOS MP2 表达式可以简写为

\[ E_\text{SOS-MP2, corr} \simeq -1.3 w_g X_{gPQ}^2 \]
E_SOS_LTDF_simp = - 1.3 * np.einsum("g, gPQ ->", grid_weights, X_gPQ**2)
print("LT-DF-SOS-MP2 corr   in mH: ", E_SOS_LTDF_simp * 1000)
print("Deviation to SOS-MP2 in mH: ", (E_SOS_LTDF_simp - E_SOS_normal) * 1000)
LT-DF-SOS-MP2 corr   in mH:  -344.13161031563544
Deviation to SOS-MP2 in mH:  0.12023650543391273
计算效率比较#

这里我们对 SOS MP2,LT SOS MP2 与 LT-DF SOS MP2 的计算效率进行比较,工具是 np.einsum_path。我们假定内存的空间是足够多的,并且不考虑为生成各种积分、或各种 DF 三中心积分所需要耗费的时间。

普通 MP2

效率耗时最大的部分在双电子积分转换过程。

print(np.einsum_path("ui, va, uvkl, kj, lb -> iajb",
    Cf, Cv, mol.intor("int2e"), Cf, Cv,
    optimize=["greedy", 1024 ** 3 * 2 / 8])[1])  # Given 2GB memory space
  Complete contraction:  ui,va,uvkl,kj,lb->iajb
         Naive scaling:  8
     Optimized scaling:  5
      Naive FLOP count:  3.801e+14
  Optimized FLOP count:  2.487e+09
   Theoretical speedup:  152841.286
  Largest intermediate:  9.365e+06 elements
--------------------------------------------------------------------------
scaling                  current                                remaining
--------------------------------------------------------------------------
   5               uvkl,ui->iklv                      va,kj,lb,iklv->iajb
   5               iklv,kj->ijlv                         va,lb,ijlv->iajb
   5               ijlv,va->ijal                            lb,ijal->iajb
   5               ijal,lb->iajb                               iajb->iajb

DF SOS MP2

效率耗时最大的部分在从 3c2e 积分生成双电子积分过程。

print(np.einsum_path("iaP, jbP -> iajb",
    V_df_ia, V_df_ia, 
    optimize=["greedy", 1024 ** 3 * 2 / 8])[1])  # Given 2GB memory space
  Complete contraction:  iaP,jbP->iajb
         Naive scaling:  5
     Optimized scaling:  5
      Naive FLOP count:  2.368e+08
  Optimized FLOP count:  2.368e+08
   Theoretical speedup:  1.000
  Largest intermediate:  4.199e+05 elements
--------------------------------------------------------------------------
scaling                  current                                remaining
--------------------------------------------------------------------------
   5               jbP,iaP->iajb                               iajb->iajb

LT-DF SOS MP2

效率耗时最大的部分在生成中间张量 \(X_{gPQ}\) 的过程。

print(np.einsum_path("iaP, iaQ, gia -> gPQ",
    V_df_ia, V_df_ia, np.exp(D_ia * grid_points[:, None, None]),
    optimize=["greedy", 1024 ** 3 * 2 / 8])[1])  # Given 2GB memory space
  Complete contraction:  iaP,iaQ,gia->gPQ
         Naive scaling:  5
     Optimized scaling:  5
      Naive FLOP count:  2.783e+09
  Optimized FLOP count:  1.858e+09
   Theoretical speedup:  1.497
  Largest intermediate:  3.289e+06 elements
--------------------------------------------------------------------------
scaling                  current                                remaining
--------------------------------------------------------------------------
   4               gia,iaP->igaP                            iaQ,igaP->gPQ
   5               igaP,iaQ->gPQ                                 gPQ->gPQ

对于这个分子而言,放开优化张量缩并过程,结果反而会回到类似于 DF MP2 的缩并过程:

print(np.einsum_path("g, iaP, jbP, iaQ, jbQ, gia, gjb ->",
    grid_weights, V_df_ia, V_df_ia, V_df_ia, V_df_ia,
    np.exp(D_ia * grid_points[:, None, None]),
    np.exp(D_ia * grid_points[:, None, None]),
    optimize=["greedy", 1024 ** 3 * 2 / 8])[1])  # Given 2GB memory space
  Complete contraction:  g,iaP,jbP,iaQ,jbQ,gia,gjb->
         Naive scaling:  7
     Optimized scaling:  5
      Naive FLOP count:  4.207e+12
  Optimized FLOP count:  4.892e+08
   Theoretical speedup:  8600.264
  Largest intermediate:  4.199e+05 elements
--------------------------------------------------------------------------
scaling                  current                                remaining
--------------------------------------------------------------------------
   3                  gia,g->iga                iaP,jbP,iaQ,jbQ,gjb,iga->
   5               jbP,iaP->ijab                   iaQ,jbQ,gjb,iga,ijab->
   5               jbQ,iaQ->ijab                      gjb,iga,ijab,ijab->
   4             ijab,ijab->ijab                           gjb,iga,ijab->
   5               ijab,gjb->iga                                iga,iga->
   3                   iga,iga->                                       ->

简单总结

事实上,LT-DF SOS MP2 的优势并不体现在小分子的计算上,这从刚才的效率分析上就能看出来。但是,对于大分子 (占据电子数较多的情况,而非基组非常庞大的情况),LT-DF MP2 就有不小的优势。这是因为耗时最大的部分的时间复杂度分别是

  • LT-DF SOS MP2: \(O(n_\mathrm{grid} n_\mathrm{nocc} n_\mathrm{nvir} n_\mathrm{aux}^2)\)

  • DF SOS MP2: \(O(n_\mathrm{occ}^2 n_\mathrm{vir}^2 n_\mathrm{aux})\)

我们可能会认为 \(n_\mathrm{occ} < n_\mathrm{vir}\),而 \(n_\mathrm{aux}\) 在基组较大时是 \(n_\mathrm{vir}\) 的 3 倍左右。由于 \(n_\mathrm{grid}\) 不随体系增大而变化,因此在 \(n_\mathrm{grid} \ll n_\mathrm{occ}\) 的情况下,LT 的优势会凸显。

备注#

这份文档颜文杰提供了 QChem 代码中关于 7 点 LT 格点的支持;尽管最终文档使用了自定义的等比级数格点,但 7 点 LT 格点帮助了文档的完成的过程。


简单理解 SCF 中的 DIIS#

创建时间:2019-10-23

这篇文档将会简单地叙述 GGA 为代表的 SCF DIIS。

DIIS 是一种 (几乎) 专门用于自洽场过程加速的算法。关于 DIIS 的算法与数学论述,这里并不作展开。这里推荐 C. David Sherrill 的笔记 [1] 与 Psi4NumPy 的 Jupyter Notebook [2] 作为拓展阅读。

这篇笔记会借助 PySCF 的 DIIS 程序,对 Fock 矩阵进行外推。我们将描述在第 \(t\) 步 DIIS 过程之后,如何更新第 \(t+1\) 步的 Fock 矩阵。我们 并不会 从头写一个 DIIS 程序;这一方面是出于程序复杂性考虑,因为一般的 DIIS 程序应当要允许便利地增添、删除迭代过程中间的向量,能够处理解矩阵方程时会出现的线性依赖问题,并且要能保证一定的程序效率。另一方面,若对 DIIS 的更新过程有所了解,那么原则上我们已经理解了 DIIS 程序了,剩下的细节将只是时间与耐心上的问题。

import numpy as np
import scipy
from pyscf import gto, dft, lib
from functools import partial

np.einsum = partial(np.einsum, optimize=["greedy", 1024 ** 3 * 2 / 8])
np.set_printoptions(5, linewidth=150, suppress=True)

PySCF 的 DIIS 使用#

分子体系定义与 DIIS 程序#

首先,我们的分子体系是不对称的双氧水。

mol = gto.Mole()
mol.atom = """
O  0.0  0.0  0.0
O  0.0  0.0  1.5
H  1.0  0.0  0.0
H  0.0  0.7  1.0
"""
mol.basis = "6-31G"
mol.verbose = 0
mol.build()

nao = nmo = mol.nao
nocc = mol.nelec[0]
nvir = nmo - nocc
so, sv, sa = slice(0, nocc), slice(nocc, nmo), slice(0, nmo)

为了简化程序,我们借助 PySCF 的 DFT 自洽场类 scf_eng,用来生成 Fock 矩阵和密度矩阵。

scf_eng = dft.RKS(mol)
scf_eng.xc = "B3LYPg"
S = mol.intor("int1e_ovlp")
mo_occ = np.zeros(nmo)
mo_occ[:nocc] = 2

我们先简单地用下述程序展示 PySCF 中的 DIIS 是如何使用的。我们在后面会介绍 DIIS 类的具体的一些函数;这个用作演示用途的 DIIS 也时通过下述程序生成的。

下面的自洽场程序取自 pyxdh 文档 [3],但作了一些修改与简化。这个自洽场程序大体思路与 Szabo [4] 第三章的叙述契合,也与 Psi4NumPy 和 PySCF 的不少演示程序比较相似。

与 Szabo 第三章叙述不太一样的地方有两处。其中一行代码是

D = coef * D + (1 - coef) * D_old

这行代码仅仅是用来对 Naive SCF 作的修改。Szabo 的第三章可以称作是 Naive SCF,即单纯地将 Fock 矩阵对角化生成分子轨道,再得到密度代入 Fock 矩阵中。但这一行会将上一次迭代的密度 \(D_{\mu \nu}^{t-1}\) 与这一次迭代的密度 \(D_{\mu \nu}^{t}\) 混合,产生新的密度代入 Fock 矩阵中。这仅仅是为了防止 Naive SCF 振荡收敛,不会用于 DIIS 加速的算法。

另一行代码是

F = func(diis=diis, F=F, C=C, mo_occ=mo_occ)

这行代码是用于指定 DIIS 的更新。方式在这份文档中,DIIS 的更新方式有

  • func_no_special:Naive SCF,不引入 DIIS

  • diis_err_deviation:通过迭代过程中的 Fock 矩阵差值 \(\Delta F_{\mu \nu}^{t} = F_{\mu \nu}^{t} - F_{\mu \nu}^{t - 1}\) 更新 DIIS 状态

  • diis_err_gradient:通过占据-非占 Fock 矩阵 \(F_{ai}^{t}\) 更新 DIIS 状态

之所以用这种不太常见也不太直观的代码方式指定 DIIS 更新方式,单纯地是因为节省文档中的代码空间,避免代码冗余。

def scf_process(func, coef=1.0, maxcycle=128):
    diis = lib.diis.DIIS()
    
    C = e = NotImplemented                                # Orbital (canonical) coefficient
    D = np.zeros((nao, nao))                              # Density in this iteration
    D_old = np.zeros((nao, nao)) + 1e-4                   # Density in last iteration
    count = 0                                             # Iteration count (1, 2, ...)

    while (not np.allclose(D, D_old)):                    # atol=1e-8, rtol=1e-5
        if count > maxcycle:
            raise ValueError("SCF not converged!")
        count += 1
        D_old = D
        F = scf_eng.get_fock(dm=D)                        # Generate initial Fock matrix from Density
        if count > 1:                                     # avoid the case: C = NotImplemented
            F = func(diis=diis, F=F, C=C, mo_occ=mo_occ)  # Different DIIS approaches
            # func_no_special    : nothing happens
            # diis_err_deviation : F = diis.update(F)
            # diis_err_gradient  : F = diis.update(F, scf_eng.get_grad(C, mo_occ))
        e, C = scipy.linalg.eigh(F, S)                    # Solve FC = SCe
        D = scf_eng.make_rdm1(mo_coeff=C, mo_occ=mo_occ)  # D = 2 * C(occ).T @ C(occ)
        D = coef * D + (1 - coef) * D_old                 # For convergence of original SCF
            # func_no_special: D = 0.3 * D + 0.7 * D_old
            # other cases    : nothing happens

    E_tot = scf_eng.energy_tot(dm=D)

    print("SCF Converged in     ", count, " loops")
    print("Total energy (B3LYP) ", E_tot, " a.u.")
DIIS 方法加速效果#

现在我们可以来看每种 DIIS 更新方式会产生的效果了。

Naive SCF

def func_no_special(*args, **kwargs):
    return kwargs["F"]
scf_process(func_no_special, coef=0.3)
SCF Converged in      51  loops
Total energy (B3LYP)  -151.37754201912352  a.u.

DIIS:Fock 矩阵差值

def diis_err_deviation(*args, **kwargs):
    diis, F = kwargs["diis"], kwargs["F"]
    return diis.update(F)
scf_process(diis_err_deviation)
SCF Converged in      15  loops
Total energy (B3LYP)  -151.37754323564036  a.u.

DIIS:占据-非占 Fock 矩阵

def diis_err_gradient(*args, **kwargs):
    diis, F, C, mo_occ = kwargs["diis"], kwargs["F"], kwargs["C"], kwargs["mo_occ"]
    return diis.update(F, scf_eng.get_grad(C, mo_occ))
scf_process(diis_err_gradient)
SCF Converged in      22  loops
Total energy (B3LYP)  -151.37754323564045  a.u.

尽管从原理上,使用占据-非占 Fock 矩阵的方法应当更好;但在当前的体系下 Fock 矩阵差值的方法能更快地收敛。

我们能发现,若使用 PySCF 的 DIIS 类 (Psi4NumPy 的 helper_HF.pyDIIS_helper 类也是相似的),我们在实际进行 DIIS 时只需要相对于 Naive SCF 增加一行,非常方便地就可以加速收敛:

F = diis.update(F)  # diis_err_deviation

或者

F = diis.update(F, scf_eng.get_grad(C, mo_occ))  # diis_err_gradient

简单地说,这就是将 Fock 矩阵在每次迭代过程中,利用以前储存下来的 Fock 矩阵的信息进行再次更新。

DIIS 细节#

在这一段中,我们主要通过占据-非占 Fock 矩阵更新法的程序,对迭代 6 次时的 DIIS 状态进行较为细致的分析,并以此推导出第 6 次 DIIS 更新后的 Fock 矩阵。

第 6 次迭代时的 DIIS 类 diis 与更新前的 Fock 矩阵 F_old \(F_{\mu \nu}^{t=6}\)、更新后的 Fock 矩阵 F \(\mathscr{F}_{\mu \nu}\) 的获得方式是通过限制迭代次数而给出的:

diis = lib.diis.DIIS()

C = e = NotImplemented                                # Orbital (canonical) coefficient
D = np.zeros((nao, nao))                              # Density in this iteration
D_old = np.zeros((nao, nao)) + 1e-4                   # Density in last iteration
count = 0                                             # Iteration count (1, 2, ...)
F_old = NotImplemented                                # Variable in last iteration

while (not np.allclose(D, D_old)):                    # atol=1e-8, rtol=1e-5
    count += 1
    D_old = D
    F = scf_eng.get_fock(dm=D)                        # Generate initial Fock matrix from Density
    if count == 6:
        F_old = F.copy()
        F = diis.update(F, scf_eng.get_grad(C, mo_occ))
        break
    elif count > 1:                                   # avoid the case: C = NotImplemented
        F = diis.update(F, scf_eng.get_grad(C, mo_occ))
    
    e, C = scipy.linalg.eigh(F, S)                    # Solve FC = SCe
    D = scf_eng.make_rdm1(mo_coeff=C, mo_occ=mo_occ)  # D = 2 * C(occ).T @ C(occ)

这里补充一个程序细节,更新后的 Fock 矩阵 \(\mathscr{F}_{\mu \nu}\) 也可以通过 diis.extrapolate 获得:

np.allclose(F, diis.extrapolate().reshape(nmo, nmo))
True

diis.update 除了给出更新后的 Fock 矩阵外,还将当前的 Fock 矩阵与误差信息更新入 diis 中,为下一次迭代的 DIIS 更新作准备。

DIIS 储存内容#

一般来说,DIIS 储存两部分内容:待外推信息 \(p_I^t\) 与误差信息 \(e_J^t\)

我们在 SCF 过程中使用 DIIS 的目的是借助以前若干步迭代过程中的信息,对 Fock 矩阵作外推,得到在当前迭代步 \(t\) 下更好的 Fock 矩阵。因此,待外推信息 \(p_I^t\) 是第 \(t\) 次迭代过程计算得到的 Fock 矩阵 \(F_{\mu \nu}^t\)

这里会有些诡异的地方是,待外推信息 \(p_I^t\) 是单下标 \(I\) 的向量,但原子轨道基组下的 Fock 矩阵 \(F_{\mu \nu}^t\) 是双下标的矩阵。事实上,\(p_I^t\) 在实践过程中就是将 \(F_{\mu \nu}^t\) 压平成一维向量。待外推信息 \(p_I^t\) 可以通过 diis.get_vec 给出,我们将这些向量整合为 vecs 变量中,其角标储存是 \((t, I)\)

vecs = np.array([diis.get_vec(i) for i in range(diis.get_num_vec())])

我们记每次迭代的误差信息为 \(e_J^t\)。对于占据-非占 Fock 矩阵更新法而言,\(e_J^t\) 即是分子轨道基组下的非占-占据 Fock 矩阵 \(F_{ai}^t\)。我们知道,对于 SCF 收敛之后的状态下,\(F_{ai} = 0\);但这在自洽场迭代过程中,该量一般地并不是零,甚至说自洽场过程就是为了达成 \(F_{ai} = 0\) 的目的也不为过。因此,\(F_{ai}^t\) 的状况可以看作是自洽场是否收敛得较好的判标;于是我们定义 \(e_J^t\) 为压平之后的 \(F_{ai}^t\)

误差信息 \(e_J^t\) 可以通过 diis.get_err_vec 给出,我们将这些向量整合为 err_vecs 变量中,其角标储存是 \((t, J)\)

err_vecs = np.array([diis.get_err_vec(i) for i in range(diis.get_num_vec())])

我们指出,\(p_I^t\)\(e_J^t\) 下标所指代的维度未必要是一样的。

print(vecs.shape)
print(err_vecs.shape)
(5, 484)
(5, 117)

备注

从上述的叙述与代码中,能看到我们只进行了 6 次迭代,其中迭代过程的误差信息与待外推信息只储存了 5 次 (\(t\) 从 1 计数,外推信息的 \(t \in [2, 6]\))。我们定义当前作为迭代次数的上标 \(t\) 的集合是 \(\mathscr{T} = \{2, 3, 4, 5, 6\}\);但在 PySCF 的 DIIS 类 diis 中,通过程序取出这些向量的指标时则应当使用 0, 1, 2, 3, 4

我们知道,DIIS 为了进行外推,会储存许多待外推信息与误差信息;但对于大分子而言,这会占用许多内存空间。出于这个目的 (以及出于收敛性的考量),DIIS 通常只会存比较少量地待外推信息与误差信息。PySCF 的 DIIS 一般只储存 6 次迭代过程的信息。这意味着,若我们进行了 15 次迭代,待外推矩阵至多也只会储存 6 个,其余的待外推信息或误差信息都会舍去。

为简化讨论,我们在这篇文档中不讨论如何舍去已经储存的待外推信息与误差信息。

DIIS 外推:理论#

有了所有的待外推信息 \(p_I^t\) 与误差信息 \(e_J^t\) 后,我们可以作出外推结果 \(\mathscr{p}_I = \mathscr{F}_{\mu \nu}\)。上述公式中看似有问题的单下标转双下标可以通过互阵的 reshape 实现。

外推的含义是

\[ \mathscr{p}_I = \sum_{t \in \mathscr{T}} w_t p_I^t \]

\(\mathscr{T}\) 代表 DIIS 当前储存的每个外推信息对应的被迭代次数的集合,在这里恰好是从 2 开始的所有的被迭代次数。如果我们现在的迭代次数非常大,但只允许 DIIS 储存不多于 6 个待外推信息,那么求和指标 \(t\) 的取值范围 \(\mathscr{T}\) 将会舍去这些迭代次数,从而保持其集合元素数量 \(|\mathscr{T}|\) 不超过 6。

我们人为地引入权重 \(w_t\) 以归一条件:

\[ \sum_{t \in \mathscr{T}} w_t = 1 \]

如果我们假定待外推的信息 \(p_I^t\) 与对应的误差信息 \(e_J^t\) 呈线性关系,那么被外推的信息 \(\mathscr{p}_I\) 的误差 \(\mathscr{e}_J\) 应当满足

\[ \mathscr{e}_I = \sum_{t \in \mathscr{T}} w_t e_J^t \]

我们希望误差 \(\Vert \mathscr{e}_J \Vert_2^2\) 最小化,但同时又满足 \(w_t\) 的归一化条件;那么我们通过 Lagrange 乘子法,构造以下损失函数

\[\begin{split} \begin{align} \mathscr{L} (\{w_t\}_{t \in \mathscr{T}}, \lambda) &= \Vert \mathscr{e}_J \Vert_2^2 + 2 \lambda \left( \sum_{t \in \mathscr{T}} w_t - 1 \right) \\ &= \sum_J \sum_{t \in \mathscr{T}} w_t e_J^t \cdot \sum_{s \in \mathscr{T}} w_s e_J^s + 2 \lambda \left( \sum_{t \in \mathscr{T}} w_t - 1 \right) \end{align} \end{split}\]

我们现在定义

\[ B_{ts} = \sum_{J} e_J^t e_J^s \]

那么损失函数可以写为

\[ \mathscr{L} (\{w_t\}_{t \in \mathscr{T}}, \lambda) = \sum_{t, s \in \mathscr{T}} w_t B_{ts} w_s + 2 \lambda \left( \sum_{t \in \mathscr{T}} w_t - 1 \right) \]

对上述损失函数求取关于 \(w_t\) 的偏导数,则得到

\[ \frac{\partial \mathscr{L}}{\partial w_t} = 2 \sum_{s \in \mathscr{T}} B_{ts} w_s + 2 \lambda \]

我们显然是希望让损失函数对关于 \(w_t\) 的偏导数为零;那么联立归一化条件 \(\sum_{t \in \mathscr{T}} w_t = 1\),我们应当得到以下矩阵方程:

\[\begin{split} \begin{align} \begin{pmatrix} 0 & 1 & 1 & \cdots \\ 1 & B_{t_0 t_0} & B_{t_0 t_1} & \cdots \\ 1 & B_{t_1 t_0} & B_{t_1 t_1} & \\ \vdots & \vdots & & \ddots \\ \end{pmatrix} \begin{pmatrix} \lambda \\ w_{t_0} \\ w_{t_1} \\ \vdots \end{pmatrix} = \begin{pmatrix} 1 \\ 0 \\ 0 \\ \vdots \end{pmatrix} \end{align} \end{split}\]

其中,\(t_0, t_1, \cdots \in \mathscr{T}\) 是互不相同的指标。求解上述方程,就可以获得权重 \(w_t\),进而给出 \(\mathscr{F}_{\mu \nu} = \mathscr{p}_I = \sum_{t \in \mathscr{T}} w_t p_I^t\),达成目标。

DIIS 外推:实现#

首先,我们出,diis 的一个隐含变量 diis._H 储存的就是矩阵方程 LHS 的矩阵部分:

A = diis._H[:diis.get_num_vec()+1, :diis.get_num_vec()+1]
A
array([[ 0.     ,  1.     ,  1.     ,  1.     ,  1.     ,  1.     ],
       [ 1.     , 22.07074, -0.48488,  0.19048, -1.05937, -0.92243],
       [ 1.     , -0.48488, 21.30345,  0.23478,  1.63515, -0.41527],
       [ 1.     ,  0.19048,  0.23478,  1.35725, -0.18202,  0.38307],
       [ 1.     , -1.05937,  1.63515, -0.18202,  5.33326, -0.14979],
       [ 1.     , -0.92243, -0.41527,  0.38307, -0.14979,  1.18526]])

我们能很方便地构建上述矩阵的第 1 行以下、第 1 列以右的子矩阵 \(B_{ts} = \sum_{J} e_J^t e_J^s\)

np.einsum("tI, sI -> ts", err_vecs, err_vecs)
array([[22.07074, -0.48488,  0.19048, -1.05937, -0.92243],
       [-0.48488, 21.30345,  0.23478,  1.63515, -0.41527],
       [ 0.19048,  0.23478,  1.35725, -0.18202,  0.38307],
       [-1.05937,  1.63515, -0.18202,  5.33326, -0.14979],
       [-0.92243, -0.41527,  0.38307, -0.14979,  1.18526]])

我们可以直接解上述的矩阵方程:

b = np.zeros(diis.get_num_vec() + 1)
b[0] = 1
w = np.linalg.solve(A, b)
w = w[1:]
w
array([0.05123, 0.02428, 0.31624, 0.13904, 0.46921])

那么我们可以通过 \(\mathscr{F}_{\mu \nu} = \mathscr{p}_I = \sum_{t \in \mathscr{T}} w_t p_I^t\) 给出外推 Fock 矩阵 F_ex,并且与 diis 给出的外推的 Fock 矩阵 F 进行比较:

F_ex = np.einsum("t, tI -> I", w, vecs).reshape(nmo, nmo)
np.allclose(F_ex, F)
True

小技巧

在求解 DIIS 所给出的权重 \(w_t\) 向量的过程中,会遇到线性依赖关系,或者说会遇到 \(B_{ts}\) 数值上不满秩的情况。在这种情况下,求解矩阵方程可能会失败。

一种解决方案是,干脆关闭 DIIS,使用 Naive SCF 作最后的收尾工作。由于 DIIS 已经将电子态密度收敛到相当不错的状态了,因此应当能预期这种情况下 Naive SCF 可以正常地进行收敛。

另一种解决方式是对矩阵方程 \(\mathbf{A} \boldsymbol{x} = \boldsymbol{b}\) 的矩阵 \(\mathbf{A}\) 作对角化,并舍去其中绝对值极小的本征值与本征向量,求解一个子空间的线性方程组问题。这种解决方案应用在 PySCF 的 DIIS 程序中。

对 Fock 矩阵差值方法的补充说明#

Fock 矩阵差值方法的计算过程与占据-非占 Fock 矩阵方法的实现过程几乎是相同的。唯一的区别是:

  • 占据-非占 Fock 矩阵方法 \(e_J^t = F_{ai}^t\)

  • Fock 矩阵差值方法 \(e_J^t = \Delta F_{\mu \nu}^{t} = F_{\mu \nu}^{t} - F_{\mu \nu}^{t - 1}\)


简单理解真正 \(O(N^4)\) 的 MP2 方法:LS-THC-MP2#

创建时间:2021-09-18

在这份文档中,我们会简单地了解一种真正 \(O(N^4)\) 的 MP2 实现方法。它称为 LS-THC-MP2 (Least-Squares Tensor HyperContraction Second-order Møller-Plesset perturbation)。尽管这个概念应该早些时候 (自 2012 年起[1]) 有人提过,但这篇文档较多地使用 Devin A. Matthews[2] 的思路。

尽管很想说不要被这么长的名称吓到,但它一般来说还要求 LT-DF (Laplace-Transformation Density-Fitting),DF 也经常称为 RI (Resolution-Identity)。因此,本文的方法具体来说应当称为 LS-THC-LT-DF-MP2。事实上,这些麻烦的名称总地来说,都是矩阵或张量分解的思路。若对于矩阵或张量分解 (譬如 Cholesky、SVD 分解等) 和 MP2 方法本身有基本的了解,这篇文档应当是可以接受的。

计算化学方法的加速通常依赖两种策略:利用零值稀疏性本身 (剑宗?见招拆招),与利用稠密矩阵的低维分解 (气宗?万物皆准)。这篇文档明确地是使用后者的立场。该方法本身对于 MP2 的意义相对来说比较微妙,因为 LS-THC-MP2 恐怕只有在很庞大的体系下才比 DF-MP2 更快。但对于 CC2 或 MP3 等传统上需要 \(O(N^5)\) 甚至 \(O(N^6)\) 的方法 (或许包括最近兴起的 ADC(2) 方法),在数百个电子的体系下,从高标度有效地加速到 \(O(N^4)\) 确实是可能且实用的。若不考虑问题的稀疏性,这意味着我们曾经一直视为代价巨大的一些后自洽场 (Post-SCF) 方法,其计算复杂度甚至内存复杂度与没有任何积分加速的自洽场是对等的。到 \(O(N^4)\) 为止,基本可以认为基于稠密矩阵的一部分 Post-SCF 方法的计算复杂度已经优化到极限了;剩下的问题是如何利用稀疏性、以及是否可以在相同复杂度下作计算简化。

本文档始终使用经过变通的 Einstein Summation Convention。这份文档一定程度上包含了对 DF-MP2LT-DF-SOS-MP2 的简单介绍,但稍详细的讨论可以参考这两份文档。我们会利用许多 PySCF 所提供的便利接口。

警告

这份文档的实现不一定等同于文献的实现方式,也不能保证其在任意体系的正确性。这份文档仅仅是叙述 LS-THC 方法,并尝试给出作者自己所理解的模型实现而已。

%matplotlib notebook
from pyscf import gto, scf, df, mp, dft, ao2mo
from pyscf.dft.numint import NumInt
from pyscf.dft import Grids
from pyscf.ao2mo import _ao2mo
import numpy as np
import scipy
from opt_einsum import contract as einsum
import matplotlib.pyplot as plt

np.set_printoptions(6, suppress=True, linewidth=150)

准备工作#

分子体系定义#

我们在这篇文档使用闭壳层 cc-pVQZ 的水分子。使用这么小的分子显然是大材小用了,因为 LS-THC 即使是对于 MP3 也要求在数百个电子的体系才能观察到效率飞跃,对于 MP2 更是如此;但足够说明问题就行。

mol = gto.Mole(atom="O; H 1 0.94; H 1 0.94 2 104.5", basis="cc-pVQZ", verbose=0).build()
nocc, nao, nmo = mol.nelec[0], mol.nao, mol.nao
nvir = nmo - nocc
so, sv, sa = slice(0, nocc), slice(nocc, nmo), slice(0, nmo)

下面的代码给出自洽场实例、分子轨道与轨道能量:

mf = scf.RHF(mol).run()
C, e = mf.mo_coeff, mf.mo_energy
Co, Cv = C[:, so], C[:, sv]
eo, ev = e[so], e[sv]

下面的代码给出辅助基 (auxiliary basis) 的定义:

with_df = df.DF(mol, df.make_auxbasis(mol, mp2fit=True))
naux = with_df.get_naoaux()

当前的问题下,一些重要的体系大小表征值是

  • \(n_\mathrm{occ} = 5\) 占据轨道数

  • \(n_\mathrm{vir} = 110\) 非占轨道 (虚轨道) 数

  • \(n_\mathrm{AO} = n_\mathrm{MO} = 115\) 分子轨道数

  • \(n_\mathrm{aux} = 242\) 辅助基数

  • \(n_\mathrm{occ} n_\mathrm{vir} = 550\)

nocc, nvir, nao, naux, nocc*nvir
(5, 110, 115, 242, 550)
未加速与近似的 MP2 相关能#

我们再次回顾 MP2 的相关能计算。MP2 相关能可以表示为

\[ E_\mathsf{corr} = 2 g_{ij}^{ab} g_{ij}^{ab} / D_{ij}^{ab} - g_{ij}^{ab} g_{ij}^{ba} / D_{ij}^{ab} \]

特别地,相关能还可以拆分为自旋相反部分 (OS, Opposite-Spin) 与相同部分 (SS, Same-Spin):

\[\begin{split} \begin{align*} E_\textsf{OS} &= g_{ij}^{ab} g_{ij}^{ab} / D_{ij}^{ab} \\ E_\textsf{SS} &= g_{ij}^{ab} g_{ij}^{ab} / D_{ij}^{ab} - g_{ij}^{ab} g_{ij}^{ba} / D_{ij}^{ab} = \frac{1}{2} (g_{ij}^{ab} - g_{ij}^{ba})^2 / D_{ij}^{ab} \end{align*} \end{split}\]

其中,

  • g \(g_{ij}^{ab} = C_{\mu i} C_{\nu a} (\mu \nu | \kappa \lambda) C_{\kappa j} C_{\lambda b}\) (dim: \(i, a, j, b\)) 分子轨道下的双电子积分

  • D \(D_{ij}^{ab} = \varepsilon_i - \varepsilon_a + \varepsilon_j - \varepsilon_b\) (dim: \(i, a, j, b\)) MP2 分母

g = ao2mo.incore.general(mol.intor("int2e"), (Co, Cv, Co, Cv), compact=False)
D = eo[:, None, None, None] - ev[None, :, None, None] + eo[None, None, :, None] - ev[None, None, None, :]
corr_simple_os = einsum("iajb, iajb, iajb ->", g, g, 1/D)
corr_simple_ss = corr_simple_os - einsum("iajb, ibja, iajb ->", g, g, 1/D)
print("==> [Simple MP2 OS corr energy]         : {:20.12f}".format(corr_simple_os))
print("==> [Simple MP2 SS corr energy]         : {:20.12f}".format(corr_simple_ss))
print("==> [Simple MP2 corr energy]            : {:20.12f}".format(corr_simple_os + corr_simple_ss))
print("==> [Simple MP2 corr energy from PySCF] : {:20.12f}".format(mp.MP2(mf).run().e_corr))
==> [Simple MP2 OS corr energy]         :      -0.240427624844
==> [Simple MP2 SS corr energy]         :      -0.071748736656
==> [Simple MP2 corr energy]            :      -0.312176361500
==> [Simple MP2 corr energy from PySCF] :      -0.312176361500

光从能量表达式来看,好像只涉及到 \(i, j, a, b\) 四个分子轨道角标的运算与求和;但导致 MP2 能量计算耗时的主要因素在于 \(g_{ij}^{ab}\) 的构造过程,它是五次方计算量。一般来说,计算量最大的一步出现在

\[ (i \nu | \kappa \lambda) = C_{\mu i} (\mu \nu | \kappa \lambda) \]

这个单步运算涉及到 \(i, \mu, \nu, \kappa, \lambda\) 五个角标,因此是 \(O(n_\mathrm{occ} n_\mathrm{AO}^4)\) 复杂度。而且由于硬盘通常无法储存所有的原子轨道 ERI \((\mu \nu | \kappa \lambda)\),因此该计算过程对大体系而言还会出现 \(O(N^4) \sim O(N^5)\) 的多次电子积分计算过程。

一次加速:Density Fitting#

背景:对 ERI 积分张量的本征值分解近似#

我们回顾 Density Fitting 的提出动因。一般来说,原子轨道的双电子积分 (ERI, Electron-Repulsion Integral)

\[ (\mu \nu | \kappa \lambda) = \iint \phi_\mu (\boldsymbol{r}_1) \phi_\nu (\boldsymbol{r}_1) \frac{1}{|\boldsymbol{r}_1 - \boldsymbol{r}_2|} \phi_\kappa (\boldsymbol{r}_2) \phi_\lambda (\boldsymbol{r}_2) \, \mathrm{d} \boldsymbol{r}_1 \, \mathrm{d} \boldsymbol{r}_2 \]

如果作为 \(n_\mathrm{AO}^2 \times n_\mathrm{AO}^2 = 13225 \times 13225\) 的矩阵,是非常不满秩的。如果我们对该矩阵作本征值分解,会发现大于 \(10^{-5} \, \text{a.u.}\) 的本征值只有 782 个。如果只使用者 782 个本征值与本征向量重构近似的原子轨道的双电子积分 ERI,我们可以得到与精确值误差只有 \(10^{-6} \, \text{a.u.}\) 的 MP2 相关能。事实上,我们还能承受更加激进得多的近似。

eri = mol.intor("int2e").reshape(nao**2, nao**2)
eig_eri, coef_eri = scipy.linalg.eigh(eri)
(eig_eri > 1e-5).sum()
782
Y_ao_approx = coef_eri[:, -782:] * eig_eri[-782:]**0.5
eri_approx = (Y_ao_approx @ Y_ao_approx.T).reshape(nao, nao, nao, nao)
g_approx = ao2mo.incore.general(eri_approx, (Co, Cv, Co, Cv), compact=False)
corr_approx = 2 * einsum("iajb, iajb, iajb ->", g_approx, g_approx, 1/D) - einsum("iajb, ibja, iajb ->", g_approx, g_approx, 1/D)
print("==> [MP2 corr energy by approx eri]     : {:20.12f}".format(corr_approx))
print("==> [MP2 corr error from approx eri]    : {:20.12f}".format(corr_approx - (corr_simple_os + corr_simple_ss)))
==> [MP2 corr energy by approx eri]     :      -0.312175302330
==> [MP2 corr error from approx eri]    :       0.000001059170
Density Fitting 公式、程序与结果#

基于 ERI 张量严重不满秩的特性 (一定程度上表明张量数学结构上的稀疏性,而非因为张量有很多零值),我们有可能大量地简化计算。将 ERI 拆分成两个张量的乘积:

\[ (\mu \nu | \kappa \lambda) = Y_{\mu \nu, P} Y_{\kappa \lambda, P} \]

这种拆分方式并非是唯一的。我们方才在程序中定义的 Y_ao_approx 就是一种做法,其中角标 \(P\) 所代表的维度是 782 维度。但这种拆解需要求解本征问题 (求解本征问题实际上是 \(O(n_\mathrm{AO}^6)\) 计算复杂度),反而增加了计算耗时;并且 782 维度对于水分子的 cc-pVQZ 实在太大,并不经济。

为了解决这些问题,从 1990 年代发展出一套称为 Density Fitting 的方法,能有效地在 \(O(n_\mathrm{AO}^4)\) 或更低标度,给出对 ERI 的张量分解。这种分解并不类似于数学中的 Cholesky 或 SVD 分解;数学所讨论的分解需要提前计算矩阵并将矩阵储存在内存,但比较流行的 DF 方法通过引入辅助基 (Auxiliary Basis Set) 的方式,不需要了解分子形状或预先计算 ERI 就可以进行矩阵分解。

我们已经知道对于当前的水分子 cc-pVQZ 而言,辅助基大小是 \(n_\mathrm{aux} = 242\)。DF 的方式不是唯一的;我们这里使用 PySCF 默认的方式 (RI-V)。我们通过下述代码生成 Y_ov \(Y_{ia, P} = Y_{\mu \nu, P} C_{\mu i} C_{\nu a}\) (dim: \(i, a, P\)) ERI 在辅助基下的拆解张量 (由于 PySCF 的实现方式,通常也称为 Cholesky 3c-2e (3-center 2-electron) 积分)。

Y_ov = _ao2mo.nr_e2(next(with_df.loop(naux)), C, (0, nocc, nocc, nmo), aosym="s2", mosym="s1") \
             .reshape(naux, nocc, nvir).transpose(1, 2, 0)

那么分子轨道下的双电子积分可以近似为

\[ g_{ij}^{ab} \simeq Y_{ia, P} Y_{jb, P} \]

上述的单步计算过程由于设计角标 \(i, j, a, b, P\),因此计算复杂度是 \(O(n_\mathrm{nocc}^2 n_\mathrm{vir}^2 n_\mathrm{aux})\)。尽管也是 \(O(N^5)\) 的复杂度,但若基组较大或者硬盘可以存下所有 \(Y_{\mu \nu, P}\),那么 DF-MP2 方法相比于没有任何数值近似的 MP2 方法的效率完全就是划得来甚至优势巨大的。这个真的是谁用谁知道啊,我以前算过的大多数不算太大的分子都是 DF-MP2 比 \(O(n_\mathrm{AO}^4)\) 的自洽场快多了。而 DF-MP2 所引入的数值误差只有 \(2 \times 10^{-5} \, \mathrm{a.u.} \sim 0.01 \, \mathrm{kcal/mol}\),是可以接受的。

g_df = einsum("iaP, jbP -> iajb", Y_ov, Y_ov)
corr_df_os = einsum("iajb, iajb, iajb ->", g_df, g_df, 1/D)
corr_df_ss = corr_simple_os - einsum("iajb, ibja, iajb ->", g_df, g_df, 1/D)
print("==> [DF-MP2 OS corr energy]             : {:20.12f}".format(corr_df_os))
print("==> [DF-MP2 SS corr energy]             : {:20.12f}".format(corr_df_ss))
print("==> [DF-MP2 corr energy]                : {:20.12f}".format(corr_df_os + corr_df_ss))
print("==> [DF-MP2 corr error]                 : {:20.12f}".format((corr_df_os + corr_df_ss) - (corr_simple_os + corr_simple_ss)))
==> [DF-MP2 OS corr energy]             :      -0.240393392264
==> [DF-MP2 SS corr energy]             :      -0.071794050091
==> [DF-MP2 corr energy]                :      -0.312187442355
==> [DF-MP2 corr error]                 :      -0.000011080854

对 OS 部分的二次加速:LT 方法#

LT (Laplace-Transform) 方法尽管套了 Laplace 的大名,但和 Fourier 变换或者数理方程所学的 Laplace 变换其实不太一样。真正需要我们利用的性质其实是下述非常简单的积分式与其数值格点积分的近似:

\[ x^{-1} = \int_0^{+ \infty} e^{- x t} \, \mathrm{d} t \simeq x^{-1} \simeq w_l e^{- x t_l} \]

上式的最右边是要对数值积分格点角标 \(l\) 进行求和的;lt_w \(w_l\) 是积分权重、lt_x \(t_l\) 表示格点位置。我们在这份文档中取下述粗糙的代码所给出的 \(n_\textrm{LT} = 18\) 个格点;但需要指出,只要 MP2 分母 \(D_{ij}^{ab}\) 的绝对值在 \(0.01 \, \mathrm{a.u.}\)\(400 \, \mathrm{a.u.}\) 之间,那么所需的格点数量至少可以缩减到 10 个。尽管我们在分析复杂度时会考虑 LT 格点数 \(n_\textrm{LT}\),但需要知道它在不随体系增大而增大。

lt_x = 2.5 ** np.arange(-12, 6)
lt_w = np.log(2.5) * lt_x

因此,很有意思地,\((D_{ij}^{ab})^{-1}\) 的计算变成了张量运算:

\[ (D_{ij}^{ab})^{-1} = - (- D_{ij}^{ab})^{-1} = w_l e^{D_{ij}^{ab} t_l} = - w_l e^{(\varepsilon_i - \varepsilon_a + \varepsilon_j - \varepsilon_b) t_l} \]

如果我们进而定义

  • go \(g_i^l = w_l^{1/4} e^{\varepsilon_i t_l}\) (dim: \(i, l\))

  • gv \(g_a^l = w_l^{1/4} e^{- \varepsilon_a t_l}\) (dim: \(a, l\))

go = lt_w**0.25 * np.exp( eo[:, None] * lt_x)
gv = lt_w**0.25 * np.exp(-ev[:, None] * lt_x)

那么

\[ (D_{ij}^{ab})^{-1} = - g_i^l g_a^l g_j^l g_b^l \]

进一步地,

\[ E_\textsf{OS} = g_{ij}^{ab} g_{ij}^{ab} / D_{ij}^{ab} \simeq - Y_{ia,P} Y_{jb,P} Y_{ia,Q} Y_{jb,Q} g_i^l g_a^l g_j^l g_b^l \]

看起来这个表达式很复杂,但其计算复杂度确实是 \(O(n_\mathrm{LT} n_\mathrm{occ} n_\mathrm{vir} n_\mathrm{aux}^2)\)\(O(N^4)\) 的。

#                        g_ovov    , g_ovov    , 1/D_ovov
corr_ltdf_os = - einsum("iaP , jbP , iaQ , jbQ , il, al, jl, bl ->",
                         Y_ov, Y_ov, Y_ov, Y_ov, go, gv, go, gv)
print("==> [LT-DF-MP2 OS corr energy]          : {:20.12f}".format(corr_ltdf_os))
print("==> [LT-DF-MP2 OS corr error from LT]   : {:20.12f}".format(corr_ltdf_os - corr_df_os))
print("==> [LT-DF-MP2 OS corr error from DF]   : {:20.12f}".format(corr_df_os - corr_simple_os))
==> [LT-DF-MP2 OS corr energy]          :      -0.240362101359
==> [LT-DF-MP2 OS corr error from LT]   :       0.000031290905
==> [LT-DF-MP2 OS corr error from DF]   :       0.000034232581

在当前的 LT 格点设置下,因 LT 所带来的误差接近于因 DF 所带来的误差。

需要说明的是,只有 MP2 的 OS 部分可以由 LT 加速;SS 部分在 LT 下一般只会花更多时间。

三次加速:LS-THC#

背景#

但我们不止步于 DF (Density-Fitting) 的拆解 \(g_{ij}^{ab} \simeq Y_{ia, P} Y_{jb, P}\);我们希望作更进一步的拆解。其原因是,一方面来说,即使是 DF 也无法将 MP3, CC2 等方法降低到 \(O(N^4)\) 标度;另一方面,\(Y_{ia, P}\) 仍然是 \(O(N^3)\) 的张量,一旦我们需要经常性复用张量 \(Y_{ia, P}\),那么就意味着必须要准备一块 \(O(N^3)\) 的硬盘或内存。这对于较大的体系而言,是不太容易承受的。

因此就需要寻找一种将 \(g_{ij}^{ab}\) 拆分为 \(O(N^2)\) 张量乘积的方法。拆分的大体思路是

\[ g_{ij}^{ab} \simeq X_i^R X_a^R V_{RS} X_j^S X_b^S \]

需要注意的是,这里的角标 \(R, S\) 并非表示辅助基,且一般来说其索引范围要比辅助基大一些。这种张量拆分在化学中称为 THC (Tensor HyperContraction)。

当然,知道拆分的思路不意味着计算量就真的得以简化了,而且拆分必然伴随着一定程度的精度损失;为此至少要回答两个问题。第一是,张量拆分的思路不只一种,完全也可能是 \(g_{ij}^{ab} \simeq X_i^R X_a^R X_j^R X_b^R\);所以为何我们不选用后者的拆分而选用前者。第二是,拆分张量的计算量不应超过 \(O(N^4)\),且应当有小至少一个数量级的内存消耗;我们要如何保证张量拆分的效率,同时又有可以控制的拆分精度损失?

对于这两个问题,我们的文档中不会作说明。这些恐怕都是前人的经验教训了。我们后文只对第二个问题作实现上的说明。

这里补充说明一些,尽管现在有自动化的张量拆分库,譬如 TensorLy 可以对张量作 CANDECOMP/PARAFAC 或 Tucker 拆分,但这些算法为了达到所需精度需要大量的迭代次数,不一定能保证部分对称张量在拆分时所需要满足的对称性要求,拆分有很强的随机性,而且张量经常需要全部存到内存中。这种自动化的、不需要对张量本身性质作了解的拆分方式,或许会在机器学习的神经网络层或图像识别与压缩中发挥巨大优势,但在化学庞大的张量下使用可能是不合适的。

轨道 (母) 格点拆分#

我们重新回顾分子轨道 ERI 积分的定义:

\[ g_{ij}^{ab} = \iint \phi_i (\boldsymbol{r}_1) \phi_a (\boldsymbol{r}_1) \frac{1}{|\boldsymbol{r}_1 - \boldsymbol{r}_2|} \phi_j (\boldsymbol{r}_2) \phi_b (\boldsymbol{r}_2) \, \mathrm{d} \boldsymbol{r}_1 \, \mathrm{d} \boldsymbol{r}_2 \]

我们从数值积分的角度对上式作重新解释。\(\boldsymbol{r}_1\) 数值积分坐标集合是 \(\{ \boldsymbol{r}_R \}\),其对应的求和权重是 \(\{ w_R \}\)\(\boldsymbol{r}_2\) 数值积分坐标集合是 \(\{ \boldsymbol{r}_S \}\),其对应的求和权重是 \(\{ w_S \}\)。那么,

\[ g_{ij}^{ab} \simeq w_R w_S \times \phi_i (\boldsymbol{r}_R) \phi_a (\boldsymbol{r}_R) \frac{1}{|\boldsymbol{r}_R - \boldsymbol{r}_S|} \phi_j (\boldsymbol{r}_S) \phi_b (\boldsymbol{r}_S) \]

一种容易想到的思路是,我们定义

  • Xo \(X_i^R = w_R^{1/2} \phi_i(\boldsymbol{r}_R)\) (dim: \(i, R\)) 加权的占据轨道 \(i\) 在坐标 \(\boldsymbol{r}_R\) 下的数值;

  • Xv \(X_a^R = w_R^{1/2} \phi_a(\boldsymbol{r}_R)\) (dim: \(a, R\)) 加权的非占轨道 \(a\) 在坐标 \(\boldsymbol{r}_R\) 下的数值;

这种拆分思路也可以说是依格点拆分。由于后文才会讲述的原因,这边称该格点为母格点 (parent grid)。下述程序所给出的格点相当于 Matthews 文章[2] 所述的 \((L_\mathrm{max}, N_1, N_\textsf{H}) = (11, 19, 11)\) 格点,即角向采用 Lebedev 精确到 11 阶的格点 (共 50 个角向格点)、第二周期径向 19 格点、氢原子径向 11 格点。因此,\(n_\mathrm{gird}^\mathrm{parent} = 2250\)

ni = NumInt()
grids = Grids(mol)
grids.atom_grid = {"O": (19, 50), "H": (11, 50)}
grids.prune = None
grids.build()
n_parent = grids.size
n_parent
2050

定义完格点后,我们自然地可以给出 Xo \(X_i^R\)Xv \(X_a^R\)

Xao = ni.eval_ao(mol, grids.coords)
Xo = einsum("Ru, ui, R -> iR", Xao, Co, grids.weights**0.5)
Xv = einsum("Ru, ua, R -> aR", Xao, Cv, grids.weights**0.5)

尽管说,我们如果定义 \(U_{RS} = |\boldsymbol{r}_R - \boldsymbol{r}_S|^{-1}\),那么依据格点积分的定义,容易推知

\[ g_{ij}^{ab} \simeq X_i^R X_a^R U_{RS} X_j^S X_b^S \]

但这个格点可以说是非常小了,比 SG-1 恐怕还小一个数量级,完全无法用于 DFT 的格点积分。因此,如果真的用上面定义的 \(X_i^R, X_a^R, U_{RS}\) 来近似 ERI \(g_{ij}^{ab}\),这就显得太粗糙了。

最小二乘 (LS) 的应用#

即使上面的表达式不能直接利用,我们仍然要说,上面的分析是很有帮助的。由于一般来说的张量拆解需要进行大量的迭代,才能将拆解矩阵稳定下来。但上述讨论中,我们认为 \(X_i^R, X_a^R\) 的选择是合理的,剩下的问题是 \(U_{RS} = |\boldsymbol{r}_R - \boldsymbol{r}_S|^{-1}\) 不适合用来近似 \(g_{ij}^{ab}\)

那么我们需要选取另一个矩阵 \(V_{RS}\),使得下述近似是合理的:

\[ g_{ij}^{ab} \simeq X_i^R X_a^R V_{RS} X_j^S X_b^S \]

这个近似将会基于最小二乘 (LS, Least-Square) 给出。这个思路是从 Devin A. Matthews[3] 的文章所来。相信该问题的最早前身是 T. J. Martínez 与 C. D. Sherrill[4] 的文章。

为了求使得 LS 误差最小的 \(V_{RS}\),我们需要预先作一些准备。定义

  • S \(S_{RS} = X_i^R X_a^R X_i^S X_a^S\) (dim: \(R, S\); shape: \(n_\mathrm{grid}^\mathrm{parent}, n_\mathrm{grid}^\mathrm{parent}\)) 格点重叠矩阵

S = einsum("iR, aR, iS, aS -> RS", Xo, Xv, Xo, Xv)

那么根据数值计算的原理,\(V_{RS}\) 的导出方式是

\[ V_{RS} := (\mathbf{S}^{-1})_{RT} X_i^T X_a^T g_{ij}^{ab} X_j^U X_b^U (\mathbf{S}^{-1})_{US} \]

尽管这是一个 \(O(n_\mathrm{occ}^2 n_\mathrm{vir}^2 n_\mathrm{grid}^\mathrm{parent})\)\(O(N^5)\) 计算量,但若在上述表达式引入 DF,则计算量可以进一步降到 \(O(n_\mathrm{occ} n_\mathrm{vir} n_\mathrm{aux} n_\mathrm{grid}^\mathrm{parent})\)\(O(N^4)\)

\[ V_{RS} \simeq (\mathbf{S}^{-1})_{RT} X_i^T X_a^T Y_{ia,P} Y_{jb,P} X_j^U X_b^U (\mathbf{S}^{-1})_{US} \]
方法 1:基于求本征问题的伪逆方法 (Pseudoinverse)#

但上述计算存在两个比较严重的问题。首先是 \(\mathbf{S}\) 其实是很大的 \(n_\mathrm{grid}^\mathrm{parent} \times n_\mathrm{grid}^\mathrm{parent}\) 矩阵,其求逆的计算消耗尽管是 \(O(N^3)\) 但仍然应尽量避免。更严重的是,这个 \(\mathbf{S}\) 因为不满秩,其实压根不能求逆:

try: np.linalg.inv(S)
except Exception as e: print("!!! Error:", type(e), e)
!!! Error: <class 'numpy.linalg.LinAlgError'> Singular matrix

一种解决办法是利用求取伪逆的技术。首先是要对 \(S_{RS}\) 通过求本征问题,拆解为

\[ S_{RS} = R_{T'R} R_{T'S} \quad \text{or} \quad \mathbf{S} = \mathbf{R}^\mathrm{T} \mathbf{R} \]

其中,角标 \(T'\) 区别于母格点角标 \(R, S\)。我们称 \(T'\) 的角标为精简格点 (pruned grid) 角标。这个“精简”的格点数量原则上应是矩阵 \(S_{RS}\) 的秩;但现实中,\(S_{RS}\) 的秩是依据程序编写者心情而定的。我们这里使用对 \(S_{RS}\) 求本征问题来确定秩的大小;规定有效的本征向量对应的本征值与最大的本征值之比不小于 \(10^{-5}\)。从下面的程序可以看出,精简格点大小 \(n_\mathrm{grid}^\mathrm{pruned} = 321\),比 DF 的辅助基大小 \(n_\mathrm{aux} = 242\) 要大一些。

eig, mat = np.linalg.eigh(S)
n_prune = ((eig / eig[-1]) > 1e-5).sum()
eig, mat = eig[:-n_prune-1:-1], mat[:, :-n_prune-1:-1]
n_prune
321
  • R \(R_{T' R}\) (dim: \(T, R\); shape: \(n_\mathrm{grid}^\mathrm{pruned}, n_\mathrm{grid}^\mathrm{parent}\))

R = (mat * eig**0.5).T
np.allclose(R.T @ R, S, atol=1e-6)
True

利用该矩阵,以及下述定义的

  • E \(E_{TU} = X_i^T X_a^T Y_{ia,P} Y_{jb,P} X_j^U X_b^U\) (dim: \(T, S\); shape: \(n_\mathrm{grid}^\mathrm{parent}, n_\mathrm{grid}^\mathrm{parent}\)) 在母格点表示下的双电子积分,这一步的最大计算复杂度是 \(O(n_\mathrm{occ} n_\mathrm{vir} n_\mathrm{aux} n_\mathrm{grid}^\mathrm{parent})\)

E = einsum("iT, aT, iaP, jbP, jU, bU -> TU", Xo, Xv, Y_ov, Y_ov, Xo, Xv)

伪逆问题化为了求解线性方程组问题:

\[ \mathbf{R}^\mathrm{T} \mathbf{R} \mathbf{V} \mathbf{R}^\mathrm{T} \mathbf{R} = \mathbf{E} \]
  • V \(V_{RS}\) (dim: \(R, S\); shape: \(n_\mathrm{grid}^\mathrm{parent}, n_\mathrm{grid}^\mathrm{parent}\)) 最小二乘下的母格点表示下的库仑势

Z = np.linalg.lstsq(R.T, E  , rcond=None)[0]
Y = np.linalg.lstsq(R.T, Z.T, rcond=None)[0].T
W = np.linalg.lstsq(R  , Y  , rcond=None)[0]
V = np.linalg.lstsq(R  , W.T, rcond=None)[0].T

我们需要说明,尽管我们确实成功地导出矩阵 \(R_{T'R}\),表明母格点 \(n_\mathrm{gird}^\mathrm{parent} = 2250\) 可以修剪到 \(n_\mathrm{grid}^\mathrm{pruned} = 321\);但由于张量拆解本身的定义原因,我们暂时无法将母格点下定义的 \(X_i^R\) 乘以一个转换矩阵到。因此 \(n_\mathrm{grid}^\mathrm{pruned}\) 在伪逆方法下,只能表征母格点的冗余程度,但并不能用修剪后的格点来加速计算。

最终,MP2 的能量写为

\[\begin{split} \begin{align*} E_\mathsf{corr} &= 2 g_{ij}^{ab} g_{ij}^{ab} / D_{ij}^{ab} - g_{ij}^{ab} g_{ij}^{ba} / D_{ij}^{ab} \\ &= -2 X_i^R X_a^R V_{RS} X_j^S X_b^S X_i^T X_a^T V_{TU} X_j^U X_b^U g_i^l g_a^l g_j^l g_b^l \\ &\quad + X_i^R X_a^R V_{RS} X_j^S X_b^S X_i^T X_b^T V_{TU} X_j^U X_a^U g_i^l g_a^l g_j^l g_b^l \end{align*} \end{split}\]
corr_thc_mp2 = (
    #             g_ovov            , g_ovov            , 1/D_ovov
    - 2 * einsum("iR, aR, RS, jS, bS, iT, aT, TU, jU, bU, il, al, jl, bl ->",
                  Xo, Xv,  V, Xo, Xv, Xo, Xv,  V, Xo, Xv, go, gv, go, gv)
    #             g_ovov            , g_ovov            , 1/D_ovov
    +     einsum("iR, aR, RS, jS, bS, iT, bT, TU, jU, aU, il, al, jl, bl ->",
                  Xo, Xv,  V, Xo, Xv, Xo, Xv,  V, Xo, Xv, go, gv, go, gv))
print("==> [LS-THC-MP2 corr energy pseudoinv]  : {:20.12f}".format(corr_thc_mp2))
print("==> [LS-THC-MP2 corr error pseudoinv]   : {:20.12f}".format(corr_thc_mp2 - (corr_simple_os + corr_simple_ss)))
==> [LS-THC-MP2 corr energy pseudoinv]  :      -0.312121723045
==> [LS-THC-MP2 corr error pseudoinv]   :       0.000054638455

由于所有的计算都表示为了矩阵乘法,因此尽管 MP2 能量表达式非常冗长,但最大计算量其实是 \(O(n_\mathrm{LT} n_\mathrm{occ} n_\mathrm{vir} (n_\mathrm{grid}^\mathrm{parent})^2)\)\(O(N^4)\) 的。这是相对来说很大的四次方,因此只有当 \(n_\mathrm{LT} (n_\mathrm{grid}^\mathrm{parent})^2 < n_\mathrm{occ} n_\mathrm{vir} n_\mathrm{aux}\) 时,LS-THC-LT-DF-MP2 相比于 DF-MP2 才能发挥真正的优势。关于 MP2 是 \(O(N^4)\) 的证明,需要参阅 Martínez 的文章附录[1]

方法 2:基于列轮换 Cholesky 矩阵分解的直接格点削减方法#

伪逆的做法是最为直观的;但其代价是,伪逆只能告诉我们母格点冗余程度是多大,但绝大多数的计算都无法利用修剪后的格点。为了利用修剪后的格点,另一种做法,也是 Devin. A. Matthews 文章[3] 的核心内容,是利用列轮换的 (Pivoting) Cholesky 矩阵分解。我们对这个技术不作太多介绍;其分解结果表述为

\[ S_{RS} = \Pi_{RR'} R_{TR'} R_{TS'} \Pi_{SS'} \quad \text{or} \quad \mathbf{S} = \mathbf{\Pi} \mathbf{R}^\mathrm{T} \mathbf{R} \mathbf{\Pi}^\mathrm{T} \]

其中,\(\Pi_{TR'}\) 相当于值非 1 即 0 的轮换矩阵,具有酉矩阵性质,作用等同于列轮换。\(R_{TR'}\) 是列轮换后实际的 Cholesky 分解上三角矩阵。

但还要知道,我们的目的是要通过 Cholesky 分解,削减一部分格点。因此,尽管程序所输出的角标 \(R'\) 的大小与母格点大小 \(n_\mathrm{grid}^\mathrm{parent}\) 相同,但通过设置 Cholesky 对角元阈值为最大对角元的 \(\epsilon=10^{-4}\) 倍,我们可以将母格点数量削减至 \(n_\mathrm{grid}^\mathrm{pruned} = 484\) 个。我们只取这 \(n_\mathrm{grid}^\mathrm{pruned}\) 个格点用以进行计算。这个格点数量比方才伪逆所求出的格点数量要多一些。

  • R \(R_{R'S'}\) (shape: \(n_\mathrm{grid}^\mathrm{pruned}, n_\mathrm{grid}^\mathrm{pruned}\)) 一定阈值下的 Cholesky 分解矩阵

  • P \(\Pi_{RR'}\) (shape: \(n_\mathrm{grid}^\mathrm{parent}, n_\mathrm{grid}^\mathrm{pruned}\)) 一定阈值下的列轮换矩阵

R, P_idxes, n_prune, info = scipy.linalg.lapack.dpstrf(S, tol=1e-8*np.abs(S).max())
assert info == 1  # Make sure pivoting Cholesky runs in success
# Set lower triangular as zero, since lapack does not zero tril if upper Cholesky is invoked
R[np.tril_indices(n_parent, k=-1)] = 0
# Generate permutation matrix (though permutation could be performed by array instead of matrix)
P = np.zeros((n_parent, n_parent))
P[P_idxes-1, np.arange(n_parent)] = 1
R, P = R[:n_prune, :n_prune], P[:, :n_prune]
n_prune
484

正因为列轮换矩阵非 1 即 0,因此 \(\Pi_{T R'}^2 = \Pi_{T R'}\)。又由于其实酉矩阵的性质。因此,

\[ X_i^R X_a^R \Pi_{R R'} = X_i^R X_a^S \delta_{RS} \Pi_{R R'} = X_i^R X_a^S \Pi_{R R'} \Pi_{S R'} \Pi_{R R'} = (X_i^R \Pi_{R R'}) (X_a^S \Pi_{S R'}) \]

这意味着我们可以通过定义

  • xo \(X_i^{R'} = X_i^R \Pi_{R R'}\) (shape: \(n_\mathrm{occ}, n_\mathrm{grid}^\mathrm{pruned}\))

  • xv \(X_a^{R'} = X_a^R \Pi_{R R'}\) (shape: \(n_\mathrm{vir}, n_\mathrm{grid}^\mathrm{pruned}\))

将所有的运算全部从母格点转移到修剪后的格点下。

xo = Xo @ P
xv = Xv @ P
  • E \(E_{T'U'} = X_i^{T'} X_a^{T'} Y_{ia,P} Y_{jb,P} X_j^{U'} X_b^{U'}\) (dim: \(T', U'\); shape: \(n_\mathrm{grid}^\mathrm{pruned}, n_\mathrm{grid}^\mathrm{pruned}\)) 在修剪格点表示下的双电子积分,这一步的最大计算复杂度是 \(O(n_\mathrm{occ} n_\mathrm{vir} n_\mathrm{aux} n_\mathrm{grid}^\mathrm{pruned})\)

E = einsum("iT, aT, iaP, jbP, jU, bU -> TU", xo, xv, Y_ov, Y_ov, xo, xv)

尽管求逆问题在修剪过的格点下是正定、可以实现的;但既然我们已经获得了 Cholesky 分解矩阵,我们没有必要再花 \(O(N^3)\) 的精力求逆了,而是解四次线性方程。由于满秩性成立,这里的求解过程尽管与上一段一样,但并非是伪逆:

\[ \mathbf{R}^\mathrm{T} \mathbf{R} \mathbf{V} \mathbf{R}^\mathrm{T} \mathbf{R} = \mathbf{E} \]
  • V \(V_{R'S'}\) (dim: \(R', S'\); shape: \(n_\mathrm{grid}^\mathrm{pruned}, n_\mathrm{grid}^\mathrm{pruned}\)) 最小二乘下修剪格点表示下的库仑势

Z = np.linalg.lstsq(R.T, E  , rcond=None)[0]
Y = np.linalg.lstsq(R.T, Z.T, rcond=None)[0].T
W = np.linalg.lstsq(R  , Y  , rcond=None)[0]
V = np.linalg.lstsq(R  , W.T, rcond=None)[0].T

最后我们可以求取 MP2 的总能量:

corr_thc_mp2 = (
    #             g_ovov            , g_ovov            , 1/D_ovov
    - 2 * einsum("iR, aR, RS, jS, bS, iT, aT, TU, jU, bU, il, al, jl, bl ->",
                  xo, xv,  V, xo, xv, xo, xv,  V, xo, xv, go, gv, go, gv)
    #             g_ovov            , g_ovov            , 1/D_ovov
    +     einsum("iR, aR, RS, jS, bS, iT, bT, TU, jU, aU, il, al, jl, bl ->",
                  xo, xv,  V, xo, xv, xo, xv,  V, xo, xv, go, gv, go, gv))
print("==> [LS-THC-MP2 corr energy pivotcho]   : {:20.12f}".format(corr_thc_mp2))
print("==> [LS-THC-MP2 corr error pivotcho]    : {:20.12f}".format(corr_thc_mp2 - (corr_simple_os + corr_simple_ss)))
==> [LS-THC-MP2 corr energy pivotcho]   :      -0.312116117678
==> [LS-THC-MP2 corr error pivotcho]    :       0.000060243822
简单总结#

对于 LS-THC-LT-DF-MP2,其一些重要的操作步骤是:

  • 生成 Density-Fitting 的 3c-2e 积分 \(Y_{\mu \nu, P}\) 需要 \(O(n_\mathrm{AO}^2 n_\mathrm{aux}^2)\),随后的分子轨道转化 \(Y_{ia, P}\) 需要 \(O(n_\mathrm{occ} n_\mathrm{AO}^2 n_\mathrm{aux})\) 的计算量;

  • 生成母格点重叠矩阵 \(S_{RS}\) 的计算需要 \(O(n_\mathrm{occ} n_\mathrm{vir} (n_\mathrm{grid}^\mathrm{parent})^2)\) 计算量;随后的列轮换 Cholesky 分解需要 \(O((n_\mathrm{grid}^\mathrm{pruned})^2)\) 计算量;

  • 生成修剪格点电子互斥积分 \(E_{T'U'}\) 需要 \(O(n_\mathrm{occ} n_\mathrm{vir} n_\mathrm{aux} n_\mathrm{grid}^\mathrm{pruned})\) 计算量;

  • 求取修剪格点最小二乘库仑势 \(V_{R'S'}\) 需要 \(O((n_\mathrm{grid}^\mathrm{pruned})^2)\) 计算量;

  • 计算 MP2 的交换部分 (对 SS 产生贡献的部分) 需要 \(O(n_\mathrm{LT} n_\mathrm{occ} n_\mathrm{vir} (n_\mathrm{grid}^\mathrm{pruned})^2)\) 计算量。

因此,总地来说,计算量是 \(O(N^4)\) 的。

事实上,MP3 能量也可以通过类似的方法给出,计算量同样是 \(O(N^4)\)


Python 散点绘图的平滑方法:差值与拟合#

创建时间:2019-10-21

%matplotlib notebook

import numpy as np
import matplotlib.pyplot as plt
from scipy.interpolate import make_interp_spline
from sklearn.kernel_ridge import KernelRidge

我们现在对以 \(y = \sin(x) + 0.2 \mathscr{N}(0, 1)\) 的有噪声图 (yx) 作平滑绘图。噪声点使用蓝色粗点表示,\(y = \sin(x)\) 曲线 (y_0x_0) 用蓝色虚线表示。

np.random.seed(0)

x = np.arange(- np.pi, np.pi, np.pi / 8)
y = np.sin(x) + np.random.randn(x.size) / 5

x_0 = np.arange(- np.pi, np.pi, np.pi / 100)
y_0 = np.sin(x_0)

一种平滑方式是差值。这种方式最好使用在数据点不太密集的情况下。差值的曲线 (y_1x_0) 用绿色线表示。

spl = make_interp_spline(x, y, k=3)
y_1 = spl(x_0)

另一种平滑方式是使用 RBF Kernel 作 KRR,这种方式可以在数据点较密时使用,但需要手动调整超参数 alphaalpha 相当于拟合过程中的惩罚项,过大会使曲线区域平线,过小会过拟合。拟合的曲线 (y_2x_0) 用橙色线表示。

clf = KernelRidge(alpha=0.1, kernel="rbf")
clf.fit(x.reshape(-1, 1), y)
y_2 = clf.predict(x_0.reshape(-1, 1))

我们绘制图像以看效果:

fig, ax = plt.subplots()

ax.scatter(x, y)

ax.plot(x_0, y_0, linestyle=":", label="Reference")
ax.plot(x_0, y_1, label="Interpolation", color="C2")
ax.plot(x_0, y_2, label="RBF KRR", color="C1")
ax.legend()
<matplotlib.legend.Legend at 0x7fdc55701f98>

Terraria 1.4:为何说专家模式比大师模式困难?单人模式 Boss 血量、攻击数值的初步计算方式#

创建时间:2020-06-19

对于该文档的直观认识,可以参考下述视频:[泰拉瑞亚1.4] 大师模式并非最困难模式?!详解旅程模式、For the Worthy、难度滑条对难度的影响

大致结论#

首先,我们讨论的是 1.4.0.5 版本的游戏。很可能在以后更新的游戏版本中,下述的结论会产生变化。

我们先开门见山了解一下我所理解的最困难模式为何吧:

旅程模式 2.95 倍敌怪困难度的 For the Worthy 地图

我们知道,在 Terraria 1.4 中,旅程模式下的 2.95 倍敌怪困难度并没有达到 3 倍,因此没有踏入大师模式,而仍然应称为专家模式。在这个模式下,玩家会遇到的困难有

  • 专家难度无法在血肉墙前获得 6 格饰品栏,以及血肉墙后获得 7 格饰品栏;

  • 专家难度无法获得大师模式特有物品,譬如“The Black Spot”;该物品可以较轻松地击败史莱姆女皇与光之女王等敌怪;

  • 专家难度 2.95 倍的 Boss 的 HP 并没有因为敌怪困难度而下降,反而是相对于大师难度 3.00 倍上升了 15.7%;

  • For the Worthy 本身提升了敌怪的综合性能,包括所有敌怪的 HP、ATK、防御力,以及 Boss 敌怪大小和 AI 本身;

  • 旅程模式下的 For the Worthy 比非旅程模式的对应情况要困难至少 2/3;这是指 HP 值与 ATK 值而言,从而容错率非常低。

关于专家模式与大师模式,

  • 专家模式与大师模式的 AI 几乎没有区别;

  • 一些射弹的伤害上,2 倍难度的专家模式确实比 3 倍难度的大师模式低;但旅程模式的射弹伤害是随着难度滑条的倍数呈线性关系,即 2.95 倍难度的射弹伤害与 3 倍大师难度并没有太大区别。

我们先暂且不谈非 Boss 的敌怪已经相当可怕了——开荒之初 100 点 HP 时,蓝史莱姆两次就可以将玩家踢出世界,而红色史莱姆一次即可。

而对于 Boss 而言,这可能使得从世界吞噬者或克苏鲁之脑后,近乎所有 Boss 敌怪的连续两三次攻击可以让玩家直接出局。同时,Boss 的 HP 值激增,少量的 Boss 的防御值也有所增加,使得攻略 Boss 的容错率变得相当低。或许,一般来说,玩家必须要具有无伤攻略的素质,才能通关这种难度的世界。

当然,这种世界也对应地有一些好处:

  • 旅程模式具有研究、复制功能,意味着在得到少量的花草、鱼、矿石等基础材料后,就可以无限制作物品、补药、弹药 (与金钱);

  • For the Worthy 有更多更大的发光蘑菇群落,因此宝箱与生命水晶可能会相对较多且好寻找;

  • 2.95 倍难度的 Boss 攻击力、非 Boss 的 HP 与攻击力都比 3.00 倍小出那 0.05 倍。

  • 旅程模式的城镇 NPC 能力更强,且敌怪的基础被击退能力与普通模式相同。(专家与大师模式下,敌怪的基础被击退能力都得到了小幅加强)

具体过程#

import pandas as pd
import numpy as np

基础数值#

出于简化问题,我们只讨论一部分不会有第二阶段变化的 Boss 型敌怪。我们用 NPC ID 号来称呼它们。

Map_ID_Name = {
    13: "世界吞噬者 (头部)",
    14: "世界吞噬者 (身体)",
    15: "世界吞噬者 (尾部)",
    266: "克苏鲁之脑",
    267: "飞眼怪",
    50: "史莱姆王",
    113: "血肉墙",
    114: "血肉墙 (眼部)",
}

它们的基础 HP 值、攻击力列举如下;基础值的意义是最为普通的普通模式下的数值。从下表能看出,史莱姆王的基础 HP 为 2000、攻击力为 40。从这些数值上看,史莱姆王处于世界吞噬者、克苏鲁之脑之后,蜂王之前确实是合理的。当然,由于史莱姆王的移动方式单一,使得即使 1.4 版本中史诗级地加强了史莱姆王的 AI,它也仍然是最容易攻略的 Boss。

下述数值出自 Terraria.NPC 的成员函数 void SetDefaults 中。

Base_HP_ATK = {
    13:  ( 150, 22),
    14:  ( 150, 13),
    15:  ( 150, 11),
    266: (1000, 30),
    267: ( 100, 20),
    50:  (2000, 40),
    113: (8000, 50),
    114: (8000, 50),
}

专家与大师模式的影响#

我们首先定义下述类,该类能通过输入的难度倍数给出当前是否处于专家或大师难度。2.95 倍是专家难度,3 倍是大师难度。

class GameMode:
    
    def __init__(self, power):
        self.power = power
        
    @property
    def isExpert(self):
        return self.power >= 2
    
    @property
    def isMaster(self):
        return self.power >= 3

Terraria.NPC 的成员函数 void ScaleStats_ApplyMultiplayerStats 中,在专家及大师模式下对 Boss 的 HP 与 ATK 值进行了缩放。

GameMode_HP_ATK_Scale = {
    13:  (0.7 , 1.1),
    14:  (0.7 , 0.8),
    15:  (0.7 , 0.8),
    266: (0.85, 0.9),
    267: (0.85, 0.9),
    50:  (0.7 , 0.8),
    113: (0.7 , 1.5),
    114: (0.7 , 1.5),
}

看起来大多数情况下都是削弱而非加强;但在经过上述缩放过程后,还要乘上困难倍数才得到最终的 HP 与 ATK。大师模式的困难倍数是 3 倍。

但如果难度达到大师级别,Boss 的 HP 会回削弱到 0.85 倍。这也就意味着,2.95 倍的专家难度的 Boss HP 会比 3 倍的大师难度更大,且幅度是

2.95 / 3 / 0.85 - 1 = 15.69%

For the Worthy 的影响#

首先,在 Terraria.NPC 的成员函数 void getGoodAdjustments 中,For the Worthy 会提供一个基础 HP 与 ATK 的基础值的提升:

GoodWorld_HP_ATK_Scale = {
    13:  (1. , 1. ),
    14:  (1. , 1. ),
    15:  (1. , 1. ),
    266: (1. , 1.2),
    267: (1. , 1.2),
    50:  (1. , 1. ),
    113: (1.5, 1. ),
    114: (1.5, 1. ),
}

可以看出,For the Worthy 并没有对许多的 Boss 的基础数值作改动;在这里被加强的 Boss 有克苏鲁之脑 (攻击力加强 20%),以及血肉墙 (HP 加强 50%)。

随后,在非旅程的专家模式会增加 1/2 倍,大师模式会增加 1/3 倍的 HP 与 ATK 值;旅程模式下都直接增加 1 倍的 HP 与 ATK 值。随后,ATK 值还会额外增加 1/3 倍。这是在 Terraria.NPC 的成员函数 void ScaleStats_ApplyGameMode 中实现的。

HP 值总结#

我们可以以下述程序来简单地计算不同的模式下,Boss 的 HP 值情况:

def calculate_HP(idx, power, isJourneyMode, isGoodWorld):
    HP = Base_HP_ATK[idx][0]
    if GameMode(idx).isExpert:
        HP *= GameMode_HP_ATK_Scale[idx][0]
    if GameMode(power).isMaster:
        HP *= 0.85
    if isGoodWorld:
        HP *= GoodWorld_HP_ATK_Scale[idx][0]
        if not isJourneyMode:
            HP *= (power + 1)
        else:
            HP *= 2 * power
    else:
        HP *= power
    return int(HP)

譬如,对于克苏鲁之脑,其在 2.95 倍难度的旅程模式 For the Worthy 下,其 HP 值为

calculate_HP(266, 2.95, True, True)
5015

这个血量值已经接近通常认为最为困难的大师模式 For the Worthy 的两倍了。

我们用下述的 pandas 表格呈现结果:

header = pd.MultiIndex.from_product([
    ["普通地图", "For the Worthy"],
    ["非旅程模式", "旅程模式"],
    ["专家", "2.95", "大师"]],
    names=["HP 值", "", "困难度"])
data = np.array([[[[
    calculate_HP(idx, power, isJourneyMode, isGoodWorld)
    for power in (2, 2.95, 3)]
    for isJourneyMode in (False, True)]
    for isGoodWorld in (False, True)]
    for idx in Map_ID_Name]
).reshape(-1, 3 * 2 * 2)
df = pd.DataFrame(data, index=[val for val in Map_ID_Name.values()], columns=header)
df
HP 值 普通地图 For the Worthy
非旅程模式 旅程模式 非旅程模式 旅程模式
困难度 专家 2.95 大师 专家 2.95 大师 专家 2.95 大师 专家 2.95 大师
世界吞噬者 (头部) 210 309 267 210 309 267 315 414 357 420 619 535
世界吞噬者 (身体) 210 309 267 210 309 267 315 414 357 420 619 535
世界吞噬者 (尾部) 210 309 267 210 309 267 315 414 357 420 619 535
克苏鲁之脑 1700 2507 2167 1700 2507 2167 2550 3357 2890 3400 5015 4335
飞眼怪 170 250 216 170 250 216 255 335 289 340 501 433
史莱姆王 2800 4130 3570 2800 4130 3570 4200 5530 4760 5600 8260 7140
血肉墙 11200 16520 14280 11200 16520 14280 25200 33180 28560 33600 49560 42840
血肉墙 (眼部) 11200 16520 14280 11200 16520 14280 25200 33180 28560 33600 49560 42840

ATK 值总结#

我们可以以下述程序来简单地计算不同的模式下,Boss 的 ATK 值情况:

def calculate_ATK(idx, power, isJourneyMode, isGoodWorld):
    ATK = Base_HP_ATK[idx][1]
    if GameMode(idx).isExpert:
        ATK *= GameMode_HP_ATK_Scale[idx][1]
    if isGoodWorld:
        ATK *= GoodWorld_HP_ATK_Scale[idx][1]
        if not isJourneyMode:
            ATK *= (power + 1)
        else:
            ATK *= 2 * power
        ATK *= 4/3
    else:
        ATK *= power
    return int(ATK)

譬如,对于血肉墙,其在 2.95 倍难度的旅程模式 For the Worthy 下,其 ATK 值为

calculate_ATK(113, 2.95, True, True)
590

这也就意味着,不论玩家防御力多高,如果只有血肉墙前的装备,在其贴身一击下,是不可能活下来的。

我们用下述的 pandas 表格呈现结果:

header = pd.MultiIndex.from_product([
    ["普通地图", "For the Worthy"],
    ["非旅程模式", "旅程模式"],
    ["专家", "2.95", "大师"]],
    names=["ATK 值", "", "困难度"])
data = np.array([[[[
    calculate_ATK(idx, power, isJourneyMode, isGoodWorld)
    for power in (2, 2.95, 3)]
    for isJourneyMode in (False, True)]
    for isGoodWorld in (False, True)]
    for idx in Map_ID_Name]
).reshape(-1, 3 * 2 * 2)
df = pd.DataFrame(data, index=[val for val in Map_ID_Name.values()], columns=header)
df
ATK 值 普通地图 For the Worthy
非旅程模式 旅程模式 非旅程模式 旅程模式
困难度 专家 2.95 大师 专家 2.95 大师 专家 2.95 大师 专家 2.95 大师
世界吞噬者 (头部) 48 71 72 48 71 72 96 127 129 129 190 193
世界吞噬者 (身体) 20 30 31 20 30 31 41 54 55 55 81 83
世界吞噬者 (尾部) 17 25 26 17 25 26 35 46 46 46 69 70
克苏鲁之脑 54 79 81 54 79 81 129 170 172 172 254 259
飞眼怪 36 53 54 36 53 54 86 113 115 115 169 172
史莱姆王 64 94 96 64 94 96 128 168 170 170 251 256
血肉墙 150 221 225 150 221 225 300 395 400 400 590 600
血肉墙 (眼部) 150 221 225 150 221 225 300 395 400 400 590 600

补充信息#

上述程序信息来自 ILSpy 对 Terraria 1.4.0.5 的反向工程结果。

诸闭壳层量子化学方法的密度矩阵#

创建时间:2021-01-04;最后修改:2021-06-10

在这份简短笔记中,我们会回顾一些量子化学方法的密度矩阵,及其性质。大体的结论在下述表格中。

我们在这里只讨论闭壳层与实函数的情况。

方法

RHF 轨道基函数

能量关系

\(P_p^q\) 对称性

\(\Gamma_{pr}^{qs}\) 对称性

1-RDM 与电子数

\(\Gamma_{pr}^{qs}\)\(P_p^q\) 的关系

\(\mathbf{P}\) 幂等性

\(P_i^a\) 为零

\(\Gamma_{ij}^{ab}\) 为零

\(F_p^q\) 对称性

1-RDM 偶极矩

RHF

Full-CI

×

×

×

MP2

×

×

×

×

×

CCSD

×

×

×

×

×

CISD

×

×

×

×

×

CASCI

×

×

N/A

N/A

×

×

CASSCF

×

×

N/A

N/A

之所以上面表格中 CASCI、CASSCF 方法不能说 \(P_i^a\) (密度矩阵的占据-非占) 与 \(\Gamma_{ij}^{ab}\) (2-RDM 的占据-非占),是因为它们并是非基于 RHF 参考态的方法,不存在确切的占据与未占轨道。

预定义#

from pyscf import gto, scf, mp, cc, ci, mcscf, fci
import numpy as np
from functools import partial

np.einsum = partial(np.einsum, optimize=True)
np.set_printoptions(precision=5, linewidth=150, suppress=True)

这里讨论的密度矩阵并非是 Full-CI 的情形矩阵,而是会因方法各异而不同的。

这里采用相对比较严格的 Einstein Summation Convention,即被求和角标必须是一个在上,一个在下。

这份文档中使用下述上下标:

  • \(p, q, r, s, m, n\) 分子轨道

  • \(i, j\) 分子占据轨道,\(a, b\) 分子未占轨道

  • \(\mu, \nu, \kappa, \lambda\) 原子轨道

分子轨道函数 \(\phi_p (\boldsymbol{r})\) 与原子轨道函数 \(\phi_\mu (\boldsymbol{r})\) 之间满足关系 (\(C_p^\mu\) 称为原子轨道系数)

\[ \phi_p (\boldsymbol{r}) = C_p^\mu \phi_\mu (\boldsymbol{r}) \]

我们假定了研究体系必然是实函数,但我们暂且定义函数的共轭记号如下:(不使用 Einstein Summation)

\[ \phi^p (\boldsymbol{r}) = \phi_p^* (\boldsymbol{r}) \]

分子轨道之间是正交归一的,但原子轨道需要用重叠积分:

\[ \int \phi_p (\boldsymbol{r}) \phi^q (\boldsymbol{r}) \, \mathrm{d} \boldsymbol{r} = \delta_p^q, \; \int \phi_\mu (\boldsymbol{r}) \phi^\nu (\boldsymbol{r}) \, \mathrm{d} \boldsymbol{r} = S_\mu^\nu \]

Full-CI 密度矩阵的定义与性质#

这里只作理论上的讨论。Full-CI 密度矩阵程序上的实现会在后面呈现。

密度矩阵与约化密度的定义有关。由于我们只讨论闭壳层情形,因此波函数可以安全地写成空间坐标的函数。

1-RDM 与基转换关系#

我们回顾一阶约化密度 \(\rho(\boldsymbol{r}; \boldsymbol{r}')\)

\[ \rho(\boldsymbol{r}; \boldsymbol{r}') = \idotsint \Psi^* (\boldsymbol{r}, \boldsymbol{r}_2, \boldsymbol{r}_3, \cdots, \boldsymbol{r}_{n_\mathrm{elec}}) \Psi (\boldsymbol{r}', \boldsymbol{r}_2, \boldsymbol{r}_3, \cdots, \boldsymbol{r}_{n_\mathrm{elec}}) \, \mathrm{d} \boldsymbol{r}_2 \, \mathrm{d} \boldsymbol{r}_3 \cdots \, \mathrm{d} \boldsymbol{r}_{n_\mathrm{elec}} \]

但是现在只有有限的基函数展开一阶约化密度;如果这组基函数是 RHF 分子轨道 \(\{ \phi_{p} (\boldsymbol{r}) \}\),那么定义下述分子轨道基一阶约化密度矩阵 \(P_p^q\) (One-Order Reduced Density Matrix, 1-RDM)

\[ \rho(\boldsymbol{r}; \boldsymbol{r}') = P_p^q \phi^p (\boldsymbol{r}) \phi_q (\boldsymbol{r}') \]

如果是原子轨道 \(\{ \phi_\mu (\boldsymbol{r}) \}\),那么它称为原子轨道基 1-RDM \(P_\mu^\nu\)

\[ \rho(\boldsymbol{r}; \boldsymbol{r}') = P_\mu^\nu \phi^\mu (\boldsymbol{r}) \phi_\nu (\boldsymbol{r}') \]

依据分子轨道与原子轨道间的关系,有

\[ \rho(\boldsymbol{r}; \boldsymbol{r}') = C_\mu^p P_p^q C_q^\nu \phi^\mu (\boldsymbol{r}) \phi_\nu (\boldsymbol{r}') \]

因此,原子轨道基与分子轨道基的 1-RDM 间存在关系

\[ P_\mu^\nu = C_\mu^p P_p^q C_q^\nu \]

1-RDM 迹#

\(\boldsymbol{r}, \boldsymbol{r}'\) 相同时,我们会将一阶约化密度简记为电子密度 \(\rho(\boldsymbol{r}) = \rho(\boldsymbol{r}; \boldsymbol{r})\)

一阶约化密度具有积分为电子数的性质:

\[ \int \rho(\boldsymbol{r}) \, \mathrm{d} \boldsymbol{r} = n_\mathrm{elec} \]

在分子轨道基的表示下,上式可以写为

\[ P_p^q \int \phi^p (\boldsymbol{r}) \phi_q (\boldsymbol{r}) \, \mathrm{d} \boldsymbol{r} = P_p^q \delta^p_q = \mathrm{tr} (\mathbf{P}) = n_\mathrm{nelec} \]

1-RDM 对称性#

首先我们可以证明下述等式:

\[\begin{split} \begin{align} &\quad\ \iint \phi_p (\boldsymbol{r}) \rho(\boldsymbol{r}; \boldsymbol{r}') \phi^q (\boldsymbol{r}') \, \mathrm{d} \boldsymbol{r} \, \mathrm{d} \boldsymbol{r}' \\ &= \iint \phi_p (\boldsymbol{r}) P_r^s \phi^r (\boldsymbol{r}) \phi_s (\boldsymbol{r}') \phi^q (\boldsymbol{r}') \, \mathrm{d} \boldsymbol{r} \, \mathrm{d} \boldsymbol{r}' \\ &= P_r^s \delta_p^r \delta_s^q = P_p^q \end{align} \end{split}\]

对于上式,如果我们交换被积元变量 \(\boldsymbol{r}, \boldsymbol{r'}\)、并对表达式取共轭,得到 (不使用 Einstein Summation)

\[ \iint \phi_p (\boldsymbol{r}) \rho(\boldsymbol{r}; \boldsymbol{r}') \phi^q (\boldsymbol{r}') \, \mathrm{d} \boldsymbol{r} \, \mathrm{d} \boldsymbol{r}' = \iint \phi_q (\boldsymbol{r}) \rho^*(\boldsymbol{r}'; \boldsymbol{r}) \phi^p (\boldsymbol{r}') \, \mathrm{d} \boldsymbol{r} \, \mathrm{d} \boldsymbol{r}' \]

如果我们再利用实数情形下,根据一阶约化密度的定义,有 \(\rho(\boldsymbol{r}; \boldsymbol{r}') = \rho(\boldsymbol{r}'; \boldsymbol{r}) = \rho^*(\boldsymbol{r}'; \boldsymbol{r})\),那么可以立即得到 (不使用 Einstein Summation)

\[ P_p^q = \iint \phi_p (\boldsymbol{r}) \rho(\boldsymbol{r}; \boldsymbol{r}') \phi^q (\boldsymbol{r}') \, \mathrm{d} \boldsymbol{r} \, \mathrm{d} \boldsymbol{r}' = \iint \phi_q (\boldsymbol{r}) \rho(\boldsymbol{r}; \boldsymbol{r}') \phi^p (\boldsymbol{r}') \, \mathrm{d} \boldsymbol{r} \, \mathrm{d} \boldsymbol{r}' = P_q^p \]

即 1-RDM 矩阵 \(P_p^q\) 是对称矩阵。

2-RDM#

与 1-RDM 相同地,依据二阶约化密度 \(\gamma (\boldsymbol{r}_1, \boldsymbol{r}_2; \boldsymbol{r}'_1, \boldsymbol{r}'_2)\) 的定义:

\[ \gamma (\boldsymbol{r}_1, \boldsymbol{r}_2; \boldsymbol{r}'_1, \boldsymbol{r}'_2) = \idotsint \Psi^* (\boldsymbol{r}_1, \boldsymbol{r}_2, \boldsymbol{r}_3, \cdots, \boldsymbol{r}_{n_\mathrm{elec}}) \Psi (\boldsymbol{r}'_1, \boldsymbol{r}'_2, \boldsymbol{r}_3, \cdots, \boldsymbol{r}_{n_\mathrm{elec}}) \, \mathrm{d} \boldsymbol{r}_3 \cdots \, \mathrm{d} \boldsymbol{r}_{n_\mathrm{elec}} \]

用分子轨道基作展开,可以定义分子轨道基的二阶约化密度矩阵 \(\Gamma_{pr}^{qs}\) (Two-Order Reduced Density Matrix, 2-RDM)

\[ \gamma (\boldsymbol{r}_1, \boldsymbol{r}_2; \boldsymbol{r}'_1, \boldsymbol{r}'_2) = \Gamma_{pr}^{qs} \phi^p (\boldsymbol{r}_1) \phi_q (\boldsymbol{r}'_1) \phi^r (\boldsymbol{r}_2) \phi_s (\boldsymbol{r}'_2) \]

原子与分子轨道基转换也与 1-RDM 类似:

\[ \Gamma_{\mu \kappa}^{\nu \lambda} = C_\mu^p C^\nu_q \Gamma_{pr}^{qs} C_\kappa^r C^\lambda_s \]

2-RDM 与 1-RDM 的关系#

出于全同粒子的性质,2-RDM 与 1-RDM 之间存在关系:

\[ \rho(\boldsymbol{r}_1; \boldsymbol{r}'_1) = \frac{1}{n_\mathrm{elec} - 1} \iint \gamma (\boldsymbol{r}_1, \boldsymbol{r}_2; \boldsymbol{r}'_1, \boldsymbol{r}_2) \, \mathrm{d} \boldsymbol{r}_2 \]

对上式展开并作一部分积分后,可以得到

\[ P_p^q \phi^p (\boldsymbol{r}_1) \phi_q (\boldsymbol{r}'_1) = \frac{1}{n_\mathrm{elec} - 1} \Gamma_{pr}^{qm} \phi^p (\boldsymbol{r}_1) \phi_q (\boldsymbol{r}'_1) \delta_m^r \]

由于上式要在任意的 \(\boldsymbol{r}_1, \boldsymbol{r}'_1\) 的取值下成立,因此可以认为

\[ P_p^q = \frac{1}{n_\mathrm{elec} - 1} \Gamma_{pr}^{qm} \delta_m^r \]

注意上式要对等式右边作关于 \(r, m\) 角标的求和。

2-RDM 对称性#

分析 2-RDM 对称性相对比较麻烦。这里就略过讨论了。我们仅指出,实数闭壳层下的 2-RDM 应当具有二重对称性,而不具有更高的对称性:

\[ \Gamma_{pr}^{qs} = \Gamma_{rp}^{sq} \]

密度矩阵与能量#

这是最关键的一个性质。密度矩阵可以用来表示电子态的能量。

现在记原子轨道基组下的单电子算符积分为 \(h_\mu^\nu\)、双电子算符积分为 \(g_{\mu \kappa}^{\nu \lambda}\),其中单电子算符包含动能、原子核-电子库伦势能、电场势能等贡献,双电子算符包含电子-电子库伦势能贡献。那么,体系单点能为

\[ E_\mathrm{tot} = E_\mathrm{elec} + E_\mathrm{nuc} = h_\mu^\nu P_\nu^\mu + \frac{1}{2} g_{\mu \kappa}^{\nu \lambda} \Gamma_{\nu \lambda}^{\mu \kappa} + E_\mathrm{nuc} \]

1-RDM 与偶极矩#

Full-CI 的 1-RDM 可以直接用以计算偶极矩;以 \(z\) 轴方向施加的电场为例:

\[ d_z = - z_\mu^\nu P_\nu^\mu \]

其中 \(z_\mu^\nu = \langle \mu | z | \nu \rangle\)

广义 Fock 矩阵#

广义 Fock 矩阵定义为

\[ F_p^q = h_p^r P_r^q + g_{pr}^{ms} \Gamma_{ms}^{qr} \]

特别地,在 RHF 下,它是对角矩阵。而在 Full-CI 下,它会是对称矩阵。

RHF 密度矩阵特有性质#

幂等性#

在 Conanical-HF 下,幂等性是几乎显然的:\(P_p^q\) 一定是对角矩阵;如果 \(p\) 所代表的轨道是占据轨道,那么一定填了因为填了两个电子而值为 2,否则为零。因此,Conanical-HF 下一定满足

\[ P_p^m P_m^q = 2 P_p^q \]

一般程序都只会给出 Conanical-HF 的结果。但若讨论 Nonconanical-HF 时 \(P_p^q\) 未必是对角矩阵,但上述结论应仍然成立。

非占-占据部分为零#

Hartree-Fock 方法严格地将轨道分为占据与非占据。因此,Canonical 或 Nonconanical HF 方法都会保证 1-RDM 是块状对角化的;即在占据-非占 \(P_i^a\),非占-占据 \(P_a^i\),非占-非占 \(P_a^b\) 均严格为零。对于 2-RDM 是类似的。

由于 Hartree-Fock 方法没有考虑非占轨道的贡献,因此任何 Post-HF 方法均一定程度上有激发态的贡献。一般来说,非占-非占的 \(P_a^b\) 贡献总是存在的;但占据-非占或非占-占据的 \(P_i^a\)\(P_a^i\) 则未必存在。

通用计算函数#

下面的文档仅仅是验证开头的表格的代码。

分子定义:水分子#

  • mol 水分子实例;

  • nelec \(n_\mathrm{elec}\) 电子数;

  • nocc \(n_\mathrm{occ}\) 占据轨道数;

  • h 原子轨道基 \(h_\mu^\nu\),维度 \((\mu, \nu)\)

  • g 原子轨道基 \(g_{\mu \kappa}^{\nu \lambda}\),维度 \((\mu, \nu, \kappa, \lambda)\)

  • S 原子轨道基 \(S_\mu^\nu\),维度 \((\mu, \nu)\)

  • mf_rhf RHF 实例。

mol = gto.Mole()
mol.atom = """
O  0. 0. 0.
H  0. 0. 1.
H  0. 1. 0.
"""
mol.basis = "6-31G"
mol.verbose = 0
mol.build()
<pyscf.gto.mole.Mole at 0x7feae8ff9580>
nelec = mol.nelectron
nocc = mol.nelec[0]
nelec, nocc
(10, 5)
h = mol.intor("int1e_kin") + mol.intor("int1e_nuc")
g = mol.intor("int2e")
S = mol.intor("int1e_ovlp")
mf_rhf = scf.RHF(mol).run()

验证能量表达式#

验证

\[ E_\mathrm{tot} = h_\mu^\nu P_\nu^\mu + \frac{1}{2} g_{\mu \kappa}^{\nu \lambda} \Gamma_{\nu \lambda}^{\mu \kappa} + E_\mathrm{nuc} = h_p^q P_q^p + \frac{1}{2} g_{pr}^{qs} \Gamma_{qs}^{pr} + E_\mathrm{nuc} \]
eng_nuc = mol.energy_nuc()
def verify_energy_relation(eng, eng_nuc, rdm1, rdm2, h_mo, g_mo):
    return np.allclose(np.einsum("pq, qp ->", h_mo, rdm1) + 0.5 * np.einsum("pqrs, qpsr ->", g_mo, rdm2) + eng_nuc, eng)

验证 1-RDM 对称性#

验证 \(P_p^q = P_q^p\)

def verify_rdm1_symm(rdm1):
    # Output: 1-RDM symmetric property
    return np.allclose(rdm1, rdm1.T)

验证 2-RDM 对称性#

验证 \(\Gamma_{pr}^{qs} = \Gamma_{rp}^{sq}\)

def verify_rdm2_symm(rdm2):
    return np.allclose(rdm2, np.einsum("pqrs -> rspq", rdm2))

验证 1-RDM 的迹#

验证 \(P_p^r \delta_r^p = n_\mathrm{elec}\)

def verify_rdm1_tr(rdm1):
    return np.allclose(rdm1.trace(), nelec)

验证 1-RDM 与 2-RDM 的关系#

验证 \(P_p^q = (n_\mathrm{elec} - 1)^{-1} \Gamma_{pr}^{qm} \delta_m^r\)

def verify_rdm12_relation(rdm1, rdm2):
    return np.allclose(rdm1, (nelec - 1)**-1 * rdm2.diagonal(axis1=-1, axis2=-2).sum(axis=-1))

验证 1-RDM 幂等性#

验证 \(P_p^m P_m^q = 2 P_p^q\)

def verify_rdm1_idomp(rdm1):
    return np.allclose(rdm1 @ rdm1, 2 * rdm1)

验证 \(P_i^a\) 为零#

这里实际上同时验证 \(P_a^i\) 是否为零。

def verify_rdm1_ov(rdm1):
    mat1 = rdm1[nocc:, :nocc]
    mat2 = rdm1[:nocc, nocc:]
    return np.allclose(mat1, np.zeros_like(mat1)) and np.allclose(mat2, np.zeros_like(mat2))

验证 \(\Gamma_{ij}^{ab}\) 为零#

def verify_rdm2_ovov(rdm2):
    mat = rdm2[:nocc, nocc:, :nocc, nocc:]
    return np.allclose(mat, np.zeros_like(mat))

验证广义 Fock 矩阵对称性#

\[ F_p^q = h_p^r P_r^q + g_{pr}^{ms} \Gamma_{ms}^{qr} \]
def verify_gF_symm(rdm1, rdm2, h_mo, g_mo):
    gF = np.einsum("pr, rq -> pq", h_mo, rdm1) + np.einsum("pmrs, mqsr -> pq", g_mo, rdm2)
    return np.allclose(gF, gF.T, atol=1e-4)

偶极矩的验证#

通过 1-RDM 计算的偶极矩为 (不考虑原子核影响)

\[ d_z = - z_\mu^\nu P_\nu^\mu \]

但另一种偶极矩的计算方式是对 \(h_\mu^\nu\) 作更改,求得该情形下的能量作数值差分得到。数值差分的间隙设定为 1e-4 单位电场强度。

h_field = 1e-4

def get_hcore_p(mol_=mol):
    return mol.intor("int1e_kin") + mol.intor("int1e_nuc") - h_field * mol.intor("int1e_r")[2]
def get_hcore_m(mol_=mol):
    return mol.intor("int1e_kin") + mol.intor("int1e_nuc") + h_field * mol.intor("int1e_r")[2]

mf_rhf_p, mf_rhf_m = scf.RHF(mol), scf.RHF(mol)
mf_rhf_p.get_hcore = get_hcore_p
mf_rhf_m.get_hcore = get_hcore_m
mf_rhf_p.run(), mf_rhf_m.run()

charges = mol.atom_charges()
coords  = mol.atom_coords()
nucl_dip = np.einsum('i,ix->x', charges, coords)
def verify_dip(method, rdm1, z_intg):
    mf_met_m, _, _, _ = method(mf_rhf_m)
    mf_met_p, _, _, _ = method(mf_rhf_p)
    dip_num = (mf_met_p.e_tot - mf_met_m.e_tot) / (2 * h_field) + nucl_dip[2]
    dip_rdm1 = - (rdm1 * z_intg).sum() + nucl_dip[2]
    return np.allclose(dip_num, dip_rdm1, atol=1e-4)

各种方法的验证#

总验证程序#

def verify_all(method):
    # rdm1, rdm2 here are both in mo_basis
    mf_met, C, rdm1, rdm2 = method(mf_rhf)
    h_mo = C.T @ h @ C
    g_mo = np.einsum("up, vq, uvkl, kr, ls -> pqrs", C, C, g, C, C)
    z_intg = C.T @ mol.intor("int1e_r")[2] @ C
    print("===  Energy Relat  ===  ", verify_energy_relation(mf_met.e_tot, eng_nuc, rdm1, rdm2, h_mo, g_mo))
    print("===   1-RDM Symm   ===  ", verify_rdm1_symm(rdm1))
    print("===   2-RDM Symm   ===  ", verify_rdm2_symm(rdm2))
    print("===   1-RDM Trace  ===  ", verify_rdm1_tr(rdm1))
    print("===  12-RDM Relat  ===  ", verify_rdm12_relation(rdm1, rdm2))
    print("===   1-RDM Idomp  ===  ", verify_rdm1_idomp(rdm1))
    print("===   1-RDM ov     ===  ", verify_rdm1_ov(rdm1))
    print("===   2-RDM ovov   ===  ", verify_rdm2_ovov(rdm2))
    print("=== GenFock Symm   ===  ", verify_gF_symm(rdm1, rdm2, h_mo, g_mo))
    print("===   1-RDM Dipole ===  ", verify_dip(method, rdm1, z_intg))

RHF#

def method_rhf(mf_rhf):
    mf_met = mf_rhf
    C = mf_rhf.mo_coeff
    Cinv = np.linalg.inv(C)
    # In AO basis
    rdm1 = mf_rhf.make_rdm1()
    rdm2 = np.einsum("uv, kl -> uvkl", rdm1, rdm1) - 0.5 * np.einsum("uv, kl -> ukvl", rdm1, rdm1)
    # Transform to MO basis
    rdm1 = np.einsum("pu, uv, qv -> pq", Cinv, rdm1, Cinv)
    rdm2 = np.einsum("pu, qv, uvkl, rk, sl -> pqrs", Cinv, Cinv, rdm2, Cinv, Cinv)
    return mf_met, C, rdm1, rdm2
verify_all(method_rhf)
===  Energy Relat  ===   True
===   1-RDM Symm   ===   True
===   2-RDM Symm   ===   True
===   1-RDM Trace  ===   True
===  12-RDM Relat  ===   True
===   1-RDM Idomp  ===   True
===   1-RDM ov     ===   True
===   2-RDM ovov   ===   True
=== GenFock Symm   ===   True
===   1-RDM Dipole ===   True

Full-CI#

def method_fci(mf_rhf):
    mf_met = fci.FCI(mf_rhf).run()
    C = mf_rhf.mo_coeff
    # In MO basis
    rdm1, rdm2 = mf_met.make_rdm12(mf_met.ci, mol.nao, mol.nelec)
    return mf_met, C, rdm1, rdm2
verify_all(method_fci)
===  Energy Relat  ===   True
===   1-RDM Symm   ===   True
===   2-RDM Symm   ===   True
===   1-RDM Trace  ===   True
===  12-RDM Relat  ===   True
===   1-RDM Idomp  ===   False
===   1-RDM ov     ===   False
===   2-RDM ovov   ===   False
=== GenFock Symm   ===   True
===   1-RDM Dipole ===   True

MP2#

def method_mp2(mf_rhf):
    mf_met = mp.MP2(mf_rhf).run()
    C = mf_rhf.mo_coeff
    # In MO basis
    rdm1, rdm2 = mf_met.make_rdm1(), mf_met.make_rdm2()
    return mf_met, C, rdm1, rdm2
verify_all(method_mp2)
===  Energy Relat  ===   True
===   1-RDM Symm   ===   True
===   2-RDM Symm   ===   True
===   1-RDM Trace  ===   True
===  12-RDM Relat  ===   False
===   1-RDM Idomp  ===   False
===   1-RDM ov     ===   True
===   2-RDM ovov   ===   False
=== GenFock Symm   ===   False
===   1-RDM Dipole ===   False

CCSD#

def method_ccsd(mf_rhf):
    mf_met = cc.CCSD(mf_rhf).run()
    C = mf_rhf.mo_coeff
    # In MO basis
    rdm1, rdm2 = mf_met.make_rdm1(), mf_met.make_rdm2()
    return mf_met, C, rdm1, rdm2
verify_all(method_ccsd)
===  Energy Relat  ===   True
===   1-RDM Symm   ===   True
===   2-RDM Symm   ===   True
===   1-RDM Trace  ===   True
===  12-RDM Relat  ===   True
===   1-RDM Idomp  ===   False
===   1-RDM ov     ===   False
===   2-RDM ovov   ===   False
=== GenFock Symm   ===   False
===   1-RDM Dipole ===   False

CISD#

def method_cisd(mf_rhf):
    mf_met = ci.CISD(mf_rhf).run()
    C = mf_rhf.mo_coeff
    # In MO basis
    rdm1, rdm2 = mf_met.make_rdm1(), mf_met.make_rdm2()
    return mf_met, C, rdm1, rdm2
verify_all(method_cisd)
===  Energy Relat  ===   True
===   1-RDM Symm   ===   True
===   2-RDM Symm   ===   True
===   1-RDM Trace  ===   True
===  12-RDM Relat  ===   True
===   1-RDM Idomp  ===   False
===   1-RDM ov     ===   False
===   2-RDM ovov   ===   False
=== GenFock Symm   ===   False
===   1-RDM Dipole ===   False

CASCI#

def method_casci(mf_rhf):
    mf_met = mcscf.CASCI(mf_rhf, ncas=4, nelecas=4).run()
    C = mf_met.mo_coeff
    Cinv = np.linalg.inv(C)
    # In AO basis
    rdm1, rdm2 = mcscf.addons.make_rdm12(mf_met)
    # Transform to MO basis
    rdm1 = np.einsum("pu, uv, qv -> pq", Cinv, rdm1, Cinv)
    rdm2 = np.einsum("pu, qv, uvkl, rk, sl -> pqrs", Cinv, Cinv, rdm2, Cinv, Cinv)
    return mf_met, C, rdm1, rdm2
verify_all(method_casci)
===  Energy Relat  ===   True
===   1-RDM Symm   ===   True
===   2-RDM Symm   ===   True
===   1-RDM Trace  ===   True
===  12-RDM Relat  ===   True
===   1-RDM Idomp  ===   False
===   1-RDM ov     ===   False
===   2-RDM ovov   ===   False
=== GenFock Symm   ===   False
===   1-RDM Dipole ===   False

CASSCF#

def method_casscf(mf_rhf):
    mf_met = mcscf.CASSCF(mf_rhf, ncas=4, nelecas=4).run()
    C = mf_met.mo_coeff
    Cinv = np.linalg.inv(C)
    # In AO basis
    rdm1, rdm2 = mcscf.addons.make_rdm12(mf_met)
    # Transform to MO basis
    rdm1 = np.einsum("pu, uv, qv -> pq", Cinv, rdm1, Cinv)
    rdm2 = np.einsum("pu, qv, uvkl, rk, sl -> pqrs", Cinv, Cinv, rdm2, Cinv, Cinv)
    return mf_met, C, rdm1, rdm2
verify_all(method_casscf)
===  Energy Relat  ===   True
===   1-RDM Symm   ===   True
===   2-RDM Symm   ===   True
===   1-RDM Trace  ===   True
===  12-RDM Relat  ===   True
===   1-RDM Idomp  ===   False
===   1-RDM ov     ===   False
===   2-RDM ovov   ===   False
=== GenFock Symm   ===   True
===   1-RDM Dipole ===   True

补充#

感谢 hebrewsnabla 对 CASCI、CASSCF 密度矩阵的讨论。

Rutishauser–Romberg 数值导数收敛三角的计算#

创建时间:2021-02-26

这一份简单笔记会介绍 (Generalized) Rutishauser–Romberg 数值导数收敛三角的 Python 程序的使用与原理。后文会简称 Rutishauser–Romberg 为 RR,Generalized RR 为 GRR。

由于 GRR 方法与 RR 方法仅仅差别在等比数列的比例上,因此不加赘述。我们只讨论 RR 收敛三角。

这篇文档的复现对象是 Medved et al. [1]

import numpy as np
import findiff
import pandas as pd

np.set_printoptions(precision=4, suppress=True, linewidth=120)

问题阐述#

现在我们想要对函数 \(f(x) = \sin(x - 0.5)\) 求其在零点处的三阶导数 \(f^{(3)}(0)\) 的数值结果。

我们假设上述问题有如下限制:

  • \(f(x)\) 的值可以直接获得,但 \(f\) 的任意阶导数不能直接获得;

  • \(f(x)\) 只能通过单浮点精度计算机计算 (即存在较大机器精度误差)。

理论上,最理想的结果会是 \(- \cos(- 0.5) \simeq -0.877583\)。但在只能通过数值导数计算的情况下,这个值并不是轻易可以得到并且信任的。

这样模型问题看起来比较蠢。但现实中,在遇到需要数值地求取高阶导数,却无法保证函数本身精度的情况下,这种分析模式就非常关键。例子是量子化学中的高阶极化率数值计算问题。

def f(x):
    return np.array(np.array(np.sin(x-0.5), dtype=np.float32), dtype=float)

问题的困难:过低或过高的间隔的数值误差#

对于三阶导数问题,采用五点差分时,

\[ f^{(3)} (x) \simeq \frac{1}{h^3} \left[ - \frac{1}{2} f(x-2h) + f(x-h) - f(x+h) + \frac{1}{2} f(x+2h) \right] \]

原则上,数值导数的间隔 \(h\) (interval) 越小越好。如果 \(h\) 过大 (譬如取 \(h = 0.5\)),那么结果就会与理论值 (-0.877583) 相差很大:

x, h = 0, 0.5
1/h**3 * (-0.5*f(x-2*h) + f(x-h) - f(x+h) + 0.5*f(x+2*h))
-0.8240854740142822

但如果间隔 \(h\) 太小 (譬如取 \(h = 0.005\)),那么又会出于机器精度,反而会有更大的偏差:

x, h = 0, 0.005
1/h**3 * (-0.5*f(x-2*h) + f(x-h) - f(x+h) + 0.5*f(x+2*h))
-0.9536743164062499

因此,间隔 \(h\) 必须要适中 (譬如取 \(h = 0.05\)),才有可能得到正确的结果:

x, h = 0, 0.05
1/h**3 * (-0.5*f(x-2*h) + f(x-h) - f(x+h) + 0.5*f(x+2*h))
-0.877261161804199

不仅如此,如果采用下述七点差分公式时,精度或许 (也或许不) 会有些许提升:

\[ f^{(3)} (x) \simeq \frac{1}{48 h^3} \left[ f(x-4h) - 34 f(x-2h) + 64 f(x-h) - 64 f(x+h) + 34 f(x+2h) - f(x+4h) \right] \]

差分点数量越多,一般结果越精确。但这同时也受限于方才提到的大间隔或机器精度的问题。

1/h**3 * 1/48 * (f(x-4*h) - 34*f(x-2*h) + 64*f(x-h) - 64*f(x+h) + 34*f(x+2*h) - f(x+4*h))
-0.8778721094131468

因此,随即而来的问题是,

  • 间隔 \(h\) 在什么情况下可以被信任,可能得到正确的结果?

  • 采用几点差分会有更好的效果?

问题的解决:RR 收敛三角#

通过上面简单的分析,我们知道合理的间隔在 \(0.005\)\(0.5\) 之间。因此,我们设计以两倍为间隔的等比数列,称为偏移值数列 (英文一般称为 offsets) offsets_half

offsets_half = np.array([0.004 * 2**n for n in range(10)])
offsets_half
array([0.004, 0.008, 0.016, 0.032, 0.064, 0.128, 0.256, 0.512, 1.024, 2.048])

我们对这些数,求取 \(f(x)\),生成的数组为 fx_pos \([f(0.004), f(0.008), \cdots, f(2.048)]\)

fx_pos = f(offsets_half)
fx_pos
array([-0.4759, -0.4724, -0.4653, -0.4511, -0.4223, -0.3635, -0.2416,  0.012 ,  0.5003,  0.9997])

我们同时求取 \(f(-x)\),生成的数组为 fx_neg \([f(-0.004), f(-0.008), \cdots, f(-2.048)]\)

fx_neg = f(-offsets_half)
fx_neg
array([-0.4829, -0.4864, -0.4934, -0.5073, -0.5346, -0.5875, -0.686 , -0.8479, -0.9989, -0.5593])

最后需要求取 \(f(0)\) f0

f0 = f(0)
f0
array(-0.4794)

通过下述程序给出并绘制 RR 收敛三角。其中 grrtrig 程序可供下载 grrtrig.py,而大多数函数也会在本文档重新说明。

import grrtrig
grr = grrtrig.calculate_GRR_trig(offsets_half, 3, fx_pos, fx_neg, f0)
df, _ = grrtrig.output_pd_grr_trig(offsets_half, grr)
df
0 1 2 3 4 5 6 7
0.004 -0.931323 -0.941024 -0.943126 -0.943630 -0.943755 -0.943786 -0.943793 -0.943795
0.008 -0.902219 -0.909495 -0.911364 -0.911835 -0.911953 -0.911982 -0.911989
0.016 -0.880391 -0.881452 -0.881722 -0.881791 -0.881808 -0.881813
0.032 -0.877208 -0.877397 -0.877388 -0.877386 -0.877386
0.064 -0.876639 -0.877527 -0.877527 -0.877527
0.128 -0.873975 -0.877533 -0.877554
0.256 -0.863299 -0.877214
0.512 -0.821555

绿色的值中,存有精确的三阶数值导数 \(f^{(3)}(0)\) 的概率比较大。

实现原理#

从这里开始仅仅是程序笔记。如果只关心如何使用 RR 收敛三角,上面的文档与程序应当已经足够了。

给定微小偏移函数值下的任意阶数值导数#

首先是考察下述问题。在给定任意偏移列表 (offsets) \(\{ a_m \}\) 或写作向量 \(\boldsymbol{a}\) 的情况下,其任意阶数值导数 \(f^{(n)}\) 应该如何给出?

数值导数的形式会是 (其中 \(\{ b_m \}\) 为待求量,它与导数阶数 \(n\) 有关,我们称 \(\{ b_m \}\) 为数值差分系数 Finite Difference Coefficients)

\[ f^{(n)} = \sum_{j = 0}^m b_j f(a_j) \]

举例来说,五点差分的三阶导数情况下,\(m = 5, n = 3\),并且 \(\{ a_m \} = \{ -2, -1, 0, 1, 2 \}\)。那么由此得到的 \(\{ b_m \} = \{ - 1/2, 1, 0, -1, 1/2 \}\)。但这是在间隔 \(h = 1\) 的情形下给出的。如果是其他间隔,那么

\[\begin{split} \{ a_m \} = \{ -2h, -h, 0, h, 2h \} \\ \{ b_m \} = \{ - h^{-3}/2, h^{-3}, 0, -h^{-3}, h^{-3}/2 \} \end{split}\]

这类数值差分系数可以很容易地从 Wikipedia: Finite difference coefficient 上获得。

但我们实际会遇到的是更复杂的差分情形。譬如方才提到的三阶导数的七点差分。一般的七点差分是从 -3 到 3 的等差数列;但我们方才用到的却是从 -4 到 4 的正负两条等比数列。这要求我们能对任意情形的偏移列表 \(\{ a_m \}\) 进行系数 \(\{ b_m \}\) 的计算。

其计算原理这里不详细展开。详情参考 Crtaylor 的交互网页。实现过程非常简单。

  • \(m \times m\) 矩阵 \(\mathbf{M}\),其中矩阵元 \(M_{ij} = a_j^i\)

  • \(m\) 维度向量 \(\boldsymbol{r}\),其中 \(r_n = n!\),其余值均为零。

随后求解 \(\mathbf{M} \boldsymbol{b} = \boldsymbol{r}\) 即可。

def calculate_findiff_coefs(offsets, deriv):
    """
    Calculate Finite Difference Coefficients
    
    Parameters
    ----------
    offsets : array_like
    deriv : int
    
    Returns
    -------
    coefs : ndarray
    
    Examples
    --------
    >>> calculate_findiff_coefs([-2, -1, 0, 1, 2], 4) 
    array([ 1., -4.,  6., -4.,  1.])
    
    >>> calculate_findiff_coefs([-2, -1, 0, 1, 2, 1.99], 3)
    array([  -0.1867,   -0.6722,    3.7688,   -6.0505, -124.5   ,  127.6406])
    
    References
    ----------
    https://web.media.mit.edu/~crtaylor/calculator.html
    https://github.com/maroba/findiff/blob/e8ca33707e3e25d76bf0f93b2391e466209287b1/findiff/coefs.py
    """
    
    if len(offsets) < deriv + 1:
        raise ValueError("Length of offsets should be larger than derivative order plus 1.")
    if len(offsets) != len(set(offsets)):
        # Note that this program could not handle pathological cases, only make simple check instead.
        raise ValueError("Possibly exactly same offset value is given. Please check `offsets'.")
    
    offsets = np.asarray(offsets)
    matrix = np.array([offsets**n for n in range(len(offsets))])
    rhs = np.zeros(len(offsets))
    rhs[deriv] = np.math.factorial(deriv)
    
    return np.linalg.solve(matrix, rhs)

GRR 收敛三角具体实现#

这里根据 Medveď 文章式 (15) 来实现。GRR 收敛三角的首列通过上述程序进行数值差分。如果我们令 GRR 收敛三角为矩阵 \(\mathbf{P}\),其矩阵元用 \(P_{r, c}\) 表示,那么 \(c >= 1\) 时,

\[ P_{r, c} = \frac{a^{2c} P_{r, c-1} - P_{r+1, c-1}}{a^{2c} - 1} \]

以此方式迭代即可。

事实上,如果收敛三角任意列可以全部使用上述 calculate_findiff_coefs 函数求得,并且与迭代表达式等价。但出于数值稳定性考虑,仍然选择使用迭代式。

def calculate_GRR_trig(offsets_half, deriv, fx_pos, fx_neg, f0=None):
    """
    Calculate (Generalized) Rutishauser–Romberg Triangle
    
    Parameters
    ----------
    offsets_half : array_like
        Positive half part of offsets.  For example, if all offsets are [-2, -1, 0, 1, 2],
            then `offsets_half' should be [1, 2].
        Must be a geometric sequence. This function does not make double check on this.
        Do not contain zero in this array.
    deriv : int
    fx_pos : array_like
        Value list of f(offsets_half). Dimension should be the same to `offsets_half'.
    fx_neg : array_like
        Value list of f(-offsets_half). Dimension should be the same to `offsets_half'.
    f0 : float or None
        Value of f(0). May leave as None if derivative order is odd number.
    
    Returns
    -------
    grr_trig : ndarray
        (Generalized) Rutishauser–Romberg triangle.
    """
    
    if deriv % 2 == 0 and f0 is None:
        raise ValueError("`f0' should be provided if derivative order is even number.")
    else:
        f0 = 0 if f0 is None else f0
    if len(offsets_half) < 2:
        raise ValueError("length of `offsets_half' must >= 2 in order to calculate ratio.")

    comp_len = (deriv + 1) // 2
    mat_size = len(offsets_half) - comp_len
    grr_trig = np.zeros((mat_size, mat_size))
    ratio = offsets_half[-1] / offsets_half[-2]
    
    for r in range(mat_size):
        i_end = r + comp_len
        offsets = np.concatenate([offsets_half[r:i_end], -offsets_half[r:i_end], [0]])
        coef_list = calculate_findiff_coefs(offsets, deriv)
        val_list = np.concatenate([fx_pos[r:i_end], fx_neg[r:i_end], [f0]])
        grr_trig[r, 0] = (coef_list * val_list).sum()
    for c in range(1, mat_size):
        for r in range(mat_size-c):
            grr_trig[r, c] = (ratio**(2*c) * grr_trig[r, c-1] - grr_trig[r+1, c-1]) / (ratio**(2*c) - 1)    
            
    for r in range(mat_size):
        for c in range(mat_size-r, mat_size):
            grr_trig[r, c] = np.nan
    return grr_trig

对于文档开头 \(f(x) = \sin(x - 0.5)\) 的问题,其 RR 收敛三角为

calculate_GRR_trig(offsets_half, 3, fx_pos, fx_neg, f0)
array([[-0.9313, -0.941 , -0.9431, -0.9436, -0.9438, -0.9438, -0.9438, -0.9438],
       [-0.9022, -0.9095, -0.9114, -0.9118, -0.912 , -0.912 , -0.912 ,     nan],
       [-0.8804, -0.8815, -0.8817, -0.8818, -0.8818, -0.8818,     nan,     nan],
       [-0.8772, -0.8774, -0.8774, -0.8774, -0.8774,     nan,     nan,     nan],
       [-0.8766, -0.8775, -0.8775, -0.8775,     nan,     nan,     nan,     nan],
       [-0.874 , -0.8775, -0.8776,     nan,     nan,     nan,     nan,     nan],
       [-0.8633, -0.8772,     nan,     nan,     nan,     nan,     nan,     nan],
       [-0.8216,     nan,     nan,     nan,     nan,     nan,     nan,     nan]])

实现补充#

GRR 收敛三角的合理收敛值确定#

如果不考虑机器精度所导致的误差,那么收敛三角的最右上方一定是精度 (accuracy) 阶数最高的结果。但很显然,事实是这个值反而是偏差最大的点。因此,我们需要问,上述三角的那个结果是可以信任的?

一般来说,与周围数值偏差最小的点是可以信任的。上述三角的第 1-4 列、第 3-6 行看起来比较可靠。但我们需要一种方法较为系统地评价值是否可以信任。

这里的评价标准是与下方和左方的数值进行比较;如果误差较小,那么就可以信任。因此,首列与对角线没有被纳入评判。事实上,对角线值往往更准确,因此最终评价的时候还是需要人看看周围的数值是否合理。

def check_grr_trig_converge(grr_trig):
    """
    Convergence Check Matrix of (Generalized) Rutishauser–Romberg Triangle
    
    Parameters
    ----------
    grr_trig : ndarray
        (Generalized) Rutishauser–Romberg triangle.
    
    Return
    ------
    mat_chk : ndarray
    """
    
    n = len(grr_trig)
    mat_chk = np.zeros((n, n))
    for r in range(0, n-1):
        for c in range(1, n-r-1):
            mat_chk[r, c] = np.abs(grr_trig[r, c] - grr_trig[r+1, c]) + np.abs(grr_trig[r, c] - grr_trig[r, c-1])
    for r in range(n):
        mat_chk[r, 0] = np.nan
    for r in range(0, n):
        for c in range(n-r-1, n):
            mat_chk[r, c] = np.nan
    return mat_chk
grr = calculate_GRR_trig(offsets_half, 3, fx_pos, fx_neg, f0)
check_grr_trig_converge(grr)
array([[   nan, 0.0412, 0.0339, 0.0323, 0.0319, 0.0318, 0.0318,    nan],
       [   nan, 0.0353, 0.0315, 0.0305, 0.0303, 0.0302,    nan,    nan],
       [   nan, 0.0051, 0.0046, 0.0045, 0.0044,    nan,    nan,    nan],
       [   nan, 0.0003, 0.0001, 0.0001,    nan,    nan,    nan,    nan],
       [   nan, 0.0009, 0.    ,    nan,    nan,    nan,    nan,    nan],
       [   nan, 0.0039,    nan,    nan,    nan,    nan,    nan,    nan],
       [   nan,    nan,    nan,    nan,    nan,    nan,    nan,    nan],
       [   nan,    nan,    nan,    nan,    nan,    nan,    nan,    nan]])

GRR 收敛三角在 Pandas 的绘制#

上述结果可以通过图像表格呈现。浅绿色打底的单元格是上面给出的误差最小的若干单元格附近的格点;其数值导数可以被相信的概率较大。这些单元格外围一圈也有值得考虑的价值。

def output_pd_grr_trig(offsets_half, grr_trig, tolerance=3):
    """
    Pandas Presentation of (Generalized) Rutishauser–Romberg Triangle
    
    Parameters
    ----------
    offsets_half : array_like
    grr_trig : ndarray
        (Generalized) Rutishauser–Romberg triangle.
    tolerance : int
        Number of minimum difference cells in convergence check matrix.
    
    Return
    ------
    df : pandas.io.formats.style.Styler
        Pandas show of GRR triangle.
    df_check : pandas.io.formats.style.Styler
        Pandas show of convergence check matrix of GRR triangle.
    """
    n = len(grr_trig)
    df = pd.DataFrame(grr_trig, columns=range(n), index=offsets_half[:n])
    df_check = pd.DataFrame(check_grr_trig_converge(grr_trig), columns=range(n), index=offsets_half[:n])
    df.replace(np.nan, "", regex=True, inplace=True)
    df_check.replace(np.nan, "", regex=True, inplace=True)

    t = check_grr_trig_converge(grr_trig).flatten()
    t = t.argsort()[:3]
    t = np.array([t // n, t % n]).T
    
    def highlight_cells(x):
        df = x.copy()
        df.loc[:,:] = '' 
        for r, c in t:
            df.iloc[r, c] = "background-color: lightgreen"
            df.iloc[r, c-1] = "background-color: lightgreen"
            df.iloc[r+1, c] = "background-color: lightgreen"
        return df

    df = df.style.apply(highlight_cells, axis=None)
    df_check = df_check.style.apply(highlight_cells, axis=None)
    return df, df_check
df, df_check = output_pd_grr_trig(offsets_half, grr)
df
0 1 2 3 4 5 6 7
0.004 -0.931323 -0.941024 -0.943126 -0.943630 -0.943755 -0.943786 -0.943793 -0.943795
0.008 -0.902219 -0.909495 -0.911364 -0.911835 -0.911953 -0.911982 -0.911989
0.016 -0.880391 -0.881452 -0.881722 -0.881791 -0.881808 -0.881813
0.032 -0.877208 -0.877397 -0.877388 -0.877386 -0.877386
0.064 -0.876639 -0.877527 -0.877527 -0.877527
0.128 -0.873975 -0.877533 -0.877554
0.256 -0.863299 -0.877214
0.512 -0.821555

Cholesky 分解中下三角矩阵的导数#

创建时间:2021-03-17

这份文档简单地学习 Cholesky 分解中下三角矩阵的导数。这是在实现 RI-SCF/MP2 的解析导数时所遇到的问题。

这篇文档的学习与参考文献与复现对象是 Murray [1]

import numpy as np
import scipy

Cholesky 分解回顾#

对于任意实对称正定矩阵 \(\mathbf{S} \in \mathbb{R}^{n \times n}\),其 Cholesky 分解可以通过下式给出:

\[ \mathbf{S} = \mathbf{L} \mathbf{L}^\dagger \; \text{or} \; S_{PQ} = L_{PR} L_{QR} \]

其中,\(\mathbf{L}\) 是下三角矩阵。

n = 10
S = np.cov(np.random.randn(n, 2 * n))
L = np.linalg.cholesky(S)
np.allclose(L @ L.T, S)
True

我们不讨论 Cholesky 分解的实现方式,只需要知道结论即可。

Cholesky 分解矩阵 \(\mathbf{L}\) 的数值导数#

现在假定 \(\mathbf{S}\) 是关于外部参量 \(x\) 的函数矩阵,并且 \(\partial_x \mathbf{S}\) 是已知且对称的。在这种情况下,我们希望求取 \(\partial_x \mathbf{L}\)。我们令 \(\partial_x \mathbf{S}\) 的变量名是 dS

dS = np.cov(np.random.randn(n, n))

数值导数很容易地通过数值查分方法给出:当数值导数间隔 \(h\) 很小时 (譬如 1e-6),那么下述近似关系成立:

\[ (\mathbf{L} + h \partial_x \mathbf{L}) (\mathbf{L} + h \partial_x \mathbf{L})^\dagger \simeq \mathbf{S} + h \partial_x \mathbf{S} \]

我们令通过上述方法求得的 \(\partial_x \mathbf{L}\) 的变量名是 ndL

h = 1e-7
ndL = (np.linalg.cholesky(S + h * dS) - L) / h

Cholesky 分解矩阵 \(\mathbf{L}\) 的解析导数#

通过链式法则,可以知道

\[ \partial_x \mathbf{S} = \partial_x \mathbf{L} \mathbf{L}^\dagger + \mathbf{L} \partial_x \mathbf{L}^\dagger \]

对等式两边同时左乘 \(\mathbf{L}^{-1}\) 并右乘 \(\mathbf{L}^{-\dagger}\),得到

\[ \mathbf{L}^{-1} \partial_x \mathbf{S} \mathbf{L}^{-\dagger} = \mathbf{L}^{-1} \partial_x \mathbf{L} + \partial_x \mathbf{L}^\dagger \mathbf{L}^{-\dagger} \]

我们能知道 \(\mathbf{L}^{-1}\)\(\partial_x \mathbf{L}\) 都是下三角矩阵,因此它们的乘积也是下三角矩阵。同理,\(\partial_x \mathbf{L}^\dagger \mathbf{L}^{-\dagger}\) 是上三角矩阵。这两个矩阵相互呈转置关系,因此对角线上的值是相等的。

因此,我们构造下述作用关系 (或者等价地,矩阵)

\[\begin{split} \Phi_{ij} = \left\{ \begin{aligned} & 1 && i < j \\ & 1/2 && i = j \\ & 0 && i > j \end{aligned} \right. \end{split}\]
F = np.zeros((n, n))
for i in range(n):
    F[i, :i] = 1
    F[i, i] = 1/2

那么利用上下三角的对称性,下式的下三角部分成立:

\[ \boldsymbol{\Phi} \odot (\mathbf{L}^{-1} \partial_x \mathbf{S} \mathbf{L}^{-\dagger}) = \mathbf{L}^{-1} \partial_x \mathbf{L} \]

对上式左乘 \(\mathbf{L}\),立即得到

\[ \partial_x \mathbf{L} = \mathbf{L} \boldsymbol{\Phi} \odot (\mathbf{L}^{-1} \partial_x \mathbf{S} \mathbf{L}^{-\dagger}) \]

为了程序书写方便,额外定义 Linv \(\mathbf{L}^{-1}\)。注意到点乘 \(\odot\) 的运算优先级比矩阵乘法高,但在 numpy 中点乘与矩阵乘法的运算优先级相同,因此要多加一层括号。

Linv = np.linalg.inv(L)
dL = L @ (F * (Linv @ dS @ Linv.T))

在适当的阈值下,数值与解析导数的误差相近。

np.allclose(dL, ndL, rtol=1e-5, atol=1e-6)
True

\(\mathbf{L}\) 的解析导数的快速实现#

由于矩阵求逆是 \(O(n^3)\) 运算量,计算耗时相当大;因此较为廉价的方法是利用求解线性问题,避免直接求逆。

from scipy.linalg import solve_triangular
from functools import partial
st = partial(solve_triangular, lower=True)
np.allclose(L @ (F * st(L, st(L, dS.T).T)), dL, rtol=1e-5, atol=1e-6)
True

我们现在考虑较大的矩阵 (1000 维度):

n = 1000
S = np.cov(np.random.randn(n, 2 * n))
L = np.linalg.cholesky(S)
dS = np.cov(np.random.randn(n, n))
F = np.zeros((n, n))
for i in range(n):
    F[i, :i] = 1
    F[i, i] = 1/2

其计算耗时可以估计如下:

%%timeit -n 10
# with inverse
Linv = np.linalg.inv(L)
dL = L @ (F * (Linv @ dS @ Linv.T))
67.6 ms ± 456 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)
%%timeit -n 10
# without inverse
dL = L @ (F * st(L, st(L, dS.T).T))
36.5 ms ± 1.49 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)

Python 预缓存的 Property 修饰器简单实现#

创建时间:2021-03-19

这份简短笔记,我们会讨论预缓存 Python Property (类属性) 的简单实现。

在运行程序时,特别是对于耗时但不耗内存、以后需要经常取用的计算,我们会希望找个内存或硬盘空间储存起来。为了方便起见,我们只讨论借用内存的方法。

假设现在的问题是,我们要计算 \(c = a+b, d = a^2\)。为了调用的便利,\(a, b\) 两个变量 (作为常数) 作为 property。

但麻烦之处在于,\(a, b\) 的值并不容易求,求完之后对内存的消耗却又不大。(嘛先不要追问为什么求个 1+1 要这么复杂)

\[\begin{split} \begin{align} a &= \sum_{n = 1}^{\infty} \frac{1}{2^n} \simeq \sum_{n = 1}^{1000} \frac{1}{2^n} \\ b &= \int_0^1 3 x^2 \, \mathrm{d} x \simeq \sum_{n = 0}^{5000} \frac{3 n^2}{5000^3} \end{align} \end{split}\]

同时,我们也不清楚末端用户是否需要求 \(c\) (需要同时计算 \(a\), \(b\)),还是需要求 \(d\) (只需要计算 \(a\) 即可)。

def get_a():
    a = 0
    for n in range(1, 1001):
        a += 1 / 2**n
    return a
def get_b():
    b = 0
    for n in range(5001):
        b += 3 * n**2 / 5000**3
    return b

这篇文档讨论四种做法。第一种做法简单但低效;第二、三种做法代码较复杂;第四种代码简单且不会产生多余的计算。作者倾向使用 第三种第四种 做法。

即时调用 property 定义方法#

最简单粗暴的方法是需要 \(a, b\) 时就现场计算。在第一次调用 \(a, b\) 时固然需要耗时的计算,但第二次调用仍然会相当费时。

class Dummy:
    
    @property
    def a(self):
        return get_a()
    
    @property
    def b(self):
        return get_b()
    
    @property
    def c(self):
        return self.a + self.b
    
    @property
    def d(self):
        return self.a**2
dum = Dummy()
dum.c
2.00030002
%%timeit -n 50
dum.c
2.41 ms ± 172 µs per loop (mean ± std. dev. of 7 runs, 50 loops each)

一般的 property 做法#

为了避免多余的计算,一般的方法是,需要首先在 __init__ 中声明两个隐含变量 _a, _b 以保存结果。在使用 a, b 两个 property 之前,先要使用 setter 函数作 \(a, b\) 的计算并分别保存到 _a, _b 中;随后再用 getter 函数调用它们。

class General:
    
    def __init__(self):
        self._a = NotImplemented
        self._b = NotImplemented
    
    @property
    def a(self):
        return self._a
    
    @a.setter
    def a(self, val):
        self._a = val
    
    @property
    def b(self):
        return self._a
    
    @b.setter
    def b(self, val):
        self._b = val
    
    @property
    def c(self):
        return self.a + self.b
    
    @property
    def d(self):
        return self.a**2

如果没有预先使用 setter 函数,就会碰到下面这种尴尬的情况:

gen = General()
gen.c
-------------------------------------------------
TypeError       Traceback (most recent call last)
<ipython-input-7-ed9f42ab8466> in <module>
      1 gen = General()
----> 2 gen.c

<ipython-input-6-7edbb0413f0d> in c(self)
     23     @property
     24     def c(self):
---> 25         return self.a + self.b
     26 
     27     @property

TypeError: unsupported operand type(s) for +: 'NotImplementedType' and 'NotImplementedType'

因此,正确的调用方式是

gen = General()
gen.a, gen.b = get_a(), get_b()
gen.c, gen.d
(2.0, 1.0)

上面的步骤是耗时的,但随后当要调用 a, b 变量时,就会快捷很多:

%%timeit -n 50
gen.c
265 ns ± 28 ns per loop (mean ± std. dev. of 7 runs, 50 loops each)

但这里有一个问题:\(a, b\) 的值实际上可以看作常数;如果末端用户真正希望得到的是 \(d = a^2\) 而非 \(c = a + b\),那么实际上用户不需要 \(b\),自然也就不需要对其花时间赋值了。决定是否要对 \(b\) 赋值的任务由此交给末端用户,这会造成一些困扰。

偷懒的做法:将赋值函数嵌入 getter 函数#

如果这个任务交给程序编写者,那么一种最简单的实现方式是把赋值函数嵌入到 getter 函数中:

  • 如果 \(a\) 的值已经被计算过,那么就从缓存空间 _a 取出该值;

  • 如果 \(a\) 被调用前没有被计算过,那么就计算该值并放入缓存 _a

class Improved:
    
    def __init__(self):
        self._a = NotImplemented
        self._b = NotImplemented
    
    @property
    def a(self):
        if self._a is NotImplemented:
            self._a = get_a()
        return self._a
    
    @property
    def b(self):
        if self._b is NotImplemented:
            self._b = get_b()
        return self._b
    
    @property
    def c(self):
        return self.a + self.b
    
    @property
    def d(self):
        return self.a**2

如果末端用户只需要求 \(d = a^2\),那么耗费时间的关键步就只有 \(a\) 的计算;缓存空间 _b 就是空的。

imp = Improved()
print(imp.d)
print(imp._b)
1.0
NotImplemented

同时,以后再需要调用 \(d\) 时,\(a\) 的值也不会再被计算第二次。

当然,这种做法的弊端是,用户原则上无权限更改 \(a, b\) 的值 (通过更改隐含变量 _a, _b 是可能的,但这违背了 PEP8 的程序规范)。

改进的做法:缩减隐含变量的声明#

但上面的定义仍然有很多冗余。对于每个 property,我们总要声明隐含变量、调用时判断是否缓存空间存在。这两步可以通过改编在 property 修饰器内部增加一段代码方便地实现。这个修饰器我们命名为 cached_property

def cached_property(f):
    def wrap(*args, **kwargs):
        self = args[0]                                                    # self
        _f = "_" + f.__name__                                             # _a
        if not hasattr(self, _f) or getattr(self, _f) is NotImplemented:  # if self._a is NotImplemented:
            setattr(self, _f, f(*args))                                   # self._a = get_a()
        return getattr(self, _f)                                          # return self._a
    return property(wrap)                                                 # make this wrap a property

这样之后,不仅代码量减少很多 (调用方式与最简单的 Dummy 完全一致),同时也避免多余重复的计算。

class Advanced:
    
    @cached_property
    def a(self):
        return get_a()
    
    @cached_property
    def b(self):
        return get_b()
    
    @property
    def c(self):
        return self.a + self.b
    
    @property
    def d(self):
        return self.a**2
adv = Advanced()
print(adv.d)
print(hasattr(adv, "_b"))
1.0
False

计算化学的低标度 Laplace-Transform 方法格点导出思路#

创建时间:2022-10-26

在这份文档中,我们会首先介绍 Laplace-Transform 的定义与意义。随后给出关于 Laplace-Transform 的格点优化思路。最后我们呈现了多种情况下的预优化格点,并比较了不同的积分区间下,格点的数量对 Laplace-Transform 作为近似的误差的影响程度。

import matplotlib.pyplot as plt
from matplotlib_inline.backend_inline import set_matplotlib_formats
%matplotlib inline

set_matplotlib_formats('svg')
import numpy as np
import pandas as pd
import pickle
from scipy import integrate, optimize, interpolate
from scipy.special import expi, exp1, roots_laguerre
from numpy import exp

Laplace-Transform 方法简介#

\(1/x\) 函数的变换#

计算化学中,特别是类似于 MP2 或 MPn 方法、或者 RPA 方法,会对表达式 \(1/x\) 进行下述变换:

\[ \frac{1}{x} = \int_0^{+\infty} \mathrm{e}^{- t x} \, \mathrm{d} t \simeq \mathrm{LT} (\boldsymbol{t}, \boldsymbol{w}, x) \mathrel{:=} \sum_\alpha^\tau w_\alpha \mathrm{e}^{- t_\alpha x} \]

对于第二个约等号,它就是将解析的积分转换为数值的积分;其数值格点为 \(\boldsymbol{t}\) 或记为 \(t_\alpha\),格点权重为 \(\boldsymbol{w}\) 或记为 \(w_\alpha\)

对于 Laplace-Transform 近似函数 \(\mathrm{LT} (\boldsymbol{t}, \boldsymbol{w}, x)\),其程序化实现可以通过下述函数给出:

def get_LT(t, w):
    t = np.asarray(t).reshape(-1, 1)
    w = np.asarray(w).reshape(-1, 1)
    def inner(x):
        res = (w * np.exp(- t * x)).sum(axis=0)
        return res if res.size > 1 else res[0]
    return inner

\([0,+\infty)\) 上的格点积分通常会选用 Gauss-Laguerre。因此我们不妨以这个格点,进行简单的计算。如果现在的 \(x = 2\),那么我们预期的 \(\mathrm{LT} (\boldsymbol{t}, \boldsymbol{w}, x) \simeq 1/x = 0.5\)。下述程序的 t_GL20w_GL20 分别是 20 阶 Gauss-Laguerre 格点的坐标与权重。

t_GL20 = roots_laguerre(20)[0]
w_GL20 = np.exp(t_GL20) * roots_laguerre(20)[1]
get_LT(t_GL20, w_GL20)(2)
0.4999999999999992

Laplace-Transform 在张量运算的意义#

事实上 Laplace-Transform 是将一个非常简单的倒数运算,转换为一个非常复杂的格点积分。在大多数情况下,这样的计算方式是非常划不来的。

但对于一些张量运算问题,这种化简为繁的变换反而会给出更优的计算复杂度。在化学中较早利用这一点的文献可以参考 Häser JCP 1991 [1]。以下述计算问题为例:

\[ E = \sum_{ijk}^n \frac{A_{ik} A_{jk}}{\varepsilon_i + \varepsilon_j} \]

出于方便,我们令角标 \(i, j, k\) 所对应的维度均为 \(n = 1000\)。该问题有两种计算上先后的方法:

\[\begin{split} \begin{align*} B_{jk} = \sum_i^n \frac{A_{ik}}{\varepsilon_i + \varepsilon_j}, \; E &= \sum_{jk}^n B_{jk} A_{jk}; \text{or} \\ C_{ij} = \sum_k^n A_{ik} A_{jk}, \; E &= \sum_{ij}^n \frac{C_{ij}}{\varepsilon_i + \varepsilon_j} \end{align*} \end{split}\]

不论哪种计算方法,总是有单步的运算涉及到 3 个角标,因此我们认为计算复杂度为 \(O(n^3) \sim 10^{9}\)

但若引入 Laplace-Transform,那么计算问题就化为

\[\begin{split} \begin{align*} E &= \sum_{ijk}^n \frac{A_{ik} A_{jk}}{\varepsilon_i + \varepsilon_j} \\ &\sim \sum_{ijk}^n \sum_\alpha^\tau w_\alpha \mathrm{e}^{- t_\alpha (\varepsilon_i + \varepsilon_j)} A_{ik} A_{jk} \\ &= \sum_{k}^n \sum_\alpha^\tau \left( w_\alpha \cdot \sum_i^n A_{ik} \mathrm{e}^{- t_\alpha \varepsilon_i} \cdot \sum_j^n A_{jk} \mathrm{e}^{- t_\alpha \varepsilon_j} \right) \end{align*} \end{split}\]

其中 \(\alpha\) 是 Laplace-Transform 所需要的格点角标,不妨认为其格点总数 \(\tau \sim 20\) (事实上这个数量在实际问题中可以更低)。现在令

\[ D_{k\alpha} = \sum_i^n A_{ik} \mathrm{e}^{- t_\alpha \varepsilon_i} \]

该步的计算过程牵涉到角标 \(i k \alpha\),计算复杂度是 \(O(n^2 \tau) \sim 2 \times 10^7\)。随后一步是

\[ E = \sum_{k}^n \sum_\alpha^\tau w_\alpha D_{k \alpha}^2 \]

这一步只牵涉到角标 \(k \alpha\),其计算消耗不关键。因此 Laplace-Transform 下整个算术计算量大概是 \(2 \times 10^7\),比起未变换的方法要快上 \(n / \tau \sim 50\) 倍。至此,化简为繁的 Laplace-Transform 在一部分张量运算中的意义就凸显出来了。

Laplace-Transform 作为近似的准确性#

20 阶 Gauss-Laguerre 格点准确性的绘图展示#

我们先前已经验证了 \(x = 2\) 时,在 20 阶 Gauss-Laguerre 格点下的 Laplace-Transform 的合理性了。

我们进而会再问

  1. Laplace-Transform 作为近似,有多准确,在何种精度要求下可以使用;

  2. 格点数 \(\tau\) 与精度之间的关系怎样,多大程度上可以节省格点数量。

这节先解决第一个问题。我们首先以两种格点呈现 Laplace-Transform 准确性:

  • 20 阶 Gauss-Laguerre 格点 (记为 GL-20),格点坐标变量为 t_GL20、权重坐标变量为 w_GL20;这组格点我们已经生成过了;

  • 10 点优化后的格点 (记为 L2log-10),格点坐标变量为 t_L2log10、权重坐标变量为 w_L2log10;这组格点定义在下面的代码块中。

t_L2log10 = 10**np.array([
    -2.53336424, -1.68532097, -1.0947392 , -0.5937269 , -0.1473786 ,
     0.2593688 ,  0.63558393,  0.98782998,  1.32281566,  1.65472166])
w_L2log10 = 10**np.array([
    -2.09510186, -1.50144309, -1.00290442, -0.55908055, -0.1570772 ,
     0.21293416,  0.55798319,  0.88444692,  1.20302408,  1.55159606])

第一张图展示了函数 \(1/x\)\(\mathrm{LT} (\boldsymbol{t}, \boldsymbol{w}, x)\) 的大体趋势。从下图中,我们能看到的情况是:

  • 对于格点 GL-20,并不是所有的 Laplace-Transform 函数 \(\mathrm{LT} (\boldsymbol{t}, \boldsymbol{w}, x)\) 的结果都能接近真值 \(1/x\),而只有 \((0.1, 20)\) 区间内的结果接近;

  • 对于格点 L2log-10,则在 \((0.1, 500)\) 区间内的结果都接近真值 \(1/x\)

Hide code cell source
fig, ax = plt.subplots(figsize=(5, 4))
x = np.logspace(-4, 4, 10000)
ax.plot(x, 1 / x, color="black", linewidth=1, label="1 / x")
ax.plot(x, get_LT(t_GL20, w_GL20)(x), label=r"GL-20")
ax.plot(x, get_LT(t_L2log10, w_L2log10)(x), label=r"L2log-10")
ax.fill_between([0.1, 500], [1e10, 1e10], color="C2", alpha=0.2)
ax.set_xscale("log")
ax.set_yscale("log")
ax.set_xlim(1e-4, 1e4)
ax.set_ylim(1e-4, 1e4)
ax.set_xlabel("$x$")
ax.set_ylabel("$y$")
ax.set_title("General trend of $1 / x \sim LT(t,w,x)$")
ax.legend()
fig.tight_layout()
_images/e410493a10ae72c8081f95f7625d0cf311246378b4e7413f2194b2802555437b.svg

若我们绘制绝对误差图,则可以更加明显地看到这个现象。

  • GL-20 近似的函数在 \((0.2, 9)\) 的区间内,可以非常接近 \(1/x\);但一旦超过这个区间,则近似的准确性急剧下降。

  • 而对于 L2log-10 格点,在 \((0.1, 500)\) 的区间内,近似函数的误差在 \(10^{-4}\) 以内波动;而超过这一区间,近似准确性也同样急剧下降。

Hide code cell source
fig, ax = plt.subplots(figsize=(5, 3))
ax.plot(x, get_LT(t_GL20, w_GL20)(x) - 1 / x, label="GL-20")
ax.plot(x, get_LT(t_L2log10, w_L2log10)(x) - 1 / x, label="L2log-10")
ax.fill_between([0.1, 500], [-1e-4, -1e-4], [1e-4, 1e-4], color="C2", alpha=0.2)
ax.set_xscale("log")
ax.set_xlim(1e-2, 1e3)
ax.set_ylim(-3e-4, 3e-4)
ax.set_xlabel("$x$")
ax.set_ylabel("Deviation")
ax.set_title("Error function $LT(t,w,x) - 1/x$")
ax.legend()
fig.tight_layout()
_images/3c1616cfc1cb876c490ec3d6089869b374c8184c99c0ce36c7be27309bdf550f.svg

在化学中,我们对这类近似函数的要求有两点:

  • 近似的绝对误差应当在 \(10^{-4}\) 级别;这对应的能量误差是 \(10^{-4}\) Hartree,即 0.06 kcal/mol。之所以要有这个精度要求,是因为化学精度为 1 kcal/mol。为了避免数值计算误差影响化学的认识,我们希望将误差控制在非常小的范围内。

  • 至少应当保证区间 \(x \in (0.1, 500)\) 内的数值有较好的近似。对于 MP2 方法而言,\(x\) 就是轨道能级的差值 \(\varepsilon_a + \varepsilon_b - \varepsilon_i - \varepsilon_j\);对于一般的分子而言,该数值常见的区间即是 \((0.1, 500)\)

因此,如果只是使用 20 阶的 Gauss-Laguerre 格点 GL-20,是无法满足计算化学对求取 \(1/x\) 运算的需求的。相比而言,误差稍大但可接受的 L2log-10 格点不仅数量更少,并且更为适合用于化学的实际问题。

误差量标#

在进一步考虑优化格点前,我们先规定近似函数的误差值如何确定。

其中一种误差量标是 \(L_2^\mathrm{log}\) 量标。以格点 \(\boldsymbol{t}\) 与相应权重 \(\boldsymbol{w}\) 为变量,令下述积分损失函数 \(I_2^\mathrm{log}\)

\[\begin{split} \begin{align*} I_2^\mathrm{log} (\boldsymbol{t}, \boldsymbol{w}) &= \int_{x_\min}^{x_\max} \left( \mathrm{LT} (\boldsymbol{t}, \boldsymbol{w}, x) - \frac{1}{x} \right)^2 \mathrm{d} \log x \\ &= \int_{x_\min}^{x_\max} \frac{1}{x} \left( \sum_\alpha^\tau w_\alpha \mathrm{e}^{- t_\alpha x} - \frac{1}{x} \right)^2 \mathrm{d} x \\ &= \frac{1}{2} \left( \frac{1}{x_\min^2} - \frac{1}{x_\max^2} \right) + \sum_{\alpha \beta}^\tau w_\alpha w_\beta \left( E_1 \big( (t_\alpha + t_\beta) x_\min \big) - E_1 \big( (t_\alpha + t_\beta) x_\max \big) \right) \\ &\quad + 2 \sum_\alpha^\tau w_\alpha \left( t_\alpha \big( E_1 (t_\alpha x_\min) - E_1 (t_\alpha x_\max) \big) - \frac{\mathrm{e}^{- t_\alpha x_\min}}{x_\min} + \frac{\mathrm{e}^{- t_\alpha x_\max}}{x_\max} \right) \end{align*} \end{split}\]

并令误差函数 \(L_2^\mathrm{log}\)

\[ L_2^\mathrm{log} (\boldsymbol{t}, \boldsymbol{w}) = \sqrt{\frac{I_2^\mathrm{log} (\boldsymbol{t}, \boldsymbol{w})}{\log x_\max - \log x_\min}} \]

损失函数 \(I_2^\mathrm{log}\) 可以用于求取极小值;误差函数 \(L_2^\mathrm{log}\) 可以用于确定与比较误差数值。

上面所出现的函数 \(E_1\) 是指数积分函数 (参考 NIST 数学函数 [2]):

\[ E_1 (z) = \int_z^{+\infty} \frac{\mathrm{e}^{-t}}{t} \mathrm{d} t \]

下述程序 loss_L2logerr_L2log 分别是求取 \(I_2^\mathrm{log}\)\(L_2^\mathrm{log}\) 的函数。

def loss_L2log(t, w, x_min, x_max):
    t2 = t[:, None] + t
    w2 = w[:, None] * w
    l = 0.5 * (1 / x_min**2 - 1 / x_max**2)
    l += (w2 * (exp1(x_min * t2) - exp1(x_max * t2))).sum()
    l += 2 * w @ (t * (exp1(x_min * t) - exp1(x_max * t)) - exp(- x_min * t) / x_min + exp(- x_max * t) / x_max)
    return l
def err_L2log(t, w, x_min, x_max):
    return np.sqrt(loss_L2log(t, w, x_min, x_max) / (np.log(x_max) - np.log(x_min)))

我们可以对先前考察过的格点 GL20 (Gauss-Laguerre 20 格点) 与 L2log-10 (优化的 10 格点) 给出 \((0.1, 500)\) 区间内的 \(L_2\) 误差值:

err_L2log(t_GL20, w_GL20, 0.1, 500)
0.005887670938109512
err_L2log(t_L2log10, w_L2log10, 0.1, 500)
4.676641107400367e-05

权重的确定方式#

若已知格点 \(\boldsymbol{t}\) 使得 \(I_2^\mathrm{log} (\boldsymbol{t}, \boldsymbol{w})\) 可以取到最小值,那么其对应的格点权重 \(\boldsymbol{w}\) 可以通过下述方式给出:

\[ \boldsymbol{w}: \frac{\partial I_2^\mathrm{log} (\boldsymbol{t}, \boldsymbol{w})}{\partial \boldsymbol{w}} = \boldsymbol{0} \]

该求取过程可以化为下述向量计算问题:

\[ \mathbf{A} \boldsymbol{w} = \boldsymbol{b} \]

其中,

\[\begin{split} \begin{align*} A_{\alpha \beta} &= \int_{x_\min}^{x_\max} \frac{\mathrm{e}^{- (t_\alpha + t_\beta) x}}{x} \mathrm{d} x \\ &= E_1 \big( (t_\alpha + t_\beta) x_\min \big) - E_1 \big( (t_\alpha + t_\beta) x_\max \big) \\ b_\alpha &= \int_{x_\min}^{x_\max} \frac{\mathrm{e}^{- t_\alpha x}}{x^2} \mathrm{d} x \\ &= \frac{\mathrm{e}^{- t_\alpha x_\min}}{x_\min} - \frac{\mathrm{e}^{- t_\alpha x_\max}}{x_\max} - t_\alpha \big( E_1 (t_\alpha x_\min) - E_1 (t_\alpha x_\max) \big) \end{align*} \end{split}\]

下述函数可以求得特定格点 \(\boldsymbol{t}\) 下对应的权重 \(\boldsymbol{w}\)

def get_w_L2log(t, x_min, x_max):
    t2 = t[:, None] + t
    a = exp1(t2 * x_min) - exp1(t2 * x_max)
    b = exp(- t * x_min) / x_min - exp(- t * x_max) / x_max - t * (exp1(t * x_min) - exp1(t * x_max))
    return np.linalg.solve(a, b)

格点优化#

到此为止,我们可以实现格点优化了。但其中仍然有一些实现细节需要关注:

  • 我们的目标误差是非常小的,\(L_2^\mathrm{log}\) 可能在 \(10^{-5}\) 量级,而 \(I_2^\mathrm{log}\) 可能在 \(10^{-8}\) 量级。将 \(I_2^\mathrm{log}\) 交给库函数进行优化时,很可能因为函数值变化太小而不会优化到底。因此,我们可以对 \(I_2^\mathrm{log}\) 乘上较大的数 (\(10^{7}\)) 以避免优化不进行。

  • 由于格点可能非常小,格点更适合以其对数 \(\log \boldsymbol{t}\) 而非直接对 \(\boldsymbol{t}\) 进行优化。

  • 对于 L-BFGS-B 或 BFGS 等直接利用梯度信息的方法,由于数值误差的存在,需要对数值导数的差分值作额外设定,并且有可能无法优化到最佳情况。在这些梯度方法后,最好接上 Simplex 优化方法譬如 Nelder-Mead 等,并且可能需要使用多次,经常可以再将误差缩小一些。

  • 一些特殊情况下,优化过程中,两个格点距离太近;这导致权重数值会存在巨大的一正一负。这容易产生严重的数值误差,从而给出错误的负的损失函数。遇到这种情况需要手动重新优化。

下述函数过程就是对 \((x_\min, x_\max) = (0.1, 500)\) 区间,对于 10 格点情形,以 \([10^{-3}, 10^{2.5}]\)\(\boldsymbol{t}\) 的取值区间的等比数列为初猜,作以 \(I_2^\mathrm{log}\) 的优化。优化后的结果中,res.x\(\log \boldsymbol{t}\),而 res.fun\(10^7 I_2^\mathrm{log}\) 的数值。

def loss_work_L2log(x_min, x_max):
    def inner(t_log10):
        t = 10**t_log10
        w = get_w_L2log(t, x_min, x_max)
        l = loss_L2log(t, w, x_min, x_max)
        return l * 1e7
    return inner
t_init = np.linspace(-3, 2.5, 10)
res = optimize.minimize(loss_work_L2log(0.1, 500), t_init,
                        method="L-BFGS-B", options={"maxiter": 100, "eps": 1e-5})
print("I2log Optimize after L-BFGS-B           :", res.fun)
res = optimize.minimize(loss_work_L2log(0.1, 500), res.x,
                        method="Nelder-Mead", options={"maxiter": 5000})
print("I2log Optimize after Nelder-Mead, iter 1:", res.fun)
res = optimize.minimize(loss_work_L2log(0.1, 500), res.x,
                        method="Nelder-Mead", options={"maxiter": 5000})
print("I2log Optimize after Nelder-Mead, iter 2:", res.fun)
I2log Optimize after L-BFGS-B           : 0.47177181272672897
I2log Optimize after Nelder-Mead, iter 1: 0.18628000475473527
I2log Optimize after Nelder-Mead, iter 2: 0.18627957842909382
res.x
array([-2.53341571, -1.68532385, -1.09473744, -0.59372725, -0.14735734,
        0.25941341,  0.63564138,  0.98788772,  1.32286416,  1.6547582 ])
np.log10(get_w_L2log(10**res.x, 0.1, 500))
array([-2.0951377 , -1.50142473, -1.00291163, -0.55906999, -0.1570291 ,
        0.21299961,  0.55804858,  0.88449758,  1.20305738,  1.55161723])

事实上我们之前使用的 L2log-10 格点 t_L2log10 就是通过这种方式导出的。可以验证,10**res.x 的结果与 t_L2log10 是非常接近的。

更普遍的格点#

对于一般情况而言,上述的 \((x_\min, x_\max) = (0.1, 500)\) 区间内 10 点格点,已经足够用于一般的化学问题了。但如果遇到更普遍的问题,比如分子体系或基组过大 (以至于分母数值需要上千),或者 HOMO/LUMO gap 数值太小 (小于 0.1 Hartree),那么上述的格点就不适用了。

同时,如果实际的问题要求并不高,只需要在区间 \((x_\min, x_\max) = (0.5, 50)\) 能计算准确即可 (譬如水分子的 B3LYP/6-31G),那么 10 个积分格点甚至都太多了。

由于当前问题作为最小值优化问题,其存在较大的潜在数值误差问题;因此实际上不适合依据化学问题的需求,自动化地给出当前问题最佳的格点。这更适合预先手动优化一些格点;而后依据特定问题和特定需求,代入合适的预优化的格点。这一节我们就对一些可以预见的情况作提前优化。

在优化中所使用到的一些方便的函数被折叠在下方。

Hide code cell source
def print_outputs(res, x_min, x_max, nspace=0, ret=False):
    x_list = np.logspace(np.log10(x_min), np.log10(x_max), 100000)
    t1 = np.sort(res.x)
    t2 = get_w_L2log(10**t1, x_min, x_max)
    t3 = np.log10(np.abs(t2))
    t4 = np.sign(t2)
    t5 = err_L2log(10**t1, t2, x_min, x_max)
    t6 = np.abs(get_LT(10**t1, t2)(x_list) - 1 / x_list).max()
    print(" "*nspace + "\"grid_log\": " + "[" + ", ".join(["{:18.10}".format(i).strip() for i in t1]).strip() + "],")
    print(" "*nspace + "\"weight_log\": " + "[" + ", ".join(["{:18.10}".format(i).strip() for i in t3]).strip() + "],")
    print(" "*nspace + "\"weight_sgn\": " + "[" + ", ".join(["{:}".format(int(i)) for i in t4]).strip() + "],")
    print(" "*nspace + "\"L2_err\": {:10.4e},".format(t5))
    print(" "*nspace + "\"max_err\": {:10.4e},".format(t6))
    if ret:
        return {
            "grid_log": t1,
            "weight_log": t3,
            "weight_sgn": np.int(t4),
            "L2_err": t5,
            "max_err": t6,
        }
Hide code cell source
def scale_points(x, tau, x_min=None, x_max=None):
    x = np.sort(x)
    x_min = np.min(x) if x_min is None else x_min
    x_max = np.max(x) if x_max is None else x_max
    spl = interpolate.interp1d(np.linspace(0, 1, res.x.size), np.sort(res.x))
    new_x = spl(np.linspace(0, 1, tau))
    new_x = x_min + (new_x - new_x.min()) * (x_max - x_min) / (new_x.max() - new_x.min())
    return new_x

优化的结果放入变量 optimized_LT 中。由于数据量太大,我们将该结果也进行了折叠。

Hide code cell source
optimized_LT = {
    (0.01, 100): {
        5: {
            "grid_log": [-0.4437405929, 0.5680664814, 1.278880164, 1.859062781, 2.371080954],
            "weight_log": [0.04714588325, 0.8394718905, 1.441894483, 1.951473521, 2.431730904],
            "weight_sgn": [1, 1, 1, 1, 1],
            "L2_err": 4.2277e-02,
            "max_err": 1.8173e-01,
        },
        6: {
            "grid_log": [-0.8234891489, 0.1883133656, 0.899042822, 1.478150712, 1.980197149, 2.440110907],
            "weight_log": [-0.3326039064, 0.4597124774, 1.061884433, 1.568330784, 2.01879262, 2.461305197],
            "weight_sgn": [1, 1, 1, 1, 1, 1],
            "L2_err": 1.8206e-02,
            "max_err": 7.8775e-02,
        },
        7: {
            "grid_log": [-1.174354992, -0.1631596654, 0.5472191491, 1.125972089, 1.626428494, 2.075227207, 2.496803354],
            "weight_log": [-0.6836876674, 0.1079912861, 0.7098673755, 1.215686496, 1.661762788, 2.070902548, 2.485406634],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1],
            "L2_err": 8.1051e-03,
            "max_err": 3.6127e-02,
        },
        8: {
            "grid_log": [-1.453308278, -0.4621218916, 0.237216967, 0.8096459118, 1.30572437, 1.749736539, 2.157236757, 2.547492197],
            "weight_log": [-0.9699507922, -0.1991141396, 0.3943135352, 0.8952459368, 1.337318234, 1.738701947, 2.115287004, 2.506806312],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 3.5244e-03,
            "max_err": 1.6601e-02,
        },
        9: {
            "grid_log": [-1.624636324, -0.6821797381, -0.01273343996, 0.5419687112, 1.026256825, 1.461453044, 1.860318313, 2.232960685, 2.595707863],
            "weight_log": [-1.158249975, -0.4412557822, 0.1285364341, 0.6158267211, 1.048525524, 1.442023583, 1.806885567, 2.155705499, 2.527002767],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 1.4876e-03,
            "max_err": 7.3290e-03,
        },
        10: {
            "grid_log": [-1.740400638, -0.8481044747, -0.216078561, 0.3145355877, 0.7822079445, 1.205126595, 1.594205485, 1.957020797, 2.300800219, 2.640064945],
            "weight_log": [-1.289811817, -0.6345529595, -0.09669315373, 0.3715064383, 0.7908808129, 1.174311917, 1.53025651, 1.865676001, 2.191444221, 2.545485992],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 6.2059e-04,
            "max_err": 3.2005e-03,
        },
        11: {
            "grid_log": [-1.82858538, -0.9777023776, -0.3840974739, 0.1193166471, 0.5675854925, 0.9759903722, 1.353745163, 1.70710788, 2.040947923, 2.360856865, 2.680212034],
            "weight_log": [-1.389584568, -0.7917142077, -0.2900725754, 0.1559216171, 0.5596742206, 0.9313260151, 1.277872208, 1.604400595, 1.915984804, 2.222651951, 2.562096757],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 2.5728e-04,
            "max_err": 1.4121e-03,
        },
        12: {
            "grid_log": [-1.908899609, -1.092661659, -0.5390285127, -0.06710187223, 0.3589718988, 0.752204805, 1.119476284, 1.465308101, 1.7930494, 2.105728436, 2.407870343, 2.71214226],
            "weight_log": [-1.478572506, -0.9344364428, -0.4754208084, -0.05560828848, 0.3320590494, 0.6934745771, 1.033043736, 1.35423825, 1.659968777, 1.95418483, 2.246660044, 2.575125909],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 1.0888e-04,
            "max_err": 7.0355e-04,
        },
    },
    (0.01, 200): {
        5: {
            "grid_log": [-0.4437436926, 0.568063034, 1.278877359, 1.859060322, 2.371079684],
            "weight_log": [0.04714223259, 0.8394689042, 1.441891797, 1.951471739, 2.431730734],
            "weight_sgn": [1, 1, 1, 1, 1],
            "L2_err": 4.0817e-02,
            "max_err": 1.8173e-01,
        },
        6: {
            "grid_log": [-0.8234847723, 0.1883217738, 0.8990457539, 1.478151133, 1.980198196, 2.440111772],
            "weight_log": [-0.3325963085, 0.4597192776, 1.061883774, 1.568331169, 2.018794098, 2.461305437],
            "weight_sgn": [1, 1, 1, 1, 1, 1],
            "L2_err": 1.7665e-02,
            "max_err": 7.8775e-02,
        },
        7: {
            "grid_log": [-1.175842415, -0.1640443831, 0.5466618933, 1.12559987, 1.626176465, 2.075059998, 2.496700601],
            "weight_log": [-0.6849559205, 0.1073478029, 0.709472289, 1.215432764, 1.661602112, 2.070809071, 2.485362505],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1],
            "L2_err": 8.0511e-03,
            "max_err": 3.6181e-02,
        },
        8: {
            "grid_log": [-1.503672049, -0.4928481615, 0.2173329618, 0.7959560992, 1.2960896, 1.743004847, 2.152690615, 2.544638883],
            "weight_log": [-1.013140626, -0.221844636, 0.3798838248, 0.8855480362, 1.330812734, 1.734522949, 2.112839607, 2.505604976],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 3.7512e-03,
            "max_err": 1.7381e-02,
        },
        9: {
            "grid_log": [-1.761192156, -0.7713048249, -0.07268012984, 0.4993472568, 0.9951145708, 1.438602923, 1.843840387, 2.221544565, 2.588364019],
            "weight_log": [-1.278304037, -0.5088377335, 0.08406420564, 0.5846754914, 1.026403862, 1.426628799, 1.796750558, 2.149658067, 2.523955193],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 1.7069e-03,
            "max_err": 8.3381e-03,
        },
        10: {
            "grid_log": [-1.922512198, -0.9787833181, -0.3085033904, 0.2467157485, 0.7313726085, 1.166817533, 1.565508312, 1.935914608, 2.285839768, 2.630176146],
            "weight_log": [-1.455699893, -0.7372395324, -0.166775232, 0.3209331004, 0.7539438772, 1.147534509, 1.511278191, 1.852874783, 2.183569632, 2.54136248],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 7.5795e-04,
            "max_err": 3.8758e-03,
        },
        11: {
            "grid_log": [-2.051259494, -1.163289631, -0.5332517107, -3.149319669e-05, 0.4734304508, 0.9025121735, 1.296759573, 1.663343815, 2.008047203, 2.337080831, 2.664193686],
            "weight_log": [-1.602008549, -0.9520988802, -0.4136804344, 0.06103450782, 0.4882901143, 0.8778382224, 1.238037723, 1.575467788, 1.896113816, 2.21024904, 2.555476159],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 3.3833e-04,
            "max_err": 1.9705e-03,
        },
        12: {
            "grid_log": [-2.125389659, -1.27227999, -0.6756846045, -0.1692338007, 0.2820187468, 0.6933728662, 1.073924742, 1.429736809, 1.765265245, 2.084360829, 2.392041247, 2.701200571],
            "weight_log": [-1.685857624, -1.084474014, -0.5791975874, -0.1298790392, 0.2771255173, 0.6519134861, 1.001208062, 1.329788235, 1.641578777, 1.94113577, 2.238278986, 2.570515491],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 1.4687e-04,
            "max_err": 9.0160e-04,
        },
        13: {
            "grid_log": [-2.242663993, -1.438882431, -0.8999751995, -0.4334919477, -0.0004036354294, 0.4070801402, 0.7893808431, 1.148142368, 1.486662411, 1.808321239, 2.116272199, 2.414952595, 2.716716004],
            "weight_log": [-1.815253757, -1.291732456, -0.8464302187, -0.4208923358, -0.0148645491, 0.3657031429, 0.7198687453, 1.051994314, 1.366815622, 1.667825022, 1.95893656, 2.249491558, 2.57670034],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 8.1263e-05,
            "max_err": 6.3702e-04,
        },
    },
    (0.01, 500): {
        5: {
            "grid_log": [-0.4437410932, 0.5680651316, 1.278879221, 1.859063022, 2.371082342],
            "weight_log": [0.04714490555, 0.8394705056, 1.44189409, 1.951474926, 2.431732818],
            "weight_sgn": [1, 1, 1, 1, 1],
            "L2_err": 3.9063e-02,
            "max_err": 1.8172e-01,
        },
        6: {
            "grid_log": [-0.8234842002, 0.1883157611, 0.8990437752, 1.478151282, 1.980197022, 2.440109457],
            "weight_log": [-0.3325998434, 0.4597137903, 1.061884804, 1.568331181, 2.018791339, 2.461302784],
            "weight_sgn": [1, 1, 1, 1, 1, 1],
            "L2_err": 1.6929e-02,
            "max_err": 7.8781e-02,
        },
        7: {
            "grid_log": [-1.17584977, -0.164059402, 0.5466511229, 1.125592263, 1.626174457, 2.075061097, 2.496702208],
            "weight_log": [-0.6849691391, 0.1073344643, 0.7094633909, 1.215429148, 1.661604674, 2.070811618, 2.485363988],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1],
            "L2_err": 7.7654e-03,
            "max_err": 3.6180e-02,
        },
        8: {
            "grid_log": [-1.506047477, -0.4942547958, 0.2164611603, 0.795383143, 1.295696942, 1.742731987, 2.152506649, 2.544525327],
            "weight_log": [-1.015164339, -0.222861029, 0.3792787846, 0.8851596384, 1.330552251, 1.734353185, 2.112741798, 2.505560311],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 3.7187e-03,
            "max_err": 1.7410e-02,
        },
        9: {
            "grid_log": [-1.817476907, -0.8058453438, -0.09524468353, 0.4835591911, 0.9837357649, 1.430371138, 1.837972682, 2.217505865, 2.585774141],
            "weight_log": [-1.326652115, -0.5345198152, 0.06749447885, 0.5732375828, 1.018422806, 1.42116593, 1.793178746, 2.147523374, 2.522877843],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 1.8242e-03,
            "max_err": 8.7229e-03,
        },
        10: {
            "grid_log": [-2.0863587, -1.085546628, -0.3807535414, 0.1948488146, 0.6930013659, 1.138149174, 1.544215434, 1.920424403, 2.274986573, 2.623072651],
            "weight_log": [-1.599511793, -0.8185424306, -0.220873466, 0.282512726, 0.726139063, 1.12752985, 1.497288947, 1.843598094, 2.177934081, 2.538430183],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 8.8510e-04,
            "max_err": 4.4350e-03,
        },
        11: {
            "grid_log": [-2.266497961, -1.304081684, -0.6218666627, -0.05950108983, 0.4300172894, 0.8691638014, 1.270835572, 1.643401567, 1.993046424, 2.326184335, 2.656770414],
            "weight_log": [-1.793318525, -1.053693385, -0.4737152104, 0.01951879354, 0.456528502, 0.8533209381, 1.219602572, 1.562114181, 1.886886517, 2.204345248, 2.552214884],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 4.1946e-04,
            "max_err": 2.3107e-03,
        },
        12: {
            "grid_log": [-2.38765623, -1.471387237, -0.8201599032, -0.2762300626, 0.2014536886, 0.6324052316, 1.027798931, 1.395047971, 1.739543861, 2.065708767, 2.378940364, 2.692503758],
            "weight_log": [-1.929747752, -1.244030054, -0.6889614172, -0.2094449835, 0.2187871893, 0.6092158723, 0.9701264583, 1.307607425, 1.626294927, 1.931093597, 2.232116086, 2.567182798],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 1.9643e-04,
            "max_err": 1.0852e-03,
        },
        13: {
            "grid_log": [-2.493629016, -1.626599794, -1.01656112, -0.4999377613, -0.04023084668, 0.3791040217, 0.7675818486, 1.131158734, 1.473976829, 1.799303583, 2.110150823, 2.410953729, 2.714206602],
            "weight_log": [-2.050282773, -1.429165117, -0.9108592497, -0.4524124551, -0.0370195445, 0.3462890272, 0.7040970985, 1.04065328, 1.359388845, 1.663337598, 1.956388168, 2.248077264, 2.575922097],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 9.5716e-05,
            "max_err": 6.7283e-04,
        },
        14: {
            "grid_log": [-2.593915195, -1.771886747, -1.209073638, -0.7256768011, -0.2876251695, 0.1173220605, 0.4966043669, 0.8548867037, 1.19544611, 1.520948167, 1.833387556, 2.134588467, 2.428077144, 2.725608717],
            "weight_log": [-2.162251051, -1.608344868, -1.136455694, -0.7026116795, -0.3021781663, 0.07187609176, 0.4248643716, 0.7598421424, 1.079633493, 1.386534111, 1.681980902, 1.968763688, 2.255788861, 2.5801373],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 5.6856e-05,
            "max_err": 5.2501e-04,
        },
        15: {
            "grid_log": [-57.41062746, -2.20154656, -1.557798543, -1.056811892, -0.6105002993, -0.1991704204, 0.186512168, 0.5518238571, 0.8992731209, 1.230733154, 1.548291627, 1.854047165, 2.149950529, 2.439257018, 2.733345379],
            "weight_log": [-2.813845748, -1.92980446, -1.459850028, -1.023951616, -0.6179731931, -0.2378348335, 0.1226730214, 0.4655762857, 0.7918528727, 1.103688236, 1.403726151, 1.69408999, 1.97722293, 2.261353386, 2.583363235],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 4.5007e-05,
            "max_err": 4.3403e-04,
        },
    },
    (0.01, 1000): {
        5: {
            "grid_log": [-0.4437396892, 0.5680645618, 1.278878121, 1.859061236, 2.371080885],
            "weight_log": [0.04714524592, 0.8394696318, 1.441892415, 1.951472969, 2.431731971],
            "weight_sgn": [1, 1, 1, 1, 1],
            "L2_err": 3.7870e-02,
            "max_err": 1.8172e-01,
        },
        6: {
            "grid_log": [-0.8234842641, 0.1883132444, 0.8990409922, 1.478148914, 1.980196005, 2.440109485],
            "weight_log": [-0.3326011439, 0.4597108422, 1.061881987, 1.568329642, 2.018791598, 2.461303416],
            "weight_sgn": [1, 1, 1, 1, 1, 1],
            "L2_err": 1.6416e-02,
            "max_err": 7.8780e-02,
        },
        7: {
            "grid_log": [-1.175840122, -0.1640536851, 0.5466578517, 1.125600268, 1.626181922, 2.075067279, 2.496706979],
            "weight_log": [-0.6849614495, 0.1073397059, 0.7094714211, 1.215437415, 1.66161109, 2.070816456, 2.485367154],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1],
            "L2_err": 7.5367e-03,
            "max_err": 3.6176e-02,
        },
        8: {
            "grid_log": [-1.506133988, -0.4943398024, 0.2163728483, 0.7953010621, 1.295630532, 1.742685958, 2.152477824, 2.544506287],
            "weight_log": [-1.015248532, -0.2229482305, 0.3791907897, 0.885087016, 1.330503384, 1.734327832, 2.112727518, 2.505549272],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 3.6231e-03,
            "max_err": 1.7419e-02,
        },
        9: {
            "grid_log": [-1.818004323, -0.8062268408, -0.0955255891, 0.4833854541, 0.9836647017, 1.430369235, 1.83799737, 2.217528455, 2.585786482],
            "weight_log": [-1.327125602, -0.5348412937, 0.06728284099, 0.573153581, 1.018434447, 1.421212244, 1.793212964, 2.147535374, 2.522878789],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 1.8047e-03,
            "max_err": 8.7225e-03,
        },
        10: {
            "grid_log": [-2.11411018, -1.102579608, -0.3920131877, 0.1868291275, 0.6870889964, 1.133732929, 1.540915064, 1.91798808, 2.273249609, 2.621924138],
            "weight_log": [-1.62332354, -0.8312903022, -0.22927415, 0.2765673593, 0.7218440637, 1.124422632, 1.495071085, 1.84207963, 2.176993487, 2.537945828],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 9.1700e-04,
            "max_err": 4.5310e-03,
        },
        11: {
            "grid_log": [-2.3787988, -1.377030567, -0.6716733519, -0.0956119946, 0.4030294367, 0.8487925117, 1.255561588, 1.632154477, 1.984973397, 2.320593769, 2.653137334],
            "weight_log": [-1.891594333, -1.109643082, -0.5114598719, -0.007577755079, 0.4366704066, 0.838872206, 1.209379024, 1.555153761, 1.882376335, 2.201644744, 2.550819688],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 4.6202e-04,
            "max_err": 2.4774e-03,
        },
        12: {
            "grid_log": [-2.662664512, -1.734877957, -1.071643075, -0.508785743, -6.243520104e-06, 0.4677287425, 0.8970347951, 1.292622778, 1.660469725, 2.006140882, 2.335845379, 2.663411567],
            "weight_log": [-2.201273033, -1.500744606, -0.9303103244, -0.4209770042, 0.05035928168, 0.481219963, 0.8735755648, 1.235470637, 1.57396885, 1.89525599, 2.209742239, 2.555184559],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 2.8777e-04,
            "max_err": 2.0033e-03,
        },
        13: {
            "grid_log": [-2.681632633, -1.76269291, -1.109440974, -0.5643145149, -0.08556385758, 0.3467549978, 0.7441148212, 1.113873085, 1.461175179, 1.789828643, 2.103152948, 2.405856669, 2.710692579],
            "weight_log": [-2.222917287, -1.533817247, -0.977146932, -0.4966317603, -0.06712928337, 0.3252991922, 0.6890260028, 1.029735932, 1.351563138, 1.657753052, 1.952431739, 2.245452568, 2.574463743],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 1.1259e-04,
            "max_err": 7.2679e-04,
        },
        14: {
            "grid_log": [-2.783861803, -1.914060489, -1.302291021, -0.7857582077, -0.3278374039, 0.08929947987, 0.4766547801, 0.8407484501, 1.185603985, 1.514192956, 1.828810705, 2.131583555, 2.42615177, 2.724412982],
            "weight_log": [-2.339607136, -1.715050475, -1.195897581, -0.7391851012, -0.3268907624, 0.05448672996, 0.4128086104, 0.7518599199, 1.074536568, 1.383266344, 1.680009566, 1.96767575, 2.255159011, 2.579808388],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 6.2211e-05,
            "max_err": 5.3562e-04,
        },
        15: {
            "grid_log": [-2.918077918, -2.1024273, -1.544622474, -1.062922135, -0.6244848626, -0.2167454029, 0.168043853, 0.534292865, 0.8841557988, 1.219047943, 1.540324132, 1.849257463, 2.147317089, 2.43783095, 2.73253957],
            "weight_log": [-2.48817292, -1.943517447, -1.474607777, -1.040450343, -0.637481159, -0.2576363227, 0.1043217134, 0.450148818, 0.78059022, 1.096953873, 1.400713789, 1.693183001, 1.977012077, 2.26120394, 2.583191982],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 4.5961e-05,
            "max_err": 4.4222e-04,
        },
    },
    (0.02, 100): {
        5: {
            "grid_log": [-0.7447622556, 0.2670407767, 0.9778526388, 1.558034148, 2.070052178],
            "weight_log": [-0.2538774878, 0.5384449109, 1.140865865, 1.650444495, 2.130702073],
            "weight_sgn": [1, 1, 1, 1, 1],
            "L2_err": 2.1881e-02,
            "max_err": 9.0862e-02,
        },
        6: {
            "grid_log": [-1.123951222, -0.112381561, 0.5982181397, 1.177251859, 1.67925086, 2.139129285],
            "weight_log": [-0.6331504364, 0.158923674, 0.7609941805, 1.267385461, 1.717809514, 2.1602943],
            "weight_sgn": [1, 1, 1, 1, 1, 1],
            "L2_err": 9.2325e-03,
            "max_err": 3.9365e-02,
        },
        7: {
            "grid_log": [-1.433878609, -0.4393865929, 0.2618282887, 0.8353638138, 1.332414201, 1.778793156, 2.198569735],
            "weight_log": [-0.9493240892, -0.1749993683, 0.4198837205, 0.9217221796, 1.36513371, 1.772374877, 2.185558286],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1],
            "L2_err": 3.8248e-03,
            "max_err": 1.7343e-02,
        },
        8: {
            "grid_log": [-1.621583878, -0.6778636594, -0.007597639933, 0.5476086066, 1.032259366, 1.468044622, 1.869314062, 2.254714628],
            "weight_log": [-1.154771605, -0.4363296903, 0.1341244142, 0.6218075722, 1.05488055, 1.449672782, 1.821303883, 2.20924425],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 1.5258e-03,
            "max_err": 7.2587e-03,
        },
        9: {
            "grid_log": [-1.744258926, -0.8537820265, -0.2232363459, 0.30641411, 0.7734293433, 1.195883007, 1.584921616, 1.949781678, 2.306264535],
            "weight_log": [-1.29421201, -0.6413215112, -0.1047525705, 0.3627005317, 0.7815565956, 1.164693855, 1.521537375, 1.864148695, 2.230843858],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 5.9986e-04,
            "max_err": 2.9677e-03,
        },
        10: {
            "grid_log": [-1.836978202, -0.9901072126, -0.4007504635, 0.09951105615, 0.5457013454, 0.9527826577, 1.329684414, 1.682811369, 2.018731408, 2.351552346],
            "weight_log": [-1.39902184, -0.8071041497, -0.3097538988, 0.13374312, 0.5361163685, 0.9069422839, 1.253052912, 1.580601246, 1.900164867, 2.249568039],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 2.3405e-04,
            "max_err": 1.2516e-03,
        },
        11: {
            "grid_log": [-1.910600436, -1.095211107, -0.5424644045, -0.07148581259, 0.3526082964, 0.7425247392, 1.105617372, 1.447013477, 1.770887221, 2.082410123, 2.39460847],
            "weight_log": [-1.480481019, -0.9376778012, -0.4795287625, -0.06127939026, 0.3228265926, 0.6793632704, 1.013772417, 1.330301016, 1.633590191, 1.933481659, 2.267581993],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 9.0791e-05,
            "max_err": 5.0309e-04,
        },
        12: {
            "grid_log": [-1.983076219, -1.19330787, -0.6764234183, -0.2378000724, 0.1614408054, 0.5333649889, 0.8835438743, 1.215320219, 1.531334066, 1.834294544, 2.128428831, 2.425988619],
            "weight_log": [-1.558843746, -1.059816406, -0.6453684015, -0.2565545794, 0.108372834, 0.4523426806, 0.7778907962, 1.087371908, 1.383494863, 1.670086232, 1.956541437, 2.280281633],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 3.6673e-05,
            "max_err": 2.4727e-04,
        },
        13: {
            "grid_log": [-3.555990096, -1.586713952, -0.9803786289, -0.52309188, -0.1184853759, 0.2557262243, 0.6070188218, 0.9392037741, 1.255572651, 1.559265311, 1.853050266, 2.140683147, 2.433638774],
            "weight_log": [-2.170636268, -1.333305447, -0.9173435561, -0.5327784059, -0.1679217509, 0.1766204925, 0.5022373499, 0.8115938847, 1.108513768, 1.396108035, 1.67741844, 1.960680759, 2.282373466],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 2.5249e-05,
            "max_err": 2.1416e-04,
        },
        14: {
            "grid_log": [-1.980366742, -1.190794657, -0.676357681, -0.2441892657, 0.1434586231, 0.4975276879, 0.8225130195, 1.118280715, 1.374304849, 1.576630685, 1.781298532, 2.016809791, 2.2628128, 2.520868356],
            "weight_log": [-1.556078449, -1.057982839, -0.6492891426, -0.2722516255, 0.07360993765, 0.3900701078, 0.6773946155, 0.926241425, 1.095084452, 1.215433641, 1.493859516, 1.76397927, 2.02215416, 2.319775343],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 1.6933e-05,
            "max_err": 9.8871e-05,
        },
        15: {
            "grid_log": [-2.248271647, -1.454772816, -0.9554875677, -0.544777466, -0.173328108, 0.1712016704, 0.4921918565, 0.7915929129, 1.067973775, 1.299692168, 1.476062154, 1.697563029, 1.951314245, 2.211622551, 2.482652198],
            "weight_log": [-1.820199843, -1.326425304, -0.9488567325, -0.5943784279, -0.2582720312, 0.05505126861, 0.3456997043, 0.6144546656, 0.8469206145, 0.9489238795, 1.112414409, 1.45011366, 1.725504198, 1.993684746, 2.301469983],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 6.8934e-06,
            "max_err": 6.4255e-05,
        },
    },
    (0.02, 200): {
        5: {
            "grid_log": [-0.7447668858, 0.2670400405, 0.9778534212, 1.558036571, 2.070054264],
            "weight_log": [-0.2538801611, 0.5384450065, 1.14086804, 1.650447469, 2.130703267],
            "weight_sgn": [1, 1, 1, 1, 1],
            "L2_err": 2.1138e-02,
            "max_err": 9.0860e-02,
        },
        6: {
            "grid_log": [-1.124503498, -0.112714167, 0.5980077448, 1.177114673, 1.679163948, 2.139080185],
            "weight_log": [-0.6336226329, 0.1586791414, 0.7608465012, 1.267296153, 1.717762293, 2.160276067],
            "weight_sgn": [1, 1, 1, 1, 1, 1],
            "L2_err": 9.1031e-03,
            "max_err": 3.9387e-02,
        },
        7: {
            "grid_log": [-1.475408386, -0.4642392953, 0.2461343831, 0.8249010241, 1.325373699, 1.774178807, 2.195760343],
            "weight_log": [-0.9847515669, -0.1930966152, 0.4087859944, 0.914631155, 1.360717799, 1.76985867, 2.184370249],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1],
            "L2_err": 4.0526e-03,
            "max_err": 1.8066e-02,
        },
        8: {
            "grid_log": [-1.754358631, -0.7632037889, -0.06387249049, 0.5085686662, 1.004658279, 1.448676554, 1.856185171, 2.246453448],
            "weight_log": [-1.27101175, -0.5002085025, 0.09322851791, 0.5941800973, 1.036259536, 1.437647922, 1.81424804, 2.205781455],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 1.7622e-03,
            "max_err": 8.2983e-03,
        },
        9: {
            "grid_log": [-1.925440121, -0.9828953951, -0.3134132719, 0.2412724837, 0.7255153696, 1.160664281, 1.55950769, 1.932134526, 2.294850662],
            "weight_log": [-1.459024658, -0.7419308607, -0.1721409642, 0.3151031982, 0.7477329013, 1.141198598, 1.506059437, 1.854854187, 2.226095867],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 7.4377e-04,
            "max_err": 3.6466e-03,
        },
        10: {
            "grid_log": [-2.040568389, -1.147950667, -0.5156698028, 0.0150408596, 0.4827070784, 0.9055321097, 1.294403843, 1.65694958, 2.000463827, 2.339499227],
            "weight_log": [-1.589884429, -0.9342030416, -0.3961518284, 0.07204013308, 0.491338293, 0.8745538421, 1.230163615, 1.565263956, 1.890787446, 2.244656736],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 3.1027e-04,
            "max_err": 1.5849e-03,
        },
        11: {
            "grid_log": [-2.129889795, -1.27912111, -0.6854440077, -0.1818122603, 0.266743785, 0.6754966919, 1.053539325, 1.407041964, 1.74085815, 2.060630906, 2.379790417],
            "weight_log": [-1.690931524, -1.093165805, -0.5912771719, -0.1449813514, 0.2591709583, 0.6312072914, 0.9779320021, 1.304402483, 1.615773768, 1.922189035, 2.261391655],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 1.2861e-04,
            "max_err": 6.9567e-04,
        },
        12: {
            "grid_log": [-2.241460498, -1.436790287, -0.8959968486, -0.4294457312, -1.348919235e-05, 0.4016857567, 0.7782664019, 1.132337573, 1.46706746, 1.785756035, 2.093092664, 2.40182449],
            "weight_log": [-1.81392354, -1.288514436, -0.8410363104, -0.4186313828, -0.01981940336, 0.3536154454, 0.7025556047, 1.030941857, 1.342670614, 1.642063724, 1.938796329, 2.270480887],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 6.2064e-05,
            "max_err": 4.2911e-04,
        },
        13: {
            "grid_log": [-2.30247448, -1.517406499, -1.00602437, -0.5695028068, -0.1678818573, 0.2098379366, 0.5681133067, 0.9091551832, 1.234485347, 1.545700126, 1.844890738, 2.135972161, 2.43100947],
            "weight_log": [-1.879370442, -1.388272831, -0.9789957963, -0.5879998651, -0.2161281371, 0.1373184541, 0.4736352893, 0.7937357941, 1.099104922, 1.391900128, 1.675727033, 1.959967639, 2.282034575],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 2.7781e-05,
            "max_err": 2.2343e-04,
        },
        14: {
            "grid_log": [-8.39713439, -1.909420325, -1.302946749, -0.8448198623, -0.434297553, -0.05189231692, 0.3070238693, 0.6448169756, 0.9651516366, 1.2726884, 1.570796543, 1.86119098, 2.146512533, 2.437652346],
            "weight_log": [-2.505235255, -1.653022945, -1.24103009, -0.8508623614, -0.4752575511, -0.1211798472, 0.2107071719, 0.5232994812, 0.8231888012, 1.115546587, 1.401381247, 1.681480541, 1.963466791, 2.284002842],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 2.0219e-05,
            "max_err": 1.9403e-04,
        },
    },
    (0.02, 500): {
        5: {
            "grid_log": [-0.7447664122, 0.267040803, 0.9778553341, 1.558036491, 2.070052966],
            "weight_log": [-0.2538804468, 0.5384470536, 1.140869309, 1.650445698, 2.130701686],
            "weight_sgn": [1, 1, 1, 1, 1],
            "L2_err": 2.0185e-02,
            "max_err": 9.0863e-02,
        },
        6: {
            "grid_log": [-1.124524022, -0.1127202272, 0.5980078041, 1.177113167, 1.679160565, 2.139074553],
            "weight_log": [-0.63363897, 0.1586797738, 0.7608466824, 1.267293211, 1.71775675, 2.160269087],
            "weight_sgn": [1, 1, 1, 1, 1, 1],
            "L2_err": 8.7410e-03,
            "max_err": 3.9395e-02,
        },
        7: {
            "grid_log": [-1.476984544, -0.4651753907, 0.2455492726, 0.8245159152, 1.325117168, 1.774017237, 2.195668111],
            "weight_log": [-0.9860943854, -0.1937768028, 0.4083772715, 0.9143720662, 1.36056149, 1.769779704, 2.184337459],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1],
            "L2_err": 3.9949e-03,
            "max_err": 1.8088e-02,
        },
        8: {
            "grid_log": [-1.806659459, -0.7950492481, -0.08442192289, 0.4944338186, 0.9947166051, 1.441743296, 1.851512983, 2.243520808],
            "weight_log": [-1.315850949, -0.5237172124, 0.07834213856, 0.5841703475, 1.029557055, 1.433360508, 1.811739663, 2.204541466],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 1.8832e-03,
            "max_err": 8.7028e-03,
        },
        9: {
            "grid_log": [-2.089554761, -1.089080191, -0.3845787678, 0.1907989686, 0.688770672, 1.133820041, 1.540187642, 1.91872681, 2.28618812],
            "weight_log": [-1.602810823, -0.8222526441, -0.2248743234, 0.2782982882, 0.7217636911, 1.123200379, 1.494169652, 1.847693661, 2.222445371],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 8.7801e-04,
            "max_err": 4.2543e-03,
        },
        10: {
            "grid_log": [-2.274013681, -1.313828651, -0.632909192, -0.07129389339, 0.4175929968, 0.8560800944, 1.257124427, 1.629440437, 1.980934537, 2.326569932],
            "weight_log": [-1.801622758, -1.064434423, -0.4854199494, 0.007190470946, 0.4434982156, 0.8395476763, 1.205274677, 1.548487519, 1.880458545, 2.239224321],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 3.9797e-04,
            "max_err": 2.0387e-03,
        },
        11: {
            "grid_log": [-2.397700364, -1.486031835, -0.8383648786, -0.2971469911, 0.1780923753, 0.6067377944, 1.000205728, 1.366054137, 1.710034016, 2.038332785, 2.364729189],
            "weight_log": [-1.941245708, -1.261194907, -0.7094003679, -0.2325165085, 0.1931086251, 0.5812711283, 0.9406085165, 1.277296298, 1.597153236, 1.910519529, 2.255112457],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 1.7821e-04,
            "max_err": 9.5517e-04,
        },
        12: {
            "grid_log": [-2.493269387, -1.626672987, -1.017424638, -0.5022145818, -0.04454655096, 0.3719626129, 0.7569464187, 1.116680778, 1.455593629, 1.777447762, 2.087244237, 2.397948462],
            "weight_log": [-2.050032852, -1.429580759, -0.9125940573, -0.4562136758, -0.04374701849, 0.3356782082, 0.6891279705, 1.021327196, 1.335985483, 1.637592433, 1.936054861, 2.26902931],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 7.9842e-05,
            "max_err": 4.6544e-04,
        },
        13: {
            "grid_log": [-2.560523616, -1.724689625, -1.146970791, -0.6548432224, -0.2147457292, 0.1862940576, 0.5562090699, 0.9010755684, 1.226431597, 1.537046158, 1.836537085, 2.128888205, 2.425733271],
            "weight_log": [-2.125398395, -1.550108043, -1.06408792, -0.626850969, -0.2303316252, 0.1332702037, 0.4704069214, 0.7873350856, 1.090102199, 1.382882587, 1.668621883, 1.955291782, 2.279515551],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 3.5940e-05,
            "max_err": 2.5334e-04,
        },
        14: {
            "grid_log": [-2.605500217, -1.7889157, -1.233959458, -0.7608244257, -0.336368031, 0.05016799628, 0.4032887989, 0.7259091229, 1.024774151, 1.311935633, 1.595786469, 1.877421285, 2.157286119, 2.444693],
            "weight_log": [-2.175120959, -1.630172185, -1.168885392, -0.7491221305, -0.3673400117, -0.02016636297, 0.2934814493, 0.5781214744, 0.8507812383, 1.128778207, 1.409601586, 1.687336377, 1.967491024, 2.286403323],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 1.9252e-05,
            "max_err": 1.6535e-04,
        },
        15: {
            "grid_log": [-2.7143128, -1.928348781, -1.40921654, -0.9590089043, -0.5450180876, -0.160084157, 0.1985491851, 0.5308812472, 0.833625517, 1.104770108, 1.360673886, 1.62384672, 1.894810474, 2.168589307, 2.45188991],
            "weight_log": [-2.291353065, -1.796677278, -1.371159584, -0.9630957953, -0.5820829638, -0.2278524458, 0.09950873668, 0.3960115917, 0.6527930196, 0.8799071835, 1.133846369, 1.414618512, 1.692610408, 1.971216634, 2.288486554],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 1.3291e-05,
            "max_err": 1.4204e-04,
        },
    },
    (0.02, 1000): {
        5: {
            "grid_log": [-0.7447649687, 0.2670399152, 0.9778524849, 1.558035263, 2.070052717],
            "weight_log": [-0.253878977, 0.5384441136, 1.140866834, 1.650445835, 2.130701765],
            "weight_sgn": [1, 1, 1, 1, 1],
            "L2_err": 1.9531e-02,
            "max_err": 9.0864e-02,
        },
        6: {
            "grid_log": [-1.124508815, -0.1127057139, 0.5980206125, 1.177124555, 1.679168361, 2.139081777],
            "weight_log": [-0.6336226696, 0.1586923708, 0.7608596141, 1.267301584, 1.71776265, 2.16027624],
            "weight_sgn": [1, 1, 1, 1, 1, 1],
            "L2_err": 8.4646e-03,
            "max_err": 3.9386e-02,
        },
        7: {
            "grid_log": [-1.476929878, -0.4650944223, 0.2456271978, 0.8245704169, 1.325151248, 1.774038221, 2.195680536],
            "weight_log": [-0.9860296963, -0.1936882901, 0.408442806, 0.9144069076, 1.360580482, 1.769790217, 2.184342564],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1],
            "L2_err": 3.8827e-03,
            "max_err": 1.8085e-02,
        },
        8: {
            "grid_log": [-1.807305789, -0.7955073451, -0.08480133786, 0.4941430852, 0.9944992044, 1.441583156, 1.851400163, 2.243447234],
            "weight_log": [-1.316413425, -0.5241218615, 0.07802009724, 0.5839484152, 1.029398246, 1.43325332, 1.811673885, 2.20450743],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 1.8594e-03,
            "max_err": 8.7138e-03,
        },
        9: {
            "grid_log": [-2.118369, -1.106771935, -0.3961883303, 0.1826453845, 0.6828630757, 1.129514459, 1.537101949, 1.916597925, 2.284821183],
            "weight_log": [-1.627552451, -0.8354654362, -0.2334438521, 0.2723598169, 0.7175790495, 1.120309736, 1.49227745, 1.846563716, 2.221874223],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 9.1210e-04,
            "max_err": 4.3572e-03,
        },
        10: {
            "grid_log": [-2.387075697, -1.386163877, -0.6814202295, -0.1059216135, 0.3920994009, 0.8371356259, 1.243138776, 1.619329875, 1.973899636, 2.322001713],
            "weight_log": [-1.900172533, -1.119157357, -0.5215969925, -0.01835913119, 0.4251172325, 0.8264249698, 1.196170082, 1.54250172, 1.876864377, 2.23738012],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 4.4255e-04,
            "max_err": 2.2194e-03,
        },
        11: {
            "grid_log": [-2.568023605, -1.605640122, -0.9232744982, -0.3606349484, 0.129059566, 0.5682525297, 0.9699583868, 1.342554102, 1.692227456, 2.025397019, 2.355986886],
            "weight_log": [-2.094873217, -1.355223596, -0.7749507766, -0.2813989507, 0.155654818, 0.5524407463, 0.9187649142, 1.261297647, 1.586109627, 1.903589795, 2.25140411],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 2.0972e-04,
            "max_err": 1.1442e-03,
        },
        12: {
            "grid_log": [-2.692408761, -1.777973615, -1.128319495, -0.5856554475, -0.1088816491, 0.3215652238, 0.7171561141, 1.085359787, 1.431324049, 1.759141719, 2.073962406, 2.388911134],
            "weight_log": [-2.235077666, -1.55164409, -0.9981884328, -0.5197914403, -0.09226909711, 0.29816974, 0.6601655081, 0.9994664395, 1.32016133, 1.626774829, 1.929237127, 2.265286848],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 9.8663e-05,
            "max_err": 5.6858e-04,
        },
        13: {
            "grid_log": [-2.779884366, -1.906013661, -1.29007621, -0.770374138, -0.3101766347, 0.1074130451, 0.4926237384, 0.852138538, 1.190785696, 1.512240242, 1.819688157, 2.117611519, 2.418442052],
            "weight_log": [-2.334622656, -1.704079194, -1.180890781, -0.7212751417, -0.3076922941, 0.07175539221, 0.4247304101, 0.756413492, 1.070873656, 1.371249854, 1.66143051, 1.950891422, 2.277043839],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 4.6175e-05,
            "max_err": 2.9757e-04,
        },
        14: {
            "grid_log": [-2.841505887, -1.997163719, -1.411467473, -0.9139096089, -0.4694520452, -0.06449887577, 0.3086215113, 0.6554074659, 0.9806276935, 1.28904928, 1.585295367, 1.872869077, 2.155393166, 2.44397079],
            "weight_log": [-2.404080115, -1.816445873, -1.323242924, -0.8815579903, -0.480767458, -0.1134399519, 0.2260831729, 0.5429650524, 0.8425370195, 1.130749163, 1.412033912, 1.688763831, 1.968248479, 2.286771042],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 2.1971e-05,
            "max_err": 1.6535e-04,
        },
        15: {
            "grid_log": [-2.911233994, -2.095521775, -1.540615842, -1.064313234, -0.632751796, -0.2353227171, 0.1325554229, 0.4717942092, 0.7808314287, 1.062209652, 1.332432049, 1.606524764, 1.883941419, 2.16178571, 2.447832804],
            "weight_log": [-2.481100621, -1.937375839, -1.474475026, -1.047597817, -0.6541864086, -0.290792007, 0.04337888728, 0.3451657442, 0.611013788, 0.8591917303, 1.127881404, 1.410539872, 1.689624583, 1.969574655, 2.287753178],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 1.5149e-05,
            "max_err": 1.5222e-04,
        },
    },
    (0.05, 100): {
        5: {
            "grid_log": [-1.141932223, -0.130459346, 0.5801724941, 1.160250801, 1.672199435],
            "weight_log": [-0.6511671715, 0.1408122926, 0.7430964045, 1.252594124, 1.732799138],
            "weight_sgn": [1, 1, 1, 1, 1],
            "L2_err": 8.9646e-03,
            "max_err": 3.6310e-02,
        },
        6: {
            "grid_log": [-1.466491992, -0.4778011142, 0.2201318244, 0.7919495554, 1.289251688, 1.74583995],
            "weight_log": [-0.9840317734, -0.2158454581, 0.3765416226, 0.8774418944, 1.324246326, 1.76433642],
            "weight_sgn": [1, 1, 1, 1, 1, 1],
            "L2_err": 3.4038e-03,
            "max_err": 1.4820e-02,
        },
        7: {
            "grid_log": [-1.654426507, -0.7239165516, -0.06265741818, 0.4869617392, 0.9680456455, 1.402949777, 1.814150659],
            "weight_log": [-1.191969151, -0.4890181335, 0.07403306945, 0.5573694904, 0.9881955197, 1.386433215, 1.79334067],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1],
            "L2_err": 1.2365e-03,
            "max_err": 5.6743e-03,
        },
        8: {
            "grid_log": [-1.777110589, -0.902145502, -0.2852005281, 0.2350802213, 0.6955003358, 1.113388481, 1.500904786, 1.87550843],
            "weight_log": [-1.331531069, -0.6994976061, -0.1753726722, 0.2844878879, 0.6981450638, 1.078457975, 1.439148697, 1.819164585],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 4.4203e-04,
            "max_err": 2.1213e-03,
        },
        9: {
            "grid_log": [-1.867450187, -1.034191363, -0.4599603204, 0.02832623366, 0.4652146848, 0.8647796224, 1.235783963, 1.586114347, 1.930732029],
            "weight_log": [-1.432961859, -0.8617409673, -0.3801888871, 0.05279158267, 0.4470505729, 0.811359916, 1.1533382, 1.484263384, 1.842215759],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 1.5644e-04,
            "max_err": 7.8182e-04,
        },
        10: {
            "grid_log": [-1.941593747, -1.138342706, -0.6023788578, -0.1467451316, 0.2649292408, 0.6450278794, 1.000356732, 1.3359163, 1.657364987, 1.978107749],
            "weight_log": [-1.514273092, -0.9919488426, -0.5540807466, -0.1503175674, 0.2231756795, 0.5716741378, 0.8999594584, 1.213111317, 1.521162019, 1.861734039],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 5.5066e-05,
            "max_err": 3.0289e-04,
        },
        11: {
            "grid_log": [-2.010215229, -1.228590778, -0.7247173349, -0.2994194385, 0.0880950063, 0.4495771293, 0.7903073359, 1.113549588, 1.422165994, 1.720609714, 2.021494707],
            "weight_log": [-1.58777489, -1.103484824, -0.7067220181, -0.3314777363, 0.02241225832, 0.3564284053, 0.6730376106, 0.9746409115, 1.265094933, 1.554256591, 1.879944401],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 1.9540e-05,
            "max_err": 1.1447e-04,
        },
        12: {
            "grid_log": [-2.093141664, -1.329143922, -0.8535918345, -0.4555503366, -0.0880468061, 0.2595756204, 0.5902837219, 0.9056831609, 1.207032633, 1.496128279, 1.776894616, 2.061554689],
            "weight_log": [-1.674603249, -1.222867136, -0.8650675094, -0.5140439988, -0.1733700185, 0.1517849774, 0.4614838697, 0.7566677943, 1.03880988, 1.311591911, 1.585068399, 1.897565643],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 7.7551e-06,
            "max_err": 4.1886e-05,
        },
        13: {
            "grid_log": [-2.234199423, -1.470715587, -0.9994623299, -0.608213079, -0.2488213589, 0.08957074615, 0.4103665437, 0.7159257102, 1.008102141, 1.288314617, 1.558565099, 1.822991432, 2.093654746],
            "weight_log": [-1.815429079, -1.366240053, -1.017065285, -0.6753575981, -0.3448925169, -0.03078943424, 0.2678663522, 0.5532568854, 0.8266083561, 1.089529388, 1.346217087, 1.607052699, 1.910067588],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 3.4189e-06,
            "max_err": 1.8228e-05,
        },
        14: {
            "grid_log": [-2.266434962, -1.511446632, -1.054852454, -0.6822518538, -0.341252277, -0.01940211035, 0.2860978286, 0.5762795941, 0.8524489022, 1.116686181, 1.370878082, 1.617399114, 1.861701049, 2.116949107],
            "weight_log": [-1.849687041, -1.416220257, -1.090920446, -0.7722687957, -0.4596904062, -0.1610217209, 0.1220853133, 0.3900871882, 0.6457722193, 0.8920967166, 1.130665814, 1.367232252, 1.615441973, 1.912717797],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 1.3229e-06,
            "max_err": 1.2526e-05,
        },
        15: {
            "grid_log": [-2.27108508, -1.537756174, -1.104714854, -0.7483358442, -0.4219411367, -0.1156856892, 0.1745265909, 0.4487212681, 0.7071745221, 0.9558279807, 1.198523332, 1.431895176, 1.655856015, 1.881622907, 2.126190443],
            "weight_log": [-1.860074218, -1.463643796, -1.162637081, -0.856320048, -0.5609063126, -0.2794339252, -0.01228056085, 0.2354963305, 0.4712911681, 0.7085968761, 0.9387999208, 1.151502152, 1.365029496, 1.608943154, 1.909920903],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 9.6298e-07,
            "max_err": 1.1149e-05,
        },
    },
    (0.05, 200): {
        5: {
            "grid_log": [-1.142683574, -0.1308789046, 0.579930102, 1.160106177, 1.672119432],
            "weight_log": [-0.6517979025, 0.140525011, 0.7429399799, 1.252512188, 1.732765668],
            "weight_sgn": [1, 1, 1, 1, 1],
            "L2_err": 8.8388e-03,
            "max_err": 3.6342e-02,
        },
        6: {
            "grid_log": [-1.519485853, -0.5089280714, 0.2011252917, 0.7798479865, 1.2816453, 1.741385217],
            "weight_log": [-1.029055427, -0.2380254453, 0.363629705, 0.8697821352, 1.320053112, 1.762438983],
            "weight_sgn": [1, 1, 1, 1, 1, 1],
            "L2_err": 3.6731e-03,
            "max_err": 1.5705e-02,
        },
        7: {
            "grid_log": [-1.799439904, -0.8176066447, -0.1235953424, 0.4457914323, 0.940114418, 1.384546859, 1.802872162],
            "weight_log": [-1.319450759, -0.55855341, 0.03078583742, 0.5294516996, 0.97072287, 1.376433956, 1.788554398],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1],
            "L2_err": 1.4735e-03,
            "max_err": 6.7160e-03,
        },
        8: {
            "grid_log": [-1.965276248, -1.03881478, -0.3804353212, 0.1673416706, 0.6469176406, 1.078979729, 1.477403032, 1.860581795],
            "weight_log": [-1.504129491, -0.8060232142, -0.2453905741, 0.2364384358, 0.6654478323, 1.057223768, 1.426591876, 1.8129027],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 5.7260e-04,
            "max_err": 2.7267e-03,
        },
        9: {
            "grid_log": [-2.078250267, -1.20337902, -0.5865022577, -0.06622875546, 0.3941942573, 0.8117592798, 1.1970234, 1.558882242, 1.912916226],
            "weight_log": [-1.632697945, -1.000788509, -0.4767056629, -0.01680304647, 0.396779505, 0.7759680659, 1.129753105, 1.469955275, 1.834820901],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 2.1978e-04,
            "max_err": 1.0919e-03,
        },
        10: {
            "grid_log": [-2.164020687, -1.328792876, -0.7522865846, -0.2621381589, 0.1762262559, 0.5770085734, 0.9487236748, 1.297551393, 1.629901025, 1.959726328],
            "weight_log": [-1.729038077, -1.154772559, -0.6707714135, -0.2361258681, 0.1594573548, 0.5247929692, 0.8664318905, 1.190364904, 1.507037368, 1.85420788],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 8.3700e-05,
            "max_err": 4.4173e-04,
        },
        11: {
            "grid_log": [-2.238366241, -1.433403414, -0.8948043212, -0.4362420128, -0.02158524018, 0.3615226975, 0.7197868592, 1.057672453, 1.378880346, 1.68833656, 1.99896637],
            "weight_log": [-1.810660661, -1.28536738, -0.8439614462, -0.4368315606, -0.06005202967, 0.2917182344, 0.6228827525, 0.9369786607, 1.238358374, 1.536857728, 1.870122141],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 3.2001e-05,
            "max_err": 1.9504e-04,
        },
        12: {
            "grid_log": [-2.30964606, -1.527511646, -1.022112689, -0.5938509648, -0.202223341, 0.1641002638, 0.510027168, 0.8384266663, 1.151383135, 1.451159966, 1.7419324, 2.036138372],
            "weight_log": [-1.887114355, -1.401740406, -1.001965418, -0.6220367536, -0.2626527327, 0.07719829589, 0.3995892268, 0.7062696974, 0.9991985217, 1.28211429, 1.564981044, 1.885764103],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 1.2643e-05,
            "max_err": 8.2145e-05,
        },
        13: {
            "grid_log": [-2.445075246, -1.673804739, -1.184276491, -0.7692410837, -0.3864466908, -0.02712570433, 0.3117721768, 0.6326261077, 0.9377131517, 1.22951051, 1.510961586, 1.786371126, 2.067465639],
            "weight_log": [-2.025005143, -1.559155614, -1.179204788, -0.8090249346, -0.455543604, -0.1224699953, 0.1918403203, 0.4898087352, 0.7741433961, 1.048212764, 1.316400075, 1.587760834, 1.899135384],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 5.8468e-06,
            "max_err": 3.6816e-05,
        },
        14: {
            "grid_log": [-2.48127563, -1.722069531, -1.253737293, -0.8622242279, -0.5007591544, -0.1607412701, 0.1600714053, 0.4644220364, 0.7553691386, 1.03487999, 1.304501163, 1.566850477, 1.826295386, 2.094335557],
            "weight_log": [-2.063828747, -1.620939376, -1.272697872, -0.9275569894, -0.5941220615, -0.2799464563, 0.01650227225, 0.2996909696, 0.5724329615, 0.8352330788, 1.090678792, 1.343929822, 1.604293436, 1.90808939],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 2.3180e-06,
            "max_err": 1.9354e-05,
        },
        15: {
            "grid_log": [-2.573770882, -1.812638496, -1.348612931, -0.9699553419, -0.6232183547, -0.2951320572, 0.01524804095, 0.3079929241, 0.5860104887, 0.8537874912, 1.112751076, 1.36233218, 1.605744508, 1.850269273, 2.108284846],
            "weight_log": [-2.155382376, -1.71150393, -1.377237248, -1.053261759, -0.7336366314, -0.4286391154, -0.1434475374, 0.1244812846, 0.383231503, 0.636778256, 0.880141695, 1.114615247, 1.35281434, 1.6071853, 1.909511102],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 1.3246e-06,
            "max_err": 1.4744e-05,
        },
    },
    (0.05, 500): {
        5: {
            "grid_log": [-1.142714393, -0.130897453, 0.579920209, 1.160102455, 1.672116062],
            "weight_log": [-0.6518238379, 0.1405111568, 0.7429361282, 1.252510579, 1.732761837],
            "weight_sgn": [1, 1, 1, 1, 1],
            "L2_err": 8.4553e-03,
            "max_err": 3.6346e-02,
        },
        6: {
            "grid_log": [-1.522479238, -0.5106518398, 0.2000827656, 0.7791806329, 1.281223202, 1.741140053],
            "weight_log": [-1.031585702, -0.2392431284, 0.3629198861, 0.8693533447, 1.319819613, 1.762337792],
            "weight_sgn": [1, 1, 1, 1, 1, 1],
            "L2_err": 3.6413e-03,
            "max_err": 1.5753e-02,
        },
        7: {
            "grid_log": [-1.873459933, -0.8622334062, -0.1518566377, 0.4268937854, 0.9273546143, 1.376162901, 1.797761867],
            "weight_log": [-1.382778063, -0.59107678, 0.0107866834, 0.5166103237, 0.9626939563, 1.371854173, 1.786394645],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1],
            "L2_err": 1.6210e-03,
            "max_err": 7.2344e-03,
        },
        8: {
            "grid_log": [-2.152468174, -1.161281267, -0.461949995, 0.1104828051, 0.6065921392, 1.050657475, 1.458205948, 1.848493641],
            "weight_log": [-1.669108485, -0.8982773933, -0.3048564136, 0.1960950823, 0.6382289955, 1.0396788, 1.416300709, 1.807834925],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 7.0488e-04,
            "max_err": 3.3207e-03,
        },
        9: {
            "grid_log": [-2.323704275, -1.381256348, -0.7117574726, -0.1570631855, 0.3271580312, 0.7623022303, 1.161177012, 1.533870585, 1.896672358],
            "weight_log": [-1.857335943, -1.14030689, -0.5704632786, -0.08324489955, 0.3493577023, 0.7428489562, 1.107786431, 1.456684757, 1.82802393],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 2.9751e-04,
            "max_err": 1.4664e-03,
        },
        10: {
            "grid_log": [-2.438854707, -1.546372336, -0.9141664947, -0.3833893802, 0.08443221611, 0.5074349944, 0.8964599888, 1.259100908, 1.602644051, 1.941663058],
            "weight_log": [-1.988209358, -1.332705615, -0.7946506079, -0.3262815633, 0.09323566181, 0.476641623, 0.8323686163, 1.167489909, 1.492969556, 1.846783173],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 1.2411e-04,
            "max_err": 6.3235e-04,
        },
        11: {
            "grid_log": [-2.527378262, -1.676611631, -1.083384643, -0.5804939351, -0.1327793236, 0.2752039521, 0.6527654887, 1.006147709, 1.340152598, 1.660343454, 1.980066518],
            "weight_log": [-2.088378656, -1.490793884, -0.9897351304, -0.544409321, -0.1412120918, 0.2301901743, 0.5768034273, 0.9035672031, 1.215491628, 1.522593833, 1.862477745],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 5.1506e-05,
            "max_err": 2.9128e-04,
        },
        12: {
            "grid_log": [-2.601152574, -1.782483558, -1.225435646, -0.7505008458, -0.3229184732, 0.07038965373, 0.436928013, 0.7815323545, 1.107562475, 1.418061922, 1.717729536, 2.019453363],
            "weight_log": [-2.17024896, -1.622083935, -1.158853927, -0.7367205631, -0.3490969116, 0.01117961051, 0.3492762686, 0.6685712062, 0.9718088177, 1.263151135, 1.552819349, 1.878926938],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 2.1308e-05,
            "max_err": 1.2193e-04,
        },
        13: {
            "grid_log": [-2.666390767, -1.871902066, -1.348278569, -0.9026812072, -0.4967477002, -0.1184885251, 0.237881251, 0.5755539601, 0.8962624392, 1.201339979, 1.49280002, 1.774934664, 2.060393511],
            "weight_log": [-2.241056186, -1.733900334, -1.31075396, -0.9143098762, -0.5425457239, -0.1920642111, 0.1399796285, 0.4549291695, 0.7535356344, 1.037510292, 1.310999156, 1.584687272, 1.897332232],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 9.0892e-06,
            "max_err": 4.2712e-05,
        },
        14: {
            "grid_log": [-2.760787608, -1.986593755, -1.48874422, -1.063117371, -0.6721682662, -0.3065732186, 0.03885200845, 0.3677023656, 0.6821970386, 0.983495939, 1.272403402, 1.550210512, 1.820366586, 2.094607761],
            "weight_log": [-2.340298965, -1.867792557, -1.47336047, -1.092412829, -0.7333287805, -0.3943817726, -0.07174770059, 0.2369796457, 0.5326214652, 0.8154780406, 1.086473577, 1.348710448, 1.612033192, 1.914847577],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 4.3215e-06,
            "max_err": 1.4313e-05,
        },
        15: {
            "grid_log": [-2.9070582, -2.130747486, -1.63832656, -1.222736183, -0.8411183799, -0.4845049962, -0.1482306773, 0.1720127929, 0.4788044345, 0.7737144358, 1.05727222, 1.329552944, 1.591862467, 1.848458341, 2.11173146],
            "weight_log": [-2.485240714, -2.012753794, -1.631738505, -1.262875755, -0.9126190187, -0.583695642, -0.2705465463, 0.03003820863, 0.319103599, 0.5971772544, 0.8633183633, 1.118104257, 1.366349609, 1.6197752, 1.917020442],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 2.1021e-06,
            "max_err": 1.1591e-05,
        },
    },
    (0.05, 1000): {
        5: {
            "grid_log": [-1.142711119, -0.1309101923, 0.579907403, 1.160094102, 1.672114584],
            "weight_log": [-0.6518286115, 0.1404958215, 0.7429249002, 1.252507636, 1.732765308],
            "weight_sgn": [1, 1, 1, 1, 1],
            "L2_err": 8.1633e-03,
            "max_err": 3.6342e-02,
        },
        6: {
            "grid_log": [-1.522425859, -0.510634471, 0.2000733454, 0.7791796308, 1.28122325, 1.74113341],
            "weight_log": [-1.031537362, -0.2392505377, 0.3629089735, 0.8693596446, 1.319814447, 1.762326232],
            "weight_sgn": [1, 1, 1, 1, 1, 1],
            "L2_err": 3.5330e-03,
            "max_err": 1.5759e-02,
        },
        7: {
            "grid_log": [-1.874842919, -0.8630128112, -0.1522969683, 0.4266348641, 0.9272060091, 1.376082573, 1.797719796],
            "weight_log": [-1.383946112, -0.5916075478, 0.0105112031, 0.5164631143, 0.9626257539, 1.371825369, 1.786381466],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1],
            "L2_err": 1.6102e-03,
            "max_err": 7.2386e-03,
        },
        8: {
            "grid_log": [-2.202602988, -1.191794156, -0.4816119588, 0.09700838426, 0.5971369867, 1.044047849, 1.45372917, 1.845671669],
            "weight_log": [-1.71208012, -0.9207916203, -0.3190612703, 0.1865967146, 0.6318555522, 1.03556194, 1.413872323, 1.806631667],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 7.5023e-04,
            "max_err": 3.4769e-03,
        },
        9: {
            "grid_log": [-2.460131416, -1.470137113, -0.7714735269, -0.1994352892, 0.2963457416, 0.739845979, 1.145068126, 1.522724081, 1.889484612],
            "weight_log": [-1.977203191, -1.207631348, -0.6147173558, -0.1140988597, 0.3276503908, 0.7278740516, 1.09794054, 1.450768956, 1.825010454],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 3.4138e-04,
            "max_err": 1.6660e-03,
        },
        10: {
            "grid_log": [-2.622034343, -1.678509863, -1.008303249, -0.4531826001, 0.03133565924, 0.466712698, 0.865464557, 1.236034921, 1.586160735, 1.930706545],
            "weight_log": [-2.155303429, -1.43703753, -0.866619235, -0.3790798708, 0.0537937974, 0.4474225389, 0.8113750211, 1.153226181, 1.484152045, 1.842152973],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 1.5161e-04,
            "max_err": 7.8293e-04,
        },
        11: {
            "grid_log": [-2.734633933, -1.839600253, -1.205123715, -0.6726410005, -0.2033425922, 0.2211484148, 0.6116483768, 0.9752587179, 1.317422948, 1.644189077, 1.969302946],
            "weight_log": [-2.283251675, -1.624347486, -1.084139614, -0.6141867006, -0.1931129901, 0.1919806709, 0.5491300853, 0.8840532443, 1.202378591, 1.514488411, 1.858168472],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 6.6707e-05,
            "max_err": 3.6306e-04,
        },
        12: {
            "grid_log": [-2.823658099, -1.970840788, -1.375376253, -0.8705880034, -0.4210161836, -0.01093062679, 0.3690320408, 0.7250349383, 1.061395061, 1.38164949, 1.690469095, 2.000564516],
            "weight_log": [-2.384121844, -1.783496486, -1.280075253, -0.8328422205, -0.4274592138, -0.0534516154, 0.2960842757, 0.6257807108, 0.9391117795, 1.24008796, 1.538198443, 1.871002464],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 2.9561e-05,
            "max_err": 1.8632e-04,
        },
        13: {
            "grid_log": [-2.898841442, -2.078858822, -1.51999359, -1.042453674, -0.6108376108, -0.2118868817, 0.1614487106, 0.5130978643, 0.8454864879, 1.160723672, 1.461244492, 1.751405944, 2.043926069],
            "weight_log": [-2.467608834, -1.91734865, -1.45161702, -1.025552694, -0.6318776409, -0.263890602, 0.08239209465, 0.4089140585, 0.7175200791, 1.010643326, 1.292242206, 1.57264464, 1.890523977],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 1.3186e-05,
            "max_err": 6.4563e-05,
        },
        14: {
            "grid_log": [-2.966799198, -2.171428829, -1.644153391, -1.191893244, -0.7784714816, -0.393887077, -0.03244162603, 0.310117306, 0.6364957662, 0.9480264627, 1.245493137, 1.5301234, 1.805437695, 2.083661726],
            "weight_log": [-2.541383249, -2.032001093, -1.601720713, -1.195857435, -0.8164703382, -0.4609072116, -0.1244050047, 0.1962768423, 0.5022443527, 0.793639328, 1.071222168, 1.33798415, 1.604234165, 1.90943081],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 6.1995e-06,
            "max_err": 2.1006e-05,
        },
        15: {
            "grid_log": [-3.146389708, -2.36125752, -1.849022621, -1.408760705, -1.003355508, -0.624186112, -0.2665866848, 0.0732403625, 0.3982101272, 0.709982753, 1.009176132, 1.296074527, 1.571655722, 1.839336585, 2.110809327],
            "weight_log": [-2.72308218, -2.232291532, -1.819691002, -1.422708217, -1.048513953, -0.6964857219, -0.3626168389, -0.04339280261, 0.2631276095, 0.5571171342, 0.8382339019, 1.106921414, 1.366388515, 1.626796501, 1.926433277],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 3.7805e-06,
            "max_err": 8.4200e-06,
        },
    },
    (0.1, 100): {
        5: {
            "grid_log": [-1.411566699, -0.4137445085, 0.289431673, 0.8652948301, 1.374544851],
            "weight_log": [-0.9258016744, -0.1479773925, 0.4486358491, 0.9549643912, 1.433220833],
            "weight_sgn": [1, 1, 1, 1, 1],
            "L2_err": 4.2406e-03,
            "max_err": 1.7474e-02,
        },
        6: {
            "grid_log": [-1.634540005, -0.695892763, -0.02898711617, 0.5243225493, 1.009356969, 1.457267256],
            "weight_log": [-1.169422349, -0.4568516361, 0.110894456, 0.5975281592, 1.034848464, 1.468613096],
            "weight_sgn": [1, 1, 1, 1, 1, 1],
            "L2_err": 1.4292e-03,
            "max_err": 6.2813e-03,
        },
        7: {
            "grid_log": [-1.771643025, -0.8940951168, -0.2748212079, 0.2471232185, 0.7090331125, 1.129863945, 1.530389084],
            "weight_log": [-1.325332415, -0.689767729, -0.1634840784, 0.2978315728, 0.7133929093, 1.100522571, 1.499603168],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1],
            "L2_err": 4.7072e-04,
            "max_err": 2.1770e-03,
        },
        8: {
            "grid_log": [-1.869682196, -1.03741996, -0.4643253336, 0.02306455331, 0.4593416599, 0.8588561693, 1.231976572, 1.595091297],
            "weight_log": [-1.435443215, -0.8657655259, -0.3854134019, 0.0468082135, 0.4407324916, 0.806041386, 1.15521945, 1.52676627],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 1.5309e-04,
            "max_err": 7.4055e-04,
        },
        9: {
            "grid_log": [-1.946222613, -1.144531684, -0.6107311861, -0.157200929, 0.2524338182, 0.6304616451, 0.984072447, 1.320149781, 1.652952097],
            "weight_log": [-1.51926634, -0.9995848059, -0.5643870188, -0.1628364798, 0.2084041187, 0.5547010819, 0.8822245077, 1.201651226, 1.550871357],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 4.9347e-05,
            "max_err": 2.4767e-04,
        },
        10: {
            "grid_log": [-2.011012762, -1.229944447, -0.7276214789, -0.3051998191, 0.07816148573, 0.4345703093, 0.7698722693, 1.088375578, 1.395280995, 1.703580643],
            "weight_log": [-1.588665045, -1.10556524, -0.7117295399, -0.3411052631, 0.006994418697, 0.3347106473, 0.6455819611, 0.9441710035, 1.240334737, 1.571695608],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 1.5809e-05,
            "max_err": 8.4932e-05,
        },
        11: {
            "grid_log": [-2.074157808, -1.308689477, -0.8332892194, -0.4405074339, -0.08295331204, 0.2526594192, 0.5712172862, 0.8755562578, 1.168345138, 1.45374698, 1.743789404],
            "weight_log": [-1.655190629, -1.201348065, -0.8472158878, -0.5082579358, -0.182379759, 0.1288028808, 0.4262347777, 0.7120949552, 0.9901454998, 1.269710613, 1.588054899],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 5.1740e-06,
            "max_err": 3.3380e-05,
        },
        12: {
            "grid_log": [-2.149864996, -1.393584004, -0.9328004398, -0.5554281014, -0.2129137662, 0.1059883641, 0.4048992691, 0.6882533996, 0.9619654816, 1.230573406, 1.497568245, 1.773311794],
            "weight_log": [-1.73299996, -1.296206996, -0.9632113946, -0.6410749441, -0.3322697092, -0.04274175341, 0.2298042756, 0.4938427883, 0.7565224779, 1.019428892, 1.288294617, 1.598676386],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 2.0065e-06,
            "max_err": 1.6365e-05,
        },
        13: {
            "grid_log": [-2.185016512, -1.434166186, -0.9870335657, -0.6338795129, -0.320964529, -0.02702863268, 0.2563762448, 0.5280615114, 0.7848062178, 1.030909072, 1.276596385, 1.527815088, 1.792731393],
            "weight_log": [-1.769212499, -1.343859947, -1.038390355, -0.7556411463, -0.4800963185, -0.2050994318, 0.06304297595, 0.312502263, 0.544679128, 0.7811444078, 1.033058597, 1.297285546, 1.603940696],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 1.0273e-06,
            "max_err": 1.0824e-05,
        },
        14: {
            "grid_log": [-2.210691316, -1.463499454, -1.024008175, -0.6820630295, -0.383780835, -0.1125340661, 0.1412642358, 0.3886842949, 0.6287390589, 0.8561785065, 1.0810801, 1.315204301, 1.558423083, 1.815835793],
            "weight_log": [-1.795655787, -1.377708923, -1.086388737, -0.8207834768, -0.569224594, -0.3345263834, -0.09962404061, 0.1402085338, 0.3595334118, 0.5672122466, 0.8028059713, 1.055815102, 1.314854587, 1.615161033],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 5.5179e-07,
            "max_err": 5.0829e-06,
        },
        15: {
            "grid_log": [-2.233671986, -1.501485971, -1.087661504, -0.7670121087, -0.477263477, -0.2115764398, 0.02935181341, 0.2562773954, 0.4802006855, 0.7020475591, 0.9170737125, 1.124065913, 1.336589615, 1.565505812, 1.816114217],
            "weight_log": [-1.821925979, -1.433799359, -1.182038603, -0.9257421696, -0.6703788329, -0.4470364729, -0.244065325, -0.03030033477, 0.1914338484, 0.4056000221, 0.6015291626, 0.8017426231, 1.041547868, 1.304064432, 1.608710781],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 5.2111e-07,
            "max_err": 6.4257e-06,
        },
    },
    (0.1, 200): {
        5: {
            "grid_log": [-1.442948892, -0.4314920423, 0.279137846, 0.8592210951, 1.371175931],
            "weight_log": [-0.9521910139, -0.1602243167, 0.4420630657, 0.9515702338, 1.431778833],
            "weight_sgn": [1, 1, 1, 1, 1],
            "L2_err": 4.4823e-03,
            "max_err": 1.8151e-02,
        },
        6: {
            "grid_log": [-1.767548742, -0.7788204631, -0.08088023484, 0.4909188731, 0.9882164565, 1.444810804],
            "weight_log": [-1.285077604, -0.5168483891, 0.07551977724, 0.576399205, 1.023214589, 1.463312873],
            "weight_sgn": [1, 1, 1, 1, 1, 1],
            "L2_err": 1.7019e-03,
            "max_err": 7.4085e-03,
        },
        7: {
            "grid_log": [-1.955487338, -1.024896829, -0.3636004421, 0.1860099929, 0.6670862098, 1.101983083, 1.513161954],
            "weight_log": [-1.493006458, -0.7899575416, -0.2269056575, 0.2564063831, 0.6872330464, 1.085451118, 1.492324317],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1],
            "L2_err": 6.1827e-04,
            "max_err": 2.8365e-03,
        },
        8: {
            "grid_log": [-2.078051701, -1.20306344, -0.586104656, -0.06584422554, 0.3945629476, 0.8124396319, 1.199943403, 1.574525734],
            "weight_log": [-1.632469462, -1.000394425, -0.4762864045, -0.01645240133, 0.3971963416, 0.7774972642, 1.138168615, 1.518153048],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 2.2101e-04,
            "max_err": 1.0602e-03,
        },
        9: {
            "grid_log": [-2.168575994, -1.33537732, -0.7612201834, -0.2729592665, 0.1639440562, 0.5635633432, 0.934635162, 1.285024295, 1.629675939],
            "weight_log": [-1.734098471, -1.162984106, -0.6814897535, -0.2484998407, 0.1458192126, 0.5102174767, 0.8522698078, 1.183233648, 1.541185758],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 7.8219e-05,
            "max_err": 3.9118e-04,
        },
        10: {
            "grid_log": [-2.243882973, -1.440971645, -0.9051989673, -0.4493884733, -0.03729445089, 0.343237075, 0.6988989323, 1.034675199, 1.356245684, 1.677044347],
            "weight_log": [-1.816658679, -1.294819139, -0.8569090099, -0.4526198475, -0.07854469165, 0.2703461077, 0.5988455473, 0.9120884902, 1.220157407, 1.560714113],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 2.7545e-05,
            "max_err": 1.5156e-04,
        },
        11: {
            "grid_log": [-2.312034417, -1.530565684, -1.026720853, -0.6011652281, -0.2136142851, 0.1472645741, 0.486954833, 0.8093014129, 1.117606565, 1.416360204, 1.717944254],
            "weight_log": [-1.889634298, -1.405581062, -1.008580513, -0.6329459922, -0.2796031449, 0.05299362715, 0.3682832545, 0.6695036953, 0.9605726461, 1.250811278, 1.577527503],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 9.8084e-06,
            "max_err": 6.1361e-05,
        },
        12: {
            "grid_log": [-2.380710691, -1.615906154, -1.141017232, -0.7473146174, -0.3864975224, -0.04553382546, 0.2792778084, 0.5899866001, 0.8886990091, 1.177583983, 1.460199491, 1.748103089],
            "weight_log": [-1.961912594, -1.509173642, -1.154950266, -0.8127203318, -0.4803368344, -0.1614859662, 0.1431213089, 0.4355906033, 0.7183868912, 0.9944580651, 1.272480932, 1.589586721],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 3.9117e-06,
            "max_err": 3.0312e-05,
        },
        13: {
            "grid_log": [-2.438275477, -1.681658212, -1.222100835, -0.8483787206, -0.5075398665, -0.1851356638, 0.1208925665, 0.4117303655, 0.6909831119, 0.9630327531, 1.23097071, 1.497719717, 1.773424193],
            "weight_log": [-2.021273155, -1.584277759, -1.255482344, -0.938450547, -0.6257798952, -0.325745496, -0.04263067349, 0.2276993638, 0.4925243869, 0.7559716739, 1.019154203, 1.288265577, 1.59879759],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 1.6957e-06,
            "max_err": 1.6193e-05,
        },
        14: {
            "grid_log": [-2.459513291, -1.709598042, -1.264655962, -0.9156118542, -0.6077904633, -0.3132292904, -0.02073745893, 0.2641113717, 0.5312656378, 0.777142732, 1.015979426, 1.262688008, 1.517738148, 1.786105808],
            "weight_log": [-2.043884664, -1.62051189, -1.319379945, -1.044401397, -0.7720241388, -0.4834606697, -0.1956412825, 0.06971300114, 0.3013783233, 0.5172596602, 0.7616598199, 1.024994647, 1.293655326, 1.602067933],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 1.2014e-06,
            "max_err": 1.2582e-05,
        },
        15: {
            "grid_log": [-2.516239252, -1.767581677, -1.327716179, -0.9933699231, -0.7082391659, -0.4327713257, -0.154156778, 0.1206835487, 0.3842130775, 0.6282137056, 0.8470209487, 1.061203967, 1.293111325, 1.538915262, 1.80032092],
            "weight_log": [-2.100824759, -1.68042033, -1.39273059, -1.150467551, -0.9064543963, -0.6271784136, -0.3476690179, -0.08519286992, 0.1536473973, 0.3540595545, 0.5325818878, 0.7703291697, 1.035658081, 1.301545487, 1.606509789],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 7.6620e-07,
            "max_err": 9.1293e-06,
        },
    },
    (0.1, 500): {
        5: {
            "grid_log": [-1.443710855, -0.4319245992, 0.2788804604, 0.8590594305, 1.371076132],
            "weight_log": [-0.9528322198, -0.1605266954, 0.4418913508, 0.9514680143, 1.431726012],
            "weight_sgn": [1, 1, 1, 1, 1],
            "L2_err": 4.3763e-03,
            "max_err": 1.8175e-02,
        },
        6: {
            "grid_log": [-1.822928938, -0.811363429, -0.10076607, 0.4782645546, 0.9802635857, 1.440145231],
            "weight_log": [-1.332130253, -0.5400591615, 0.06200760547, 0.5683966713, 1.018823857, 1.461314595],
            "weight_sgn": [1, 1, 1, 1, 1, 1],
            "L2_err": 1.8465e-03,
            "max_err": 7.8753e-03,
        },
        7: {
            "grid_log": [-2.132856178, -1.138336318, -0.4371071158, 0.1364200429, 0.6334535068, 1.07982333, 1.499598674],
            "weight_log": [-1.648293254, -0.8739357554, -0.2790499996, 0.2227650169, 0.6661596833, 1.073400808, 1.48658785],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1],
            "L2_err": 7.6497e-04,
            "max_err": 3.4686e-03,
        },
        8: {
            "grid_log": [-2.320322443, -1.376595067, -0.7063124972, -0.1511256999, 0.333477978, 0.7692043202, 1.170427308, 1.555799351],
            "weight_log": [-1.853512035, -1.13504804, -0.5645907611, -0.07695644573, 0.3560451251, 0.7507740752, 1.122376715, 1.510303164],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 3.0516e-04,
            "max_err": 1.4499e-03,
        },
        9: {
            "grid_log": [-2.442921668, -1.552326981, -0.9217130445, -0.3920681645, 0.07489197056, 0.4972866212, 0.8862651698, 1.251053652, 1.607454578],
            "weight_log": [-1.992835964, -1.339804204, -0.8032043886, -0.3358167238, 0.08295936401, 0.4660355351, 0.8228062585, 1.16532503, 1.531935244],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 1.1997e-04,
            "max_err": 5.9224e-04,
        },
        10: {
            "grid_log": [-2.533545933, -1.685598159, -1.095179501, -0.5943183514, -0.1480557193, 0.2586720831, 0.6349223705, 0.9872488892, 1.322346164, 1.654384976],
            "weight_log": [-2.095302541, -1.501811487, -1.00348304, -0.559784932, -0.1578054232, 0.2122471644, 0.5573937078, 0.8839900159, 1.202717868, 1.55143691],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 4.6767e-05,
            "max_err": 2.4070e-04,
        },
        11: {
            "grid_log": [-2.600908959, -1.782329338, -1.226074435, -0.7529294086, -0.3280977773, 0.06140041197, 0.4230864612, 0.762262617, 1.083402444, 1.392049539, 1.70153912],
            "weight_log": [-2.169992417, -1.622162538, -1.160604571, -0.7413515846, -0.3577499555, -0.002841735318, 0.3289461025, 0.6422199277, 0.9421527837, 1.239119053, 1.571026018],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 1.8169e-05,
            "max_err": 8.8561e-05,
        },
        12: {
            "grid_log": [-2.663564573, -1.868672428, -1.345240913, -0.9016683568, -0.4997050656, -0.1273405129, 0.2212557442, 0.5499316526, 0.8616937146, 1.159544162, 1.44816608, 1.740253653],
            "weight_log": [-2.238115024, -1.73040985, -1.308634279, -0.916419622, -0.5509968959, -0.2091453858, 0.1125010399, 0.4169615066, 0.7071377615, 0.9873853127, 1.267961352, 1.586920318],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 6.9920e-06,
            "max_err": 3.6386e-05,
        },
        13: {
            "grid_log": [-2.7210933, -1.943337225, -1.446561401, -1.029535547, -0.6495448876, -0.2942136293, 0.04138778722, 0.3594528261, 0.6612316694, 0.9487691966, 1.225435311, 1.496276869, 1.773521311],
            "weight_log": [-2.29945589, -1.822513133, -1.436407544, -1.070416685, -0.7232576788, -0.3942634456, -0.08236480055, 0.2125971724, 0.4920926482, 0.760368988, 1.023301223, 1.290900135, 1.600044367],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 2.7101e-06,
            "max_err": 1.5907e-05,
        },
        14: {
            "grid_log": [-2.793910365, -2.028049862, -1.548774741, -1.148848958, -0.7846923497, -0.4447229827, -0.1235722385, 0.1818766407, 0.4726737283, 0.7494275556, 1.014299332, 1.27171526, 1.527575045, 1.793652712],
            "weight_log": [-2.375035602, -1.919420396, -1.556471259, -1.20800146, -0.8772388268, -0.5641874374, -0.265840497, 0.01839361542, 0.2875988392, 0.5434070036, 0.7919257067, 1.041354638, 1.30100912, 1.605408394],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 1.1675e-06,
            "max_err": 1.0245e-05,
        },
        15: {
            "grid_log": [-2.869061517, -2.10873665, -1.640332943, -1.254949451, -0.9056870503, -0.5777932039, -0.2661270605, 0.02990151346, 0.3107943931, 0.5781134324, 0.8331083968, 1.077784055, 1.317274327, 1.558717014, 1.813878118],
            "weight_log": [-2.451344963, -2.006576277, -1.661654043, -1.332053024, -1.015692743, -0.7111502777, -0.4212191968, -0.1481129428, 0.1105025175, 0.3569500697, 0.5920015818, 0.8219638331, 1.058265519, 1.310513325, 1.61060747],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 5.8911e-07,
            "max_err": 6.4720e-06,
        },
    },
    (0.1, 1000): {
        5: {
            "grid_log": [-1.443736376, -0.4319275886, 0.2788898387, 0.8590648307, 1.371075761],
            "weight_log": [-0.9528516272, -0.1605182302, 0.4419022512, 0.9514677419, 1.431722446],
            "weight_sgn": [1, 1, 1, 1, 1],
            "L2_err": 4.2277e-03,
            "max_err": 1.8177e-02,
        },
        6: {
            "grid_log": [-1.823493924, -0.8116826107, -0.1009444969, 0.4781709207, 0.9802172249, 1.440126526],
            "weight_log": [-1.3326053, -0.5402796436, 0.06190394232, 0.5683539418, 1.018809969, 1.461315454],
            "weight_sgn": [1, 1, 1, 1, 1, 1],
            "L2_err": 1.8206e-03,
            "max_err": 7.8752e-03,
        },
        7: {
            "grid_log": [-2.174344125, -1.16314687, -0.4527709789, 0.1259695872, 0.6264226048, 1.075217411, 1.496792655],
            "weight_log": [-1.683679455, -0.8919919569, -0.2901321502, 0.2156785253, 0.661754094, 1.070889263, 1.485397788],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1],
            "L2_err": 8.1051e-04,
            "max_err": 3.6136e-03,
        },
        8: {
            "grid_log": [-2.453282265, -1.462082969, -0.7627445338, -0.1903186343, 0.3057493345, 0.7497515397, 1.157242059, 1.547492804],
            "weight_log": [-1.969916601, -1.199075078, -0.6056472967, -0.10472567, 0.3373336472, 0.7387063693, 1.115283668, 1.506804933],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 3.5244e-04,
            "max_err": 1.6600e-03,
        },
        9: {
            "grid_log": [-2.624312086, -1.681687496, -1.012159098, -0.4574568904, 0.02676586006, 0.4618702366, 0.8606546488, 1.233225093, 1.595901435],
            "weight_log": [-2.157868234, -1.440687584, -0.8708623393, -0.3836289735, 0.04895106715, 0.4423481457, 0.8071375658, 1.155885651, 1.527111652],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 1.4875e-04,
            "max_err": 7.2963e-04,
        },
        10: {
            "grid_log": [-2.739155121, -1.846382143, -1.213978267, -0.6831079425, -0.215319625, 0.2075425415, 0.5963570647, 0.9587547275, 1.302073109, 1.640914533],
            "weight_log": [-2.288414773, -1.632558929, -1.094350667, -0.6259718581, -0.2066085466, 0.1765553816, 0.5319933205, 0.866844787, 1.192137755, 1.545842334],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 6.2054e-05,
            "max_err": 3.1472e-04,
        },
        11: {
            "grid_log": [-2.826281522, -1.974552753, -1.380252275, -0.8764908513, -0.4280581488, -0.01960173623, 0.3579882323, 0.7109028998, 1.044058841, 1.363191093, 1.681797986],
            "weight_log": [-2.387036961, -1.788002737, -1.285819272, -0.8396811149, -0.4358490366, -0.06430805922, 0.2817538253, 0.6074538221, 0.9180858492, 1.223918661, 1.562738119],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 2.5703e-05,
            "max_err": 1.3680e-04,
        },
        12: {
            "grid_log": [-2.899459575, -2.079861819, -1.521983987, -1.046960803, -0.6200116717, -0.2280462195, 0.1365819036, 0.4790745003, 0.803396498, 1.113092366, 1.412902736, 1.715400496],
            "weight_log": [-2.468321265, -1.918726282, -1.454992951, -1.03344945, -0.6472484403, -0.2891696123, 0.04635769548, 0.3634863169, 0.6658537593, 0.9577577242, 1.248755036, 1.576220421],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 1.0652e-05,
            "max_err": 6.5913e-05,
        },
        13: {
            "grid_log": [-2.952501271, -2.152983202, -1.622149986, -1.171758268, -0.7653012369, -0.3902440537, -0.03963330964, 0.2911622958, 0.6052207241, 0.9048211569, 1.19247369, 1.472537504, 1.757333276],
            "weight_log": [-2.52605992, -2.010027361, -1.578569786, -1.180650641, -0.8127730555, -0.4694303734, -0.145838266, 0.161250578, 0.4538203117, 0.734098026, 1.006222107, 1.280289524, 1.594097087],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 4.3452e-06,
            "max_err": 2.3485e-05,
        },
        14: {
            "grid_log": [-3.01452732, -2.234813567, -1.734346661, -1.312851159, -0.9299030138, -0.5733395452, -0.2361257686, 0.08566291347, 0.3937170221, 0.6890782213, 0.9729088444, 1.246863201, 1.514523977, 1.787656446],
            "weight_log": [-2.592464212, -2.111851615, -1.719895199, -1.349311949, -1.001442803, -0.6720823427, -0.3565939106, -0.05417385504, 0.235268963, 0.5127618137, 0.7800093339, 1.040274999, 1.303260238, 1.607301557],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 1.8524e-06,
            "max_err": 1.0698e-05,
        },
        15: {
            "grid_log": [-3.182920441, -2.40543902, -1.910071802, -1.493700844, -1.116211199, -0.766251017, -0.4356554667, -0.1192070838, 0.1843870001, 0.4755215604, 0.7556010155, 1.026265591, 1.289178862, 1.547676332, 1.812872905],
            "weight_log": [-2.761027633, -2.285640175, -1.900827105, -1.535684591, -1.194945554, -0.8737737173, -0.5641858672, -0.2656248013, 0.01967333954, 0.293060137, 0.5573434636, 0.8141081578, 1.066131032, 1.322543648, 1.620653535],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 9.8749e-07,
            "max_err": 4.1372e-06,
        },
    },
    (0.2, 100): {
        5: {
            "grid_log": [-1.599427661, -0.6476013619, 0.02812421968, 0.5879248914, 1.086753611],
            "weight_log": [-1.129895775, -0.4021223914, 0.1730007381, 0.6671614013, 1.13788273],
            "weight_sgn": [1, 1, 1, 1, 1],
            "L2_err": 1.7993e-03,
            "max_err": 7.5010e-03,
        },
        6: {
            "grid_log": [-1.758633488, -0.8749573516, -0.2502453555, 0.2756509952, 0.7418952486, 1.176255303],
            "weight_log": [-1.310565741, -0.6667201921, -0.1354014481, 0.3295712678, 0.7522428283, 1.176097157],
            "weight_sgn": [1, 1, 1, 1, 1, 1],
            "L2_err": 5.3900e-04,
            "max_err": 2.3868e-03,
        },
        7: {
            "grid_log": [-1.867635016, -1.034505347, -0.460408556, 0.02783155761, 0.4650972447, 0.8673352783, 1.253482066],
            "weight_log": [-1.433178485, -0.8621562539, -0.380726182, 0.05234872254, 0.4479124585, 0.8202587487, 1.208752982],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1],
            "L2_err": 1.5877e-04,
            "max_err": 7.3962e-04,
        },
        8: {
            "grid_log": [-1.951085083, -1.151112221, -0.6197340621, -0.1684013027, 0.2396140263, 0.6169118153, 0.9722687771, 1.320977769],
            "weight_log": [-1.524522982, -1.007786949, -0.5755632199, -0.1760032949, 0.1941798323, 0.5411664952, 0.8760892801, 1.237011366],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 4.6244e-05,
            "max_err": 2.2575e-04,
        },
        9: {
            "grid_log": [-2.019136422, -1.240388602, -0.7420032863, -0.323773111, 0.0560221065, 0.4095951664, 0.7429995877, 1.062226883, 1.380858662],
            "weight_log": [-1.597288825, -1.118494869, -0.7302785497, -0.3639746208, -0.01892582034, 0.3066293232, 0.6172282515, 0.9230257981, 1.261802547],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 1.3362e-05,
            "max_err": 6.8118e-05,
        },
        10: {
            "grid_log": [-2.072171694, -1.306603369, -0.831471307, -0.4399840594, -0.08518641829, 0.2462227684, 0.5594295171, 0.8580358936, 1.147029168, 1.439303639],
            "weight_log": [-1.653163923, -1.199246762, -0.8460944222, -0.5100706473, -0.1890497437, 0.1158686005, 0.406356775, 0.6866499625, 0.9672127165, 1.286154183],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 3.8693e-06,
            "max_err": 1.7922e-05,
        },
        11: {
            "grid_log": [-2.104485948, -1.346116276, -0.8850602264, -0.5121026365, -0.1758700262, 0.139556019, 0.4396320422, 0.7262268036, 1.000396247, 1.265180395, 1.532076475],
            "weight_log": [-1.687000444, -1.247176899, -0.9176688901, -0.6053652329, -0.3025423033, -0.01073461149, 0.2689713869, 0.5358326075, 0.7917517745, 1.046523239, 1.339238847],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 1.4783e-06,
            "max_err": 8.9288e-06,
        },
        12: {
            "grid_log": [-2.176378022, -1.428722593, -0.9857404715, -0.6302642238, -0.3055950033, 0.000520908083, 0.2889203154, 0.5616551552, 0.8231785103, 1.077633246, 1.329248404, 1.590286423],
            "weight_log": [-1.761282229, -1.342022568, -1.039814825, -0.7418926401, -0.4450781792, -0.1642255649, 0.09820542928, 0.3493006279, 0.5959981662, 0.8414480056, 1.094083501, 1.396016562],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 5.6073e-07,
            "max_err": 1.6153e-06,
        },
        13: {
            "grid_log": [-2.239156808, -1.503249496, -1.079285085, -0.7420483819, -0.4307394866, -0.1343231177, 0.1462109081, 0.4084512329, 0.6527072693, 0.8872186065, 1.122012173, 1.360920091, 1.612980283],
            "weight_log": [-1.82674761, -1.430130359, -1.156714194, -0.8745303307, -0.586078256, -0.3114165981, -0.05739152304, 0.1731563093, 0.3903618628, 0.6177894007, 0.8579890731, 1.107569228, 1.406135774],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 3.0526e-07,
            "max_err": 9.7751e-07,
        },
        14: {
            "grid_log": [-2.265358004, -1.540479656, -1.138089369, -0.822332304, -0.5254411233, -0.2394894197, 0.03237118977, 0.2869269738, 0.5229546119, 0.7453012315, 0.9639528996, 1.184805462, 1.409278078, 1.6457413],
            "weight_log": [-1.85521448, -1.481837216, -1.24525, -0.9795303174, -0.6981200468, -0.4309090063, -0.1843365438, 0.03855798875, 0.2425444098, 0.4477345581, 0.6673896828, 0.8940775456, 1.128612997, 1.410045498],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 1.3525e-07,
            "max_err": 7.3014e-07,
        },
        15: {
            "grid_log": [-2.273141801, -1.562758473, -1.188520876, -0.8887393926, -0.6049365594, -0.3410362881, -0.08931575328, 0.1556962927, 0.3865716947, 0.5960542401, 0.7961142888, 1.000582088, 1.206821027, 1.412857243, 1.627896924],
            "weight_log": [-1.865624767, -1.525423185, -1.329581588, -1.060503728, -0.8056102068, -0.5707132021, -0.3315213185, -0.1020794697, 0.09156270201, 0.2618546875, 0.4630605808, 0.6772687367, 0.8824456168, 1.092157965, 1.350439298],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 9.2602e-08,
            "max_err": 7.4089e-07,
        },
    },
    (0.2, 200): {
        5: {
            "grid_log": [-1.71256029, -0.7147439457, -0.01157966031, 0.5642791817, 1.073529322],
            "weight_log": [-1.226795207, -0.4489830892, 0.1476180608, 0.6539485994, 1.132204114],
            "weight_sgn": [1, 1, 1, 1, 1],
            "L2_err": 2.1203e-03,
            "max_err": 8.7341e-03,
        },
        6: {
            "grid_log": [-1.935453795, -0.9968449243, -0.3299668969, 0.2233317662, 0.708367612, 1.156273697],
            "weight_log": [-1.470347069, -0.7578241491, -0.1901000247, 0.296535779, 0.7338599166, 1.167610176],
            "weight_sgn": [1, 1, 1, 1, 1, 1],
            "L2_err": 7.1459e-04,
            "max_err": 3.1383e-03,
        },
        7: {
            "grid_log": [-2.072551993, -1.195002761, -0.5757127971, -0.05377111131, 0.4081268473, 0.8289340241, 1.229424594],
            "weight_log": [-1.626243805, -0.9906658607, -0.4643698908, -0.003070688279, 0.4124704623, 0.7995596343, 1.198600038],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1],
            "L2_err": 2.3536e-04,
            "max_err": 1.0876e-03,
        },
        8: {
            "grid_log": [-2.170709281, -1.338454388, -0.7654042858, -0.2780575398, 0.1582144222, 0.5577545248, 0.9308987711, 1.294026747],
            "weight_log": [-1.736468454, -1.166818494, -0.6865361598, -0.2543396859, 0.139621476, 0.5049724849, 0.8541626295, 1.225714335],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 7.6544e-05,
            "max_err": 3.7065e-04,
        },
        9: {
            "grid_log": [-2.247579872, -1.445957985, -0.9122622842, -0.4588080786, -0.04917527149, 0.3289174151, 0.6826260164, 1.018812133, 1.351718307],
            "weight_log": [-1.820639385, -1.301078879, -0.8660097675, -0.464486525, -0.09316420376, 0.2532595546, 0.5809121947, 0.9004562583, 1.249761784],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 2.4673e-05,
            "max_err": 1.2431e-04,
        },
        10: {
            "grid_log": [-2.313177809, -1.532455153, -1.030654384, -0.608476788, -0.2248346823, 0.1321817473, 0.4681718791, 0.7872548611, 1.094524303, 1.40296282],
            "weight_log": [-1.890904695, -1.408440345, -1.015214906, -0.6443530865, -0.2954237992, 0.03318160234, 0.3447598772, 0.6437198807, 0.9399302252, 1.271130481],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 7.9081e-06,
            "max_err": 4.1619e-05,
        },
        11: {
            "grid_log": [-2.365681306, -1.598391006, -1.120000409, -0.7243952842, -0.3650326837, -0.02861493401, 0.289708794, 0.5927344377, 0.8831845786, 1.16548929, 1.452075848],
            "weight_log": [-1.946309797, -1.489056392, -1.130679361, -0.7894138102, -0.462829836, -0.1520747828, 0.1436462573, 0.4265709573, 0.7007700212, 0.9763244934, 1.29122432],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 2.5207e-06,
            "max_err": 1.3268e-05,
        },
        12: {
            "grid_log": [-2.400001476, -1.640182506, -1.175855348, -0.7980017387, -0.4564838395, -0.1358137288, 0.1695786056, 0.4616584453, 0.7412260988, 1.009305646, 1.269185894, 1.532559379],
            "weight_log": [-1.982224133, -1.539444292, -1.203894845, -0.8847810839, -0.5761856407, -0.278724919, 0.006822077422, 0.2798109745, 0.5404909286, 0.791607408, 1.043474233, 1.335292527],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 9.7015e-07,
            "max_err": 4.9254e-06,
        },
        13: {
            "grid_log": [-2.474631363, -1.724482537, -1.27521136, -0.9110862017, -0.5790954967, -0.2667872234, 0.02866477188, 0.3098627312, 0.5795971502, 0.8393053792, 1.090051827, 1.335068036, 1.585833299],
            "weight_log": [-2.059077563, -1.634377315, -1.320130795, -1.012060763, -0.7098193574, -0.4223507394, -0.1500241948, 0.1115666101, 0.3643785854, 0.6080065334, 0.8449561431, 1.085733605, 1.369766827],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 3.9606e-07,
            "max_err": 8.0189e-07,
        },
        14: {
            "grid_log": [-2.507996607, -1.766337338, -1.333869753, -0.9902507474, -0.6767977855, -0.3814576149, -0.1021440566, 0.1642097938, 0.4199616912, 0.6653022418, 0.9005402922, 1.128633014, 1.355110205, 1.591419449],
            "weight_log": [-2.094238206, -1.686693882, -1.401563951, -1.117049244, -0.8317692935, -0.5614626778, -0.3049326462, -0.05715973254, 0.1811962453, 0.4078329871, 0.6262308728, 0.8452776239, 1.075897629, 1.353228756],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 1.3980e-07,
            "max_err": 6.4430e-07,
        },
        15: {
            "grid_log": [-2.555684011, -1.820902112, -1.401089064, -1.072360541, -0.7707670036, -0.4850561282, -0.2164151568, 0.03776639498, 0.2814805842, 0.5166917322, 0.7435385982, 0.9607222697, 1.169081621, 1.376051034, 1.597776632],
            "weight_log": [-2.143467492, -1.749564489, -1.486479436, -1.218092269, -0.9404730503, -0.6801037899, -0.4381456447, -0.2047866067, 0.02261371818, 0.2428446642, 0.4525666571, 0.6496408164, 0.8447106943, 1.06178424, 1.339713615],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 7.1715e-08,
            "max_err": 2.8138e-07,
        },
    },
    (0.2, 500): {
        5: {
            "grid_log": [-1.744603269, -0.7328805263, -0.02209478054, 0.5580692383, 1.070073997],
            "weight_log": [-1.253752918, -0.461499653, 0.1409054678, 0.6504670422, 1.13071384],
            "weight_sgn": [1, 1, 1, 1, 1],
            "L2_err": 2.2407e-03,
            "max_err": 9.0834e-03,
        },
        6: {
            "grid_log": [-2.095589119, -1.095860083, -0.391729072, 0.1836406095, 0.6832627709, 1.14147687],
            "weight_log": [-1.609123279, -0.8293251729, -0.2321804795, 0.2714290427, 0.7200225358, 1.161293971],
            "weight_sgn": [1, 1, 1, 1, 1, 1],
            "L2_err": 8.8149e-04,
            "max_err": 3.8184e-03,
        },
        7: {
            "grid_log": [-2.308829928, -1.360764995, -0.6876090815, -0.1306438925, 0.3554110981, 0.7939180134, 1.207820575],
            "weight_log": [-1.840565182, -1.117104635, -0.5443060432, -0.05523253669, 0.3795339618, 0.7806056759, 1.189483545],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1],
            "L2_err": 3.3032e-04,
            "max_err": 1.5121e-03,
        },
        8: {
            "grid_log": [-2.442403159, -1.55156195, -0.9207661268, -0.390981481, 0.07614691619, 0.4990345917, 0.8904219271, 1.268082598],
            "weight_log": [-1.992240316, -1.338900535, -0.8021439061, -0.3346081504, 0.08444402795, 0.4688126575, 0.8325923331, 1.214853317],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 1.2140e-04,
            "max_err": 5.8100e-04,
        },
        9: {
            "grid_log": [-2.538738871, -1.693144541, -1.105187774, -0.6061728604, -0.1612821684, 0.244443447, 0.6203406747, 0.9746372111, 1.322501631],
            "weight_log": [-2.101110432, -1.511109137, -1.015240129, -0.5730967323, -0.1722493699, 0.1971481916, 0.5431680818, 0.8772825448, 1.237608945],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 4.4156e-05,
            "max_err": 2.1967e-04,
        },
        10: {
            "grid_log": [-2.614753762, -1.801631581, -1.252169892, -0.7844678267, -0.3633820379, 0.02381284454, 0.3844618511, 0.7240100779, 1.048475823, 1.371569301],
            "weight_log": [-2.185146484, -1.646193049, -1.192164541, -0.7773492626, -0.3962416053, -0.04234970765, 0.2898091564, 0.6057557392, 0.9158652892, 1.257957951],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 1.5940e-05,
            "max_err": 8.2859e-05,
        },
        11: {
            "grid_log": [-2.671437016, -1.878648448, -1.357941402, -0.9171570219, -0.518693188, -0.1507883462, 0.1927974812, 0.5165563391, 0.8244986815, 1.121870722, 1.421669363],
            "weight_log": [-2.246497143, -1.742311899, -1.323821683, -0.9350522373, -0.5745228593, -0.2384631064, 0.0774579055, 0.377307394, 0.6660355194, 0.9538803192, 1.278665457],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 5.7648e-06,
            "max_err": 2.7459e-05,
        },
        12: {
            "grid_log": [-2.701119763, -1.91813401, -1.413440169, -0.9894965618, -0.6059833856, -0.2510597742, 0.08088090852, 0.3932383704, 0.6882339764, 0.9681846862, 1.237523518, 1.508905791],
            "weight_log": [-2.27830795, -1.791944322, -1.395543713, -1.024434343, -0.6778992726, -0.3539015098, -0.04943356273, 0.237397089, 0.5083294648, 0.7674740186, 1.025996801, 1.323531326],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 2.7111e-06,
            "max_err": 1.3651e-05,
        },
        13: {
            "grid_log": [-2.746635501, -1.975631474, -1.49064829, -1.086984442, -0.719750291, -0.3769652424, -0.05494218018, 0.2481506106, 0.5344056429, 0.8063190993, 1.066581437, 1.319531284, 1.57735235],
            "weight_log": [-2.326450851, -1.862198092, -1.493585268, -1.142567018, -0.8084785312, -0.4935382542, -0.198206906, 0.07911718951, 0.3416996499, 0.5927473511, 0.8363331823, 1.083105669, 1.372545033],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 1.1593e-06,
            "max_err": 5.8008e-06,
        },
        14: {
            "grid_log": [-2.819151841, -2.059579712, -1.592656961, -1.206633183, -0.8539285934, -0.5239320444, -0.2140054744, 0.07819048408, 0.355833073, 0.6210184169, 0.8742828751, 1.116745382, 1.352472269, 1.593447001],
            "weight_log": [-2.401551803, -1.958462108, -1.615059605, -1.280779608, -0.9593842056, -0.6570221438, -0.3738559314, -0.1056535962, 0.1513998607, 0.3968286711, 0.6300786041, 0.8556344934, 1.085878421, 1.36017702],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 4.3305e-07,
            "max_err": 9.1413e-07,
        },
        15: {
            "grid_log": [-2.916699617, -2.161264786, -1.701955031, -1.325323392, -0.982617692, -0.6628391046, -0.3619601827, -0.0766056665, 0.1952348627, 0.4538322643, 0.7006923151, 0.9390973444, 1.171264301, 1.399333411, 1.632108094],
            "weight_log": [-2.499986621, -2.064946686, -1.733777663, -1.411176751, -1.101336694, -0.8096226925, -0.5334671355, -0.2696222036, -0.01899259674, 0.2178634622, 0.4469110161, 0.6724763134, 0.894349393, 1.118983005, 1.381605676],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 2.1095e-07,
            "max_err": 4.8717e-07,
        },
    },
    (0.2, 1000): {
        5: {
            "grid_log": [-1.744750389, -0.732967474, -0.02216406102, 0.558020805, 1.07004664],
            "weight_log": [-1.253872664, -0.4615713746, 0.140847814, 0.6504370642, 1.130702519],
            "weight_sgn": [1, 1, 1, 1, 1],
            "L2_err": 2.1881e-03,
            "max_err": 9.0860e-03,
        },
        6: {
            "grid_log": [-2.123973387, -1.112394794, -0.401797023, 0.1772395825, 0.6792412164, 1.139122536],
            "weight_log": [-1.633166453, -0.8410903974, -0.2390202999, 0.267375762, 0.7178021593, 1.160290646],
            "weight_sgn": [1, 1, 1, 1, 1, 1],
            "L2_err": 9.2325e-04,
            "max_err": 3.9369e-03,
        },
        7: {
            "grid_log": [-2.433880514, -1.439378238, -0.7381809934, -0.1646554466, 0.3323874069, 0.7787670331, 1.198552353],
            "weight_log": [-1.94931539, -1.174998551, -0.5801351301, -0.07830419957, 0.3651026168, 0.7723546856, 1.185551431],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1],
            "L2_err": 3.8248e-04,
            "max_err": 1.7346e-03,
        },
        8: {
            "grid_log": [-2.621334162, -1.677615842, -1.007316883, -0.4521240972, 0.03247436303, 0.4681957595, 0.8694099022, 1.254777546],
            "weight_log": [-2.15452996, -1.436064882, -0.8655829452, -0.3779581668, 0.05503773918, 0.4497575473, 0.8213505958, 1.209279754],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 1.5258e-04,
            "max_err": 7.2464e-04,
        },
        9: {
            "grid_log": [-2.743869702, -1.853231859, -1.222611708, -0.6929473265, -0.2259688739, 0.1964185216, 0.5853677269, 0.9501232471, 1.306497465],
            "weight_log": [-2.293764692, -1.640698445, -1.104096099, -0.6366739255, -0.2178944072, 0.1651449975, 0.5218691829, 0.8643581776, 1.230948678],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 5.9986e-05,
            "max_err": 2.9553e-04,
        },
        10: {
            "grid_log": [-2.835095685, -1.987334105, -1.397061792, -0.8962533448, -0.4499857923, -0.04318804243, 0.3331869535, 0.6856724552, 1.020935979, 1.353117897],
            "weight_log": [-2.396903982, -1.803674528, -1.30543958, -0.8617410701, -0.4596982903, -0.08950062828, 0.255831821, 0.5826270272, 0.9015162506, 1.25033112],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 2.3385e-05,
            "max_err": 1.2084e-04,
        },
        11: {
            "grid_log": [-2.908377759, -2.092126473, -1.538459245, -1.067038835, -0.6430276949, -0.2535309378, 0.1089244586, 0.4495867921, 0.7727437255, 1.08364206, 1.395343464],
            "weight_log": [-2.478042091, -1.933884951, -1.474892832, -1.056685437, -0.673106788, -0.3173241306, 0.01620482363, 0.3319037491, 0.6345127119, 0.9339197554, 1.267734597],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 9.0643e-06,
            "max_err": 4.9834e-05,
        },
        12: {
            "grid_log": [-2.949464646, -2.149213597, -1.617956481, -1.167408798, -0.7608268676, -0.3861920634, -0.03723418236, 0.2901177021, 0.5989124166, 0.8922076252, 1.174953711, 1.460296565],
            "weight_log": [-2.522809087, -2.005755382, -1.574207537, -1.176115063, -0.8083393199, -0.4664866012, -0.1466264643, 0.1542204341, 0.4389248193, 0.712120059, 0.9849810056, 1.296713235],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 3.7946e-06,
            "max_err": 1.6123e-05,
        },
        13: {
            "grid_log": [-2.984567577, -2.196438157, -1.683636699, -1.251054466, -0.8593578923, -0.4966782516, -0.1573012657, 0.162509833, 0.4650930448, 0.7519936739, 1.025066958, 1.288277512, 1.55368653],
            "weight_log": [-2.560602829, -2.065015341, -1.65746939, -1.276948565, -0.921980665, -0.5900455835, -0.2777635635, 0.0173534133, 0.2963946877, 0.5607227933, 0.8140467764, 1.066984463, 1.358726843],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 1.9630e-06,
            "max_err": 9.2617e-06,
        },
        14: {
            "grid_log": [-3.038205694, -2.263568415, -1.770837002, -1.357322092, -0.9818353414, -0.6336579625, -0.3075458612, 0.0003639753237, 0.2930753938, 0.572380643, 0.8388499538, 1.093021191, 1.337773306, 1.584321412],
            "weight_log": [-2.617314682, -2.145778619, -1.764377246, -1.402169429, -1.062385874, -0.7445578556, -0.4450432878, -0.1604526572, 0.1113985617, 0.3705284049, 0.6162645161, 0.85077027, 1.084542528, 1.35760493],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 8.0434e-07,
            "max_err": 3.3289e-06,
        },
        15: {
            "grid_log": [-3.189768597, -2.417178941, -1.931461772, -1.526598972, -1.157796596, -0.8139019255, -0.4921187476, -0.1895949453, 0.09825237109, 0.3746433514, 0.6399397131, 0.8934190069, 1.135217839, 1.367834323, 1.601992147],
            "weight_log": [-2.768908515, -2.302969269, -1.933563974, -1.580491446, -1.244659043, -0.9298328085, -0.6364885539, -0.3584176962, -0.08952420409, 0.1697605887, 0.4162602199, 0.6491847585, 0.871197003, 1.092133874, 1.35367626],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 4.0745e-07,
            "max_err": 8.7623e-07,
        },
    },
    (0.5, 100): {
        5: {
            "grid_log": [-1.772347781, -0.895116102, -0.275984055, 0.2472863182, 0.7214625102],
            "weight_log": [-1.326131403, -0.690977021, -0.1645265758, 0.3011611279, 0.753939371],
            "weight_sgn": [1, 1, 1, 1, 1],
            "L2_err": 4.8027e-04,
            "max_err": 2.0234e-03,
        },
        6: {
            "grid_log": [-1.889721698, -1.065991869, -0.5030336479, -0.02361196302, 0.4086898941, 0.8177136338],
            "weight_log": [-1.45758338, -0.9013560508, -0.4319712715, -0.005999345306, 0.3896489466, 0.7948567805],
            "weight_sgn": [1, 1, 1, 1, 1, 1],
            "L2_err": 1.1986e-04,
            "max_err": 5.3783e-04,
        },
        7: {
            "grid_log": [-1.978140838, -1.187235092, -0.6693489174, -0.2309270694, 0.1668058143, 0.5379277096, 0.8993233182],
            "weight_log": [-1.553617646, -1.052779564, -0.6378322933, -0.2506462974, 0.1116497861, 0.4589291727, 0.8291470043],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1],
            "L2_err": 2.9481e-05,
            "max_err": 1.3986e-04,
        },
        8: {
            "grid_log": [-2.04987036, -1.278943578, -0.793959433, -0.3906289919, -0.02427044256, 0.3183959697, 0.6451095864, 0.9700172325],
            "weight_log": [-1.629721532, -1.165526105, -0.7969620126, -0.4469610857, -0.1140931854, 0.2034923243, 0.5151959689, 0.8585695015],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 7.1797e-06,
            "max_err": 3.5663e-05,
        },
        9: {
            "grid_log": [-2.110413326, -1.352170973, -0.890543075, -0.5157387523, -0.1770835427, 0.1405587013, 0.443197792, 0.7361493495, 1.03218571],
            "weight_log": [-1.692991919, -1.253191013, -0.9218356102, -0.6061354168, -0.3005874654, -0.006736864455, 0.2777208509, 0.5621578359, 0.8842293053],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 1.7356e-06,
            "max_err": 8.9845e-06,
        },
        10: {
            "grid_log": [-2.126957985, -1.371448888, -0.9152245497, -0.5485558492, -0.2211204863, 0.08113671711, 0.3632059988, 0.629138167, 0.8849004788, 1.143928035],
            "weight_log": [-1.710125879, -1.275682438, -0.9538627636, -0.6508636931, -0.3623916624, -0.09195328509, 0.1619695894, 0.4055908018, 0.6516897819, 0.9399993027],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 7.9318e-07,
            "max_err": 4.7032e-06,
        },
        11: {
            "grid_log": [-2.133332881, -1.379558201, -0.9271253703, -0.5660617767, -0.2445696572, 0.05299089358, 0.3324821092, 0.5979768837, 0.8547387004, 1.115376053, 1.918199317],
            "weight_log": [-1.716852911, -1.285948995, -0.9711164373, -0.6761234895, -0.3935733048, -0.1256649347, 0.1289514697, 0.3751566846, 0.6239445301, 0.9142101788, 12.62775121],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 6.0382e-07,
            "max_err": 4.3296e-06,
        },
        12: {
            "grid_log": [-2.273594921, -1.555053126, -1.179949469, -0.9108934503, -0.6562801621, -0.4016797857, -0.1420146344, 0.1186753908, 0.3746279278, 0.6242437338, 0.8707278636, 1.126111558],
            "weight_log": [-1.864278948, -1.508294606, -1.344118812, -1.139861616, -0.8901459787, -0.6295011425, -0.362545484, -0.1058487494, 0.1393919148, 0.379273296, 0.6266340674, 0.9203613102],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 1.1866e-07,
            "max_err": 8.2946e-07,
        },
        13: {
            "grid_log": [-2.251257393, -1.526568476, -1.135200964, -0.8506112726, -0.5936567556, -0.3430321525, -0.08850028274, 0.1690944946, 0.4240129606, 0.6735601353, 0.9200193599, 1.176587755, 1.591971012],
            "weight_log": [-1.840815819, -1.47042081, -1.270962206, -1.065930566, -0.8305988999, -0.580020563, -0.3162806264, -0.05864630059, 0.1881653054, 0.4286710038, 0.6758709221, 0.9781168201, 3.362181016],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, -1],
            "L2_err": 1.2283e-07,
            "max_err": 8.3936e-07,
        },
        14: {
            "grid_log": [-2.310987059, -1.601837501, -1.243099854, -0.9739463533, -0.7212247542, -0.4911602385, -0.2634012656, -0.02934325323, 0.2036046563, 0.4328203742, 0.660282743, 0.8881507056, 1.122888694, 1.43789212],
            "weight_log": [-1.903484087, -1.568773495, -1.423821948, -1.192934469, -0.9787088927, -0.7769735367, -0.5359825462, -0.2967748478, -0.07073412865, 0.1528858986, 0.3790218346, 0.6107264133, 0.8739632955, 1.69086621],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 2.8951e-08,
            "max_err": 1.7432e-07,
        },
        15: {
            "grid_log": [-2.304916739, -1.606541214, -1.286922039, -1.019421227, -0.7591489475, -0.5436662411, -0.3321863868, -0.1097257769, 0.1090948366, 0.3220923278, 0.5342774469, 0.7491974462, 0.9690881035, 1.206458665, 1.420535074],
            "weight_log": [-1.898386747, -1.598230928, -1.518677352, -1.21381995, -1.024280586, -0.8697814338, -0.6282624635, -0.4007926678, -0.1956668986, 0.01004024139, 0.2253801452, 0.4474875427, 0.6823566355, 0.9878354344, 0.9922959645],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, -1],
            "L2_err": 2.2425e-08,
            "max_err": 1.3737e-07,
        },
    },
    (0.5, 200): {
        5: {
            "grid_log": [-1.948171, -1.014595074, -0.3511029543, 0.201238659, 0.6951556814],
            "weight_log": [-1.484711554, -0.7781268684, -0.2129668098, 0.2754959771, 0.7426627195],
            "weight_sgn": [1, 1, 1, 1, 1],
            "L2_err": 6.6627e-04,
            "max_err": 2.7851e-03,
        },
        6: {
            "grid_log": [-2.094189985, -1.22685004, -0.6169382321, -0.101405524, 0.3575077167, 0.7864809918],
            "weight_log": [-1.650747552, -1.029260827, -0.5117163684, -0.05515981222, 0.3617339588, 0.7816253613],
            "weight_sgn": [1, 1, 1, 1, 1, 1],
            "L2_err": 1.9164e-04,
            "max_err": 8.5093e-04,
        },
        7: {
            "grid_log": [-2.196867338, -1.375688533, -0.8159326976, -0.3394713443, 0.08879957235, 0.4841064395, 0.8648268176],
            "weight_log": [-1.765344147, -1.213223339, -0.7474902385, -0.3247690862, 0.06336806139, 0.4301785863, 0.8146898312],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1],
            "L2_err": 5.4235e-05,
            "max_err": 2.5394e-04,
        },
        8: {
            "grid_log": [-2.276737071, -1.485063091, -0.9659689697, -0.526412268, -0.1281929998, 0.2411125496, 0.5899255907, 0.9332583613],
            "weight_log": [-1.852044393, -1.349830214, -0.9332826674, -0.5451606423, -0.1835424624, 0.1566885291, 0.4863048394, 0.8432823429],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 1.5183e-05,
            "max_err": 7.4450e-05,
        },
        9: {
            "grid_log": [-2.342759897, -1.569861193, -1.081414325, -0.6741847752, -0.3043963815, 0.0406214317, 0.3668499365, 0.6800827317, 0.9936474069],
            "weight_log": [-1.922185915, -1.454269017, -1.080522612, -0.7262533832, -0.3905248216, -0.07239573745, 0.2322426073, 0.5332627318, 0.8683195839],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 4.2175e-06,
            "max_err": 2.1593e-05,
        },
        10: {
            "grid_log": [-2.379391208, -1.615014169, -1.142206711, -0.7543566356, -0.404616175, -0.07961511833, 0.225883463, 0.51550457, 0.7943390537, 1.075491435],
            "weight_log": [-1.960642352, -1.509013009, -1.159888328, -0.8295845962, -0.5158418591, -0.2196086687, 0.06076266831, 0.3296305081, 0.5980745775, 0.9054517489],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 1.3887e-06,
            "max_err": 7.4895e-06,
        },
        11: {
            "grid_log": [-2.395820183, -1.634883766, -1.168517323, -0.7886840409, -0.4470853466, -0.1300267288, 0.1678201134, 0.450071254, 0.7208098404, 0.9885397315, 1.30374004],
            "weight_log": [-1.977807054, -1.532845005, -1.194148071, -0.8737839112, -0.5688332101, -0.2809559794, -0.008358818318, 0.2526669473, 0.5095678282, 0.7865363422, 1.349207202],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 8.8517e-07,
            "max_err": 5.2386e-06,
        },
        12: {
            "grid_log": [-2.439634866, -1.688289814, -1.241493747, -0.8871411461, -0.5674766008, -0.2649685662, 0.02246480774, 0.2938215074, 0.5501745024, 0.7965022341, 1.043963325, 1.356618642],
            "weight_log": [-2.023621737, -1.597852401, -1.293351034, -1.003928031, -0.7138837758, -0.4327150834, -0.1689685346, 0.0766295796, 0.3105656846, 0.5464576458, 0.813791937, 1.493454238],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 2.9423e-07,
            "max_err": 2.1903e-06,
        },
        13: {
            "grid_log": [-2.50880541, -1.766148138, -1.336586396, -1.011061063, -0.73348812, -0.474666902, -0.2172708459, 0.03898065549, 0.2902580996, 0.5364191062, 0.7803644442, 1.029869147, 1.347227712],
            "weight_log": [-2.094711822, -1.686094613, -1.414361042, -1.177625091, -0.9498106035, -0.702752245, -0.4440669176, -0.1937483217, 0.04769459039, 0.2866625129, 0.531027746, 0.8052029455, 1.492730187],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 8.2128e-08,
            "max_err": 5.7524e-07,
        },
        14: {
            "grid_log": [-2.542859301, -1.805117623, -1.383254336, -1.059432468, -0.7670116467, -0.4860874427, -0.2151497416, 0.04506694065, 0.2946047215, 0.534455284, 0.7667180386, 0.9956967487, 1.241693254, 1.339686721],
            "weight_log": [-2.129877987, -1.730836425, -1.469514042, -1.217297139, -0.9488460158, -0.682798696, -0.4286486431, -0.1865136934, 0.044985964, 0.2688629737, 0.4897831881, 0.7204873029, 1.078292704, 0.6724107569],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, -1],
            "L2_err": 5.3765e-08,
            "max_err": 1.8625e-07,
        },
        15: {
            "grid_log": [-2.592564283, -1.882766319, -1.505798675, -1.205906877, -0.934135483, -0.7085614141, -0.5020687494, -0.276296613, -0.04466626782, 0.1780049089, 0.3863799445, 0.5940553527, 0.8132816721, 1.04398153, 1.304815238],
            "weight_log": [-2.1854206, -1.844770837, -1.642198959, -1.384208422, -1.172711476, -1.033965505, -0.805513292, -0.5488972232, -0.3224422274, -0.1274027729, 0.05947490388, 0.2847234698, 0.5274484479, 0.7831045595, 1.162776126],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 4.6363e-08,
            "max_err": 2.6297e-07,
        },
    },
    (0.5, 500): {
        5: {
            "grid_log": [-2.110507856, -1.112685318, -0.4095395538, 0.1663092526, 0.6755609278],
            "weight_log": [-1.624734988, -0.846931842, -0.2503532097, 0.2559754411, 0.7342416431],
            "weight_sgn": [1, 1, 1, 1, 1],
            "L2_err": 8.4812e-04,
            "max_err": 3.4956e-03,
        },
        6: {
            "grid_log": [-2.333466947, -1.394822732, -0.7279604828, -0.1746643206, 0.3103820159, 0.758303884],
            "weight_log": [-1.868340393, -1.155802985, -0.5881034349, -0.1014552153, 0.3358875679, 0.7696564117],
            "weight_sgn": [1, 1, 1, 1, 1, 1],
            "L2_err": 2.8583e-04,
            "max_err": 1.2558e-03,
        },
        7: {
            "grid_log": [-2.470513265, -1.592951482, -0.9736419928, -0.4517112678, 0.0101654551, 0.4309676267, 0.8314571935],
            "weight_log": [-2.024204259, -1.388600485, -0.8622932196, -0.4010326466, 0.014496701, 0.4015919679, 0.8006324774],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1],
            "L2_err": 9.4145e-05,
            "max_err": 4.3554e-04,
        },
        8: {
            "grid_log": [-2.568695512, -1.73642802, -1.163325115, -0.6759443951, -0.2396728877, 0.1598408458, 0.532965099, 0.8960858432],
            "weight_log": [-2.13445803, -1.564763852, -1.084416048, -0.6522094152, -0.2582850973, 0.1070280076, 0.456214272, 0.827769127],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 3.0618e-05,
            "max_err": 1.4829e-04,
        },
        9: {
            "grid_log": [-2.645497689, -1.843873782, -1.31014577, -0.856664056, -0.4470353101, -0.06896628396, 0.2847102025, 0.6208704001, 0.9537614712],
            "weight_log": [-2.218558792, -1.698983165, -1.263857399, -0.8623318364, -0.4910415087, -0.1446602771, 0.1829573332, 0.502487682, 0.8517924936],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 9.8693e-06,
            "max_err": 4.9794e-05,
        },
        10: {
            "grid_log": [-2.71139112, -1.930706246, -1.428918273, -1.006774743, -0.6232857555, -0.2665479882, 0.06909205974, 0.3878927908, 0.6950886225, 1.003694139],
            "weight_log": [-2.289129712, -1.806715504, -1.413481318, -1.042743793, -0.6941336683, -0.365962822, -0.05477868235, 0.2440885195, 0.5405662285, 0.8722390342],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 3.1644e-06,
            "max_err": 1.7418e-05,
        },
        11: {
            "grid_log": [-2.732363582, -1.957582498, -1.466380636, -1.056964819, -0.6863372368, -0.3425310806, -0.02095948089, 0.2811124216, 0.5664374274, 0.8396666789, 1.113543432],
            "weight_log": [-2.311363765, -1.840040518, -1.462829909, -1.107239029, -0.7724950248, -0.4589637998, -0.1654107689, 0.1104436022, 0.3729901399, 0.633282264, 0.9309629743],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 1.4815e-06,
            "max_err": 7.5733e-06,
        },
        12: {
            "grid_log": [-2.76136624, -1.993816733, -1.515664981, -1.121952881, -0.7668194185, -0.4365949826, -0.1253099254, 0.170357086, 0.4525692671, 0.7243046788, 0.9939552484, 1.323868698],
            "weight_log": [-2.341915025, -1.884329934, -1.527255027, -1.190493592, -0.8714011789, -0.5691787635, -0.281553628, -0.007139206753, 0.2561176668, 0.5152468153, 0.7965816589, 1.460108947],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 7.5837e-07,
            "max_err": 4.2689e-06,
        },
        13: {
            "grid_log": [-2.786729207, -2.025322086, -1.559511543, -1.183330323, -0.8481534134, -0.5372568937, -0.2431651693, 0.03675013035, 0.3032895222, 0.5581693278, 0.8056687341, 1.055918576, 1.391283599],
            "weight_log": [-2.368563538, -1.92302878, -1.587115504, -1.275048561, -0.9790807875, -0.6954083707, -0.4233300828, -0.1647291514, 0.08089808532, 0.3186628199, 0.559265213, 0.8322282828, 1.700588883],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 3.2796e-07,
            "max_err": 2.5810e-06,
        },
        14: {
            "grid_log": [-2.862897542, -2.111697338, -1.6626299, -1.302629334, -0.9786295028, -0.6776914356, -0.3955144812, -0.1262210301, 0.1341308271, 0.3856102652, 0.6281383897, 0.864984925, 1.107549806, 1.532056825],
            "weight_log": [-2.447036902, -2.02080686, -1.709698422, -1.411510283, -1.122693401, -0.8519793736, -0.5944176039, -0.3414706402, -0.09530537459, 0.1402193471, 0.368255108, 0.6011687602, 0.876351205, 2.972674901],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 9.8527e-08,
            "max_err": 2.8650e-07,
        },
        15: {
            "grid_log": [-2.942906101, -2.203284471, -1.768367303, -1.41588326, -1.0945848, -0.7981809782, -0.5249240222, -0.266584381, -0.01578407605, 0.2270887103, 0.4596841524, 0.6835659818, 0.9035116479, 1.123825851, 1.333846088],
            "weight_log": [-2.529918185, -2.124445553, -1.828663165, -1.529786058, -1.242525954, -0.9826332567, -0.7410212044, -0.4994982315, -0.260238953, -0.03432615921, 0.1790458313, 0.3902777234, 0.6073510605, 0.8301848354, 0.9577214119],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 7.6906e-08,
            "max_err": 2.4408e-07,
        },
    },
    (0.5, 1000): {
        5: {
            "grid_log": [-2.141933872, -1.130462121, -0.4198380307, 0.1602391233, 0.6721994873],
            "weight_log": [-1.651169131, -0.8591923227, -0.2569202816, 0.2525890211, 0.7328088128],
            "weight_sgn": [1, 1, 1, 1, 1],
            "L2_err": 8.9646e-04,
            "max_err": 3.6300e-03,
        },
        6: {
            "grid_log": [-2.46656468, -1.477871714, -0.7798944275, -0.2080702904, 0.2892315188, 0.7458235705],
            "weight_log": [-1.984119229, -1.21588992, -0.623468033, -0.1225795209, 0.3242280442, 0.7643250616],
            "weight_sgn": [1, 1, 1, 1, 1, 1],
            "L2_err": 3.4038e-04,
            "max_err": 1.4825e-03,
        },
        7: {
            "grid_log": [-2.654583644, -1.724011285, -1.062754423, -0.5131328964, -0.03202693658, 0.4028991942, 0.8141120125],
            "weight_log": [-2.192099764, -1.48909935, -0.9260705161, -0.4427128195, -0.01185250248, 0.3863996012, 0.7933123283],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1],
            "L2_err": 1.2365e-04,
            "max_err": 5.6806e-04,
        },
        8: {
            "grid_log": [-2.776939975, -1.901898542, -1.2849329, -0.7646937743, -0.304305859, 0.1135646032, 0.5010618569, 0.8756300503],
            "weight_log": [-2.331338148, -1.699208828, -1.175124592, -0.7153241586, -0.3016838197, 0.07861775115, 0.4392756867, 0.8192359317],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 4.4203e-05,
            "max_err": 2.1161e-04,
        },
        9: {
            "grid_log": [-2.868033786, -2.035023372, -1.461059957, -0.9729962473, -0.536205957, -0.1365672091, 0.234652578, 0.5852655417, 0.9301594711],
            "weight_log": [-2.43361206, -1.862759815, -1.381499717, -0.948696923, -0.5543830335, -0.1898124676, 0.1525253465, 0.4837786972, 0.841964583],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 1.5645e-05,
            "max_err": 7.9089e-05,
        },
        10: {
            "grid_log": [-2.940970083, -2.137355626, -1.600806778, -1.144679433, -0.7327939934, -0.3527904162, 0.002226689152, 0.3373838477, 0.6584408518, 0.9788453339],
            "weight_log": [-2.513575583, -1.99059065, -1.551982903, -1.147880487, -0.7744921742, -0.426409442, -0.09864656733, 0.2140348263, 0.5217362344, 0.8620598777],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 5.5012e-06,
            "max_err": 2.9775e-05,
        },
        11: {
            "grid_log": [-2.987934083, -2.200191521, -1.687271773, -1.253901902, -0.861328637, -0.4982743103, -0.1593247574, 0.1592706998, 0.4611030471, 0.7514970935, 1.043874483],
            "weight_log": [-2.564106623, -2.068951649, -1.660628141, -1.278778981, -0.9231562347, -0.591621035, -0.2809636622, 0.01222850814, 0.2930274731, 0.57266818, 0.8902159769],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 2.0370e-06,
            "max_err": 8.6320e-06,
        },
        12: {
            "grid_log": [-3.013531245, -2.233658429, -1.73406301, -1.315692839, -0.9373909238, -0.5870955973, -0.2592227723, 0.04977192332, 0.3422630276, 0.6203925166, 0.8882544585, 1.15849579],
            "weight_log": [-2.591405189, -2.110727269, -1.721530425, -1.35659529, -1.015154047, -0.695484701, -0.3946386247, -0.1102923529, 0.1591588726, 0.4171337571, 0.6744942437, 0.9720415772],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 1.0836e-06,
            "max_err": 5.5331e-06,
        },
        13: {
            "grid_log": [-3.043264589, -2.270841864, -1.783577996, -1.379295253, -1.014940462, -0.6772147949, -0.359518928, -0.05822148155, 0.2284712989, 0.5022052928, 0.7665197651, 1.031659845, 1.404852665],
            "weight_log": [-2.622791166, -2.155803071, -1.784532695, -1.436115694, -1.109120313, -0.8005954235, -0.5072161476, -0.2279964547, 0.03762443438, 0.2927923043, 0.5468861876, 0.8319667336, 1.941076997],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 5.7991e-07,
            "max_err": 3.1209e-06,
        },
        14: {
            "grid_log": [-3.106467772, -2.348250909, -1.888244198, -1.518200737, -1.186054479, -0.8752163279, -0.5804036756, -0.2986270759, -0.02805789211, 0.2317558622, 0.4817168192, 0.7245869953, 0.9653549767, 1.217119151],
            "weight_log": [-2.688996512, -2.249623669, -1.92293513, -1.616120097, -1.318520298, -1.032596205, -0.759017907, -0.4954756421, -0.2423399802, -0.0001797033243, 0.2345083942, 0.4683128462, 0.7123724965, 1.009719507],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 1.2158e-07,
            "max_err": 8.0588e-07,
        },
        15: {
            "grid_log": [-3.221174784, -2.461502579, -1.999582162, -1.627525451, -1.296707804, -0.9901378469, -0.6984948391, -0.4188869562, -0.1512141498, 0.1060236148, 0.3551824101, 0.5978160773, 0.8354841681, 1.074224403, 1.35647128],
            "weight_log": [-2.80324342, -2.361619359, -2.031748387, -1.724314864, -1.433925417, -1.153556046, -0.8805472628, -0.6196141612, -0.3706018242, -0.1289032498, 0.1078324208, 0.3397817121, 0.5713530044, 0.8252843843, 1.336388334],
            "weight_sgn": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
            "L2_err": 7.8697e-08,
            "max_err": 2.6638e-07,
        },
    },
}
  • 该字典为三层分级

    1. 积分有效区间 \((x_\min, x_\max)\)

    2. 格点数量 \(\tau\)

    3. 格点参数

使用该变量的方式是:

  • grid_log 是以 10 为底的格点对数 \(\log_{10} (\boldsymbol{t})\)

  • 示例:为获得积分区间是 \((0.1, 500)\)、格点数为 10 的格点坐标 \(\boldsymbol{t}\),使用下述程序可以给出:

t = 10**np.array(optimized_LT[(0.1, 500)][10]["grid_log"])
t
array([2.92721126e-03, 2.06253744e-02, 8.03194080e-02, 2.54496403e-01,
       7.11122272e-01, 1.81414536e+00, 4.31441950e+00, 9.71066315e+00,
       2.10061356e+01, 4.51216504e+01])
  • weight_log 是以 10 为底的权重绝对值对数 \(\log_10 (|\boldsymbol{w}|)\)

  • weight_sgn 是权重符号 \(\mathrm{sgn} (\boldsymbol{w})\)。尽管绝大多数情况下权重的符号都是正,但在我们优化的结果中,下述情形的符号有负的情况:

    • \((x_\min, x_\max) = (0.5, 100)\), \(\tau = 13, 15\)

    • \((x_\min, x_\max) = (0.5, 200)\), \(\tau = 14\)

  • 示例:为获得积分区间是 \((0.1, 500)\)、格点数为 10 的格点权重 \(\boldsymbol{w}\),使用下述程序可以给出:

w = optimized_LT[(0.1, 500)][10]["weight_sgn"] * 10**np.array(optimized_LT[(0.1, 500)][10]["weight_log"])
w
array([8.02966560e-03, 3.14911495e-02, 9.92012078e-02, 2.75559297e-01,
       6.95335779e-01, 1.63022356e+00, 3.60905672e+00, 7.65579007e+00,
       1.59484275e+01, 3.55989272e+01])
  • L2_err\(L_2^\mathrm{log}\) 误差值,如上文定义。

  • max_err 是积分有效区间内的最大绝对值误差 \(\max_{x \in [x_\min, x_\max]} | \mathrm{LT} (\boldsymbol{t}, \boldsymbol{w}, x) - 1 / x |\)

我们可以看一些优化数据的表现。下面的表格中,行头的第一行是积分下限 \(x_\min\)、第二行是积分上限 \(x_\max\);列头是格点数量 \(\tau\)。表格数据是误差数值 \(L_2^\mathrm{log}\)

对于积分上限确定为 \(x_\max = 500\) 的情况,我们会发现,随着 \(x_\min\) 的上升,为达成相同的精度,可以使用更少的格点数量。当 \(x_\min = 0.01\) 时需要 13 个格点才能达到 \(L_2^\mathrm{log} < 10^{-4}\);而 \(x_\min = 0.5\) 时,只需要 7 个格点即可达成,相当于可以节省一半的计算量。

Hide code cell source
l2_err_optimized_LT = {}
for (x_min, x_max) in optimized_LT:
    l2_err_optimized_LT[(x_min, x_max)] = {}
    for tau in optimized_LT[(x_min, x_max)]:
        l2_err_optimized_LT[(x_min, x_max)][tau] = optimized_LT[(x_min, x_max)][tau]["L2_err"]

df = pd.DataFrame(l2_err_optimized_LT)

df1 = df[[i for i in df.columns if i[1] == 500]]
df1.style.applymap(lambda v: "background-color: #E2F0D9;" if v < 1e-6 else
                             "background-color: #DAE3F3;" if 1e-6 <= v and v < 1e-5 else
                             "background-color: #FFFFFF;" if 1e-5 <= v and v < 1e-4 else
                             "background-color: #FFF2CC;" if 1e-4 <= v and v < 1e-3 else
                             "background-color: #FBE5D6;" if 1e-3 <= v else None) \
         .format("{:.2e}")
  0.010000 0.020000 0.050000 0.100000 0.200000 0.500000
  500 500 500 500 500 500
5 3.91e-02 2.02e-02 8.46e-03 4.38e-03 2.24e-03 8.48e-04
6 1.69e-02 8.74e-03 3.64e-03 1.85e-03 8.81e-04 2.86e-04
7 7.77e-03 3.99e-03 1.62e-03 7.65e-04 3.30e-04 9.41e-05
8 3.72e-03 1.88e-03 7.05e-04 3.05e-04 1.21e-04 3.06e-05
9 1.82e-03 8.78e-04 2.98e-04 1.20e-04 4.42e-05 9.87e-06
10 8.85e-04 3.98e-04 1.24e-04 4.68e-05 1.59e-05 3.16e-06
11 4.19e-04 1.78e-04 5.15e-05 1.82e-05 5.76e-06 1.48e-06
12 1.96e-04 7.98e-05 2.13e-05 6.99e-06 2.71e-06 7.58e-07
13 9.57e-05 3.59e-05 9.09e-06 2.71e-06 1.16e-06 3.28e-07
14 5.69e-05 1.93e-05 4.32e-06 1.17e-06 4.33e-07 9.85e-08
15 4.50e-05 1.33e-05 2.10e-06 5.89e-07 2.11e-07 7.69e-08

随后我们看到积分下限确定为 \(x_\min = 0.1\) 时,随着 \(x_\max\) 的增大,尽管达到相同精度所需要的格点数确实增加,但增加的数量一般不大。处理 \(x_\min = 0.1\) 的情形一般需要 10 个格点。

Hide code cell source
df1 = df[[i for i in df.columns if i[0] == 0.1]]
df1.style.applymap(lambda v: "background-color: #E2F0D9;" if v < 1e-6 else
                             "background-color: #DAE3F3;" if 1e-6 <= v and v < 1e-5 else
                             "background-color: #FFFFFF;" if 1e-5 <= v and v < 1e-4 else
                             "background-color: #FFF2CC;" if 1e-4 <= v and v < 1e-3 else
                             "background-color: #FBE5D6;" if 1e-3 <= v else None) \
         .format("{:.2e}")
  0.100000
  100 200 500 1000
5 4.24e-03 4.48e-03 4.38e-03 4.23e-03
6 1.43e-03 1.70e-03 1.85e-03 1.82e-03
7 4.71e-04 6.18e-04 7.65e-04 8.11e-04
8 1.53e-04 2.21e-04 3.05e-04 3.52e-04
9 4.93e-05 7.82e-05 1.20e-04 1.49e-04
10 1.58e-05 2.75e-05 4.68e-05 6.21e-05
11 5.17e-06 9.81e-06 1.82e-05 2.57e-05
12 2.01e-06 3.91e-06 6.99e-06 1.07e-05
13 1.03e-06 1.70e-06 2.71e-06 4.35e-06
14 5.52e-07 1.20e-06 1.17e-06 1.85e-06
15 5.21e-07 7.66e-07 5.89e-07 9.87e-07

GGA 的 Kohn-Sham 交换相关势函数导出#

创建时间:2020-05-21

这篇文档将会以 PBE 为例,求取 (pure,不包含 exact exchange 部分) 闭壳层 (Restricted) GGA (Generalized Gradient Approximation) 的交换相关势函数 \(v_\mathrm{xc} (\boldsymbol{r})\)。这篇文档的一些记号会倾向于 pyxdh 文档 [1],但所有与泛函核或其导数有关的量,不乘以格点权重。

%matplotlib notebook
from pyscf import gto, dft
import numpy as np
from functools import partial
from matplotlib import pyplot as plt

np.einsum = partial(np.einsum, optimize=["greedy", 1024 ** 3 * 4 / 8])
np.set_printoptions(5, linewidth=150, suppress=True)

Slater 交换势函数导出#

在讨论 GGA 的交换相关势前,我们讨论一下比较简单的情形,即 LDA (Local Density Approximation) 的 Slater 泛函。

我们知道,对于最为简单的交换相关泛函,Slater 泛函 (尽管它不包含相关部分,但从实现角度上讲是类似的),其泛函的形式为 (参考 Wikipedia 页面 [2])

\[ E_\mathrm{x}^{\mathsf{Slater}}[\rho] = - \frac{3}{4}\left( \frac{3}{\pi} \right)^{1/3} \int\rho(\boldsymbol{r})^{4/3} \, \mathrm{d} \boldsymbol{r} \]

因此,其交换势则可以推导得到

\[ v_\mathrm{x}^\mathsf{Slater} (\boldsymbol{r}) = \frac{\delta \, E_\mathrm{x}^{\mathsf{Slater}}[\rho]}{\delta \, \rho(\boldsymbol{r})} = - \left( \frac{3}{\pi} \right)^{1/3} \rho(\boldsymbol{r})^{1/3} \]

但 Slater 泛函是一种 LDA (Local Density Approximation),其交换相关 (尽管没有相关部分) 势可以很容易地直接通过泛函的变分获得;从程序上来讲,在我所习惯的记号下,也可以容易地从 (不加权重的) \(f_{\rho}\) 得到。我们以 Ne 原子与 6-311G 基组为例,以 Slater 泛函下的自洽场密度,绘制其径向格点:

ni = dft.numint.NumInt()
mol = gto.Mole()
mol.atom = """Ne"""
mol.basis = "6-311G"
mol.verbose = 0
mol.build()
<pyscf.gto.mole.Mole at 0x7f0d5472c280>
grids = dft.Grids(mol).build()
mf = dft.RKS(mol)
mf.xc = "Slater"
mf.grids = grids
mf.run()
<pyscf.dft.rks.RKS at 0x7f0d23574a30>
  • rad_ngrid:径向格点数量,用于作图

  • rad_x:径向格点的 \(x\) 坐标分量 (格点到原子核间距离),生成方式为从 \(10^{-3}\)\(10^{1}\) 的长度为 1000 的等比数列

  • rad_coord:径向格点的三维坐标

rad_ngrid = 1000
rad_x = np.logspace(-3, 1, rad_ngrid)
rad_coord = np.array([rad_x, np.zeros(rad_ngrid), np.zeros(rad_ngrid)]).T
  • rad_occ_0 \(\phi_i (\boldsymbol{r}_g)\) 分子轨道 \(i\) 函数在格点上的数值,通过 \(\phi_i = \sum_\mu \phi_\mu C_{\mu i}\) 得到

  • rad_rho_0 \(\rho (\boldsymbol{r}_g)\),但该密度只表示在径向 \(\boldsymbol{r}_g = (x_g, 0, 0)\) 上的格点,与 rho_0 尽管物理意义相同,但后者的格点是全空间的,可以被积分的格点

rad_ao_0 = ni.eval_ao(mol, rad_coord, deriv=0)
rad_rho_0 = ni.eval_rho(mol, rad_ao_0, mf.make_rdm1())

rad_xc 表示从 PySCF 的程序得到的 \(f_\mathrm{\rho}\),其定义为

\[ f_{\rho} = \frac{\partial (f[\rho] \rho)}{\partial \rho} \]

上式中的 \(f[\rho]\) 表示的是泛函核,其定义可以从下式看出

\[ E_\mathrm{xc} [\rho] = \int f[\rho] \rho(\boldsymbol{r}) \, \mathrm{d} \boldsymbol{r} \]

因此,对于 LDA 而言,

\[ f_{\rho} (\boldsymbol{r}) = \frac{\delta \, E_\mathrm{xc} [\rho]}{\delta \, \rho (\boldsymbol{r})} = v_\mathrm{xc} (\boldsymbol{r}) \]

上述记号有时会引起混淆,譬如说 \(f_{\rho}\) 可以作为关于 \(\boldsymbol{r}\) 的函数,此时的意义就与交换相关势函数 \(v_\mathrm{xc} (\boldsymbol{r})\) 一致;也可以作为关于 \(\rho\) 的泛函,因为对于不同的密度而言,\(f_{\rho}\) 并不相同;以及在程序中,\(f_{\rho}\) 是一种格点,因此它可能还用于求取格点积分。有时,我们也会将泛函核 \(f[\rho]\) 简写成 \(f\)。这些不同的符号会在不同语境下,起到不同作用;读者或许需要对这些问题留个心眼。当然,程序的结果是确实的、能用于验证的。

我们再用 rad_xc_anal_expression,拿 Slater 交换相关势的精确表达式,来用于验证我们通过 PySCF 程序所给出的交换相关势确实是正确的:

rad_xc = ni.eval_xc("Slater", rad_rho_0, deriv=1)[1][0]
rad_xc_anal_expression = - (3 / np.pi)**(1 / 3) * rad_rho_0**(1 / 3) 

下面我们就可以绘制 Slater 交换相关势了。图中所述的复现图片,是指 Gaiduk, Staroverov et al. [3] 的文章的 Figure 1。

fig, ax = plt.subplots(figsize=(4, 3))
ax.plot(rad_x, rad_xc, label="PySCF approach")
ax.plot(rad_x, rad_xc_anal_expression, linestyle=":", linewidth=5, label="anal expression")
ax.set_xscale("log"), ax.legend(loc="upper left")
ax.set_xlim(1e-3, 1e1), ax.set_ylim(-9, 1)
ax.set_xlabel(r"$r$ (Bohr)")
ax.set_ylabel(r"$v_\mathrm{x}^\mathsf{Slater} (r)$ (Hartree)")
ax.set_title("Reproduction of Figure 1")
fig.tight_layout()

PBE 交换相关势的导出#

交换相关势矩阵的验证#

但相同的做法不能在 PBE 泛函 (或者一般地,pure GGA 泛函) 中使用。对于量化中所会使用到的 \(v_{\mu \nu}^\mathrm{xc} [\rho] = \langle \mu | \hat v_\mathrm{xc} | \nu \rangle\) 交换相关势矩阵,我们曾经在 pyxdh 文档 [4] 任务 (3.3) 中作过较为细致的推导,这里给出其表达式:

\[\begin{split} \begin{align} v_{\mu \nu}^\mathrm{xc} [\rho] &= \sum_r \int \big[ f_\rho \phi_\mu \phi_\nu + 2 f_\gamma \rho_r (\phi_{r \mu} \phi_{\nu} + \phi_{\mu} \phi_{r \nu}) \big] \, \mathrm{d} \boldsymbol{r} \\ &= \langle \mu | f_\rho | \nu \rangle + 2 \sum_r \langle \mu_r | f_\gamma \rho_r | \nu \rangle + 2 \sum_r \langle \mu | f_\gamma \rho_r | \nu_r \rangle \end{align} \end{split}\]

其中,上式的

\[ \begin{align} f_\gamma = \frac{\partial (f \rho)}{\partial \gamma}, \quad \rho_r = \frac{\partial \rho}{\partial r}, \quad \mu_r = \frac{\partial \mu}{\partial r} \end{align} \]

上式中的斜体 (而非粗斜体) \(r\) 或之后会出现的 \(w\) 代表三维电子坐标 \(\boldsymbol{r}\) 的一个坐标分量。如果用较为普通的写法的话,则有

\[ v_{\mu \nu}^\mathrm{xc} [\rho] = \langle \mu | f_\rho | \nu \rangle + 2 \langle \nabla \mu | f_\gamma \nabla \rho | \nu \rangle + 2 \langle \mu | f_\gamma \nabla \rho | \nabla \nu \rangle \]

上式的两个 \(\nabla\) 梯度记号都使用点乘内积作计算。我们不妨先用程序验证一下上式的正确性。我们首先重新用 PBE 泛函计算一遍自洽场过程:

mf = dft.RKS(mol)
mf.xc = "PBE"
mf.grids = grids
mf.run()
<pyscf.dft.rks.RKS at 0x7f0d221aaac0>
  • C \(C_{\mu p}\) 分子轨道系数矩阵

  • D \(D_{\mu \nu}\) 电子态密度矩阵

C = mf.mo_coeff
D = mf.make_rdm1()
  • ao_0 \(\phi_\mu\), ao_1 \(\phi_{r \mu} = \partial_r \phi_\mu\), ao_2 \(\phi_{rw \mu} = \partial_r \partial_w \phi_\mu\) 为原子轨道函数格点与其导数

ao = ni.eval_ao(mol, grids.coords, deriv=2)
ao_0, ao_1 = ao[0], ao[1:4]
ao_2 = np.array([
    [ao[4], ao[5], ao[6]],
    [ao[5], ao[7], ao[8]],
    [ao[6], ao[8], ao[9]],
])
  • rho_0 \(\rho\), rho_1 \(\rho_r = \partial_r \rho\), rho_2 \(\rho_{rw} = \partial_r \partial_w \rho\) 为电子态密度格点

rho_0 = np.einsum("gu, gv, uv -> g", ao_0, ao_0, D)
rho_1 = 2 * np.einsum("rgu, gv, uv -> rg", ao_1, ao_0, D)
rho_2 = (
    + 2 * np.einsum("rwgu, gv, uv -> rwg", ao_2, ao_0, D)
    + 2 * np.einsum("rgu, wgv, uv -> rwg", ao_1, ao_1, D)
)
  • gamma_0 \(\gamma = \nabla \rho \cdot \nabla \rho\), gamma_1 \(\gamma_r = \partial_r \gamma\)

gamma_0 = np.einsum("rg, rg -> g", rho_1, rho_1)
gamma_1 =  2 * np.einsum("rwg, wg -> rg", rho_2, rho_1)
  • fr \(f_\rho\), fg \(f_\gamma\), frr \(f_{\rho \rho}\), frg \(f_{\rho \gamma}\), fgg \(f_{\gamma \gamma}\) 表示对泛函核与密度乘积 \(f \rho\) 的求导,下角标表示被求导对象

_, vxc, fxc, _ = ni.eval_xc("PBE, PBE", np.concatenate([rho_0[None, :], rho_1]), deriv=2)
fr, fg = vxc[:2]
frr, frg, fgg = fxc[:3]

那么,交换相关矩阵 \(v_{\mu \nu}^\mathrm{xc}\) 可以用 Vxc 表述:

Vxc = (
    + np.einsum("g, g, gu, gv -> uv", grids.weights, fr, ao_0, ao_0)
    + 2 * np.einsum("g, g, rg, rgu, gv -> uv", grids.weights, fg, rho_1, ao_1, ao_0)
    + 2 * np.einsum("g, g, rg, gu, rgv -> uv", grids.weights, fg, rho_1, ao_0, ao_1)
)

验证 Vxc \(v_{\mu \nu}^\mathrm{xc}\) 的方法,可以是生成分子轨道下的 Fock 矩阵。我们知道,Canonical RKS 的 Fock 矩阵是对角化的,我们就用这样一个性质来验证:

F = (
    + np.einsum("uv, up, vq -> pq", mol.intor("int1e_kin"), C, C)
    + np.einsum("uv, up, vq -> pq", mol.intor("int1e_nuc"), C, C)
    + np.einsum("uvkl, kl, up, vq -> pq", mol.intor("int2e"), D, C, C)
    + np.einsum("uv, up, vq -> pq", Vxc, C, C)
)

出于文档美观考虑,我们只显示出占据轨道部分的 Fock 矩阵;读者若自己执行代码,不难验证其余部分也遵循对角化矩阵。

F[:5, :5]
array([[-30.44674,  -0.     ,   0.     ,   0.     ,   0.     ],
       [ -0.     ,  -1.30498,  -0.     ,  -0.     ,  -0.     ],
       [  0.     ,  -0.     ,  -0.46122,  -0.     ,   0.     ],
       [  0.     ,  -0.     ,  -0.     ,  -0.46122,   0.     ],
       [ -0.     ,  -0.     ,  -0.     ,   0.     ,  -0.46122]])

交换相关势作为径向函数的导出#

我们再回顾一下交换相关势的计算:

\[ v_{\mu \nu}^\mathrm{xc} [\rho] = \langle \mu | f_\rho | \nu \rangle + 2 \langle \nabla \mu | f_\gamma \nabla \rho | \nu \rangle + 2 \langle \mu | f_\gamma \nabla \rho | \nabla \nu \rangle \]

但从上式,从上式我们或许可以不严谨地写

\[ \hat v_\mathrm{xc} = f_\rho + 2 f_\gamma \nabla \rho \cdot \nabla + 2 \nabla {\square} \cdot f_\gamma \nabla \rho \]

但我们其实不能直接地从上述表达式获得 \(v_\mathrm{xc} (\boldsymbol{r})\) 的信息。为此,我们需要将上式重新整理为

\[ v_{\mu \nu}^\mathrm{xc} [\rho] = \langle \mu | f_\rho | \nu \rangle + 2 \langle f_\gamma \nabla \rho | \nabla (\mu \nu) \rangle \]

对于上式等式右的第二项,我们可以通过 Gauss 定理 (类似于分部积分) 以及波函数本身无穷远处的性质,转化为

\[\begin{split} \begin{align} 2 \langle f_\gamma \nabla \rho | \nabla (\mu \nu) \rangle &= 2 \int f_\gamma \nabla \rho \cdot \nabla (\phi_\mu \phi_\nu) \, \mathrm{d} \boldsymbol{r} \\ &= 2 \int_{\Sigma} \nabla \cdot (\phi_\mu \phi_\nu f_\gamma \nabla \rho) \, \mathrm{d} \boldsymbol{\Sigma} - 2 \int \phi_\mu \phi_\nu \nabla \cdot (f_\gamma \nabla \rho) \, \mathrm{d} \boldsymbol{r} \\ &= 0 - 2 \int \phi_\mu \phi_\nu \big[ f_{\rho \gamma} \nabla \rho \cdot \nabla \rho + f_{\gamma \gamma} \nabla \gamma \cdot \nabla \rho + f_\gamma \nabla^2 \rho \big] \, \mathrm{d} \boldsymbol{r} \end{align} \end{split}\]

上式中,\(\Sigma\) 所指代的曲面是一个延伸至无穷远的单连通曲面。根据波函数的特性,上式中包含 \(\mathrm{d} \boldsymbol{\Sigma}\) 的第二类曲面积分的一项值为零。

我们可以利用上式的结论,生成 Vxc_,并与 Vxc 作验证,检查是否正确:

Vxc_ = (
    + np.einsum("g, gu, gv, g -> uv", grids.weights, ao_0, ao_0, fr)
    - 2 * np.einsum("g, gu, gv, g, rg, rg -> uv", grids.weights, ao_0, ao_0, frg, rho_1, rho_1)
    - 2 * np.einsum("g, gu, gv, g, rg, rg -> uv", grids.weights, ao_0, ao_0, fgg, gamma_1, rho_1)
    - 2 * np.einsum("g, gu, gv, g, gr -> uv", grids.weights, ao_0, ao_0, fg, rho_2.diagonal())
)
np.allclose(Vxc, Vxc_)
True

因此,上述的推导与程序印证就成立了。我们可以写

\[\begin{split} \begin{align} v_{\mu \nu}^\mathrm{xc} [\rho] &= \langle \mu | f_\rho | \nu \rangle + 2 \langle \nabla \mu | f_\gamma \nabla \rho | \nu \rangle + 2 \langle \mu | f_\gamma \nabla \rho | \nabla \nu \rangle \\ &= \langle \mu | f_\rho - 2 f_{\rho \gamma} \nabla \rho \cdot \nabla \rho - 2 f_{\gamma \gamma} \nabla \gamma \cdot \nabla \rho - 2 f_\gamma \nabla^2 \rho | \nu \rangle \end{align} \end{split}\]

因此,现在才能自然地写到,交换相关势作为关于电子坐标的函数,可以写为

\[ \hat v_\mathrm{xc} = v_\mathrm{xc} (\boldsymbol{r}) = f_\rho - 2 f_{\rho \gamma} \nabla \rho \cdot \nabla \rho - 2 f_{\gamma \gamma} \nabla \gamma \cdot \nabla \rho - 2 f_\gamma \nabla^2 \rho \]

需要注意到,这是对于 GGA 的情况。对于 LDA (就像一开始讨论的 Slater 泛函),由于 \(f \rho\) 并非是关于 \(\gamma = \nabla \rho \cdot \nabla \rho\) 的泛函,因此自然地会退化为 \(v_\mathrm{xc}^\mathsf{GGA} (\boldsymbol{r}) = f_\rho\)

Ne 原子 6-311G 基组下的 PBE 交换相关势:图像绘制#

既然我们上面已经验证过交换相关势求取的正确性与可行性,下面我们就不妨对距离 Ne 原子 \(10^{-2}\)\(10^{1}\) Bohr 处的交换相关势作直接的绘图。下面的代码不再多作说明,其意义相信上文已经有足够多的描述了。

rad_ao = ni.eval_ao(mol, rad_coord, deriv=2)
rad_ao_0, rad_ao_1 = rad_ao[0], rad_ao[1:4]
rad_ao_2 = np.array([
    [rad_ao[4], rad_ao[5], rad_ao[6]],
    [rad_ao[5], rad_ao[7], rad_ao[8]],
    [rad_ao[6], rad_ao[8], rad_ao[9]],
])
rad_rho_0 = np.einsum("gu, gv, uv -> g", rad_ao_0, rad_ao_0, D)
rad_rho_1 = 2 * np.einsum("rgu, gv, uv -> rg", rad_ao_1, rad_ao_0, D)
rad_rho_2 = (
    + 2 * np.einsum("rwgu, gv, uv -> rwg", rad_ao_2, rad_ao_0, D)
    + 2 * np.einsum("rgu, wgv, uv -> rwg", rad_ao_1, rad_ao_1, D)
)
rad_gamma_0 = np.einsum("rg, rg -> g", rad_rho_1, rad_rho_1)
rad_gamma_1 =  2 * np.einsum("rwg, wg -> rg", rad_rho_2, rad_rho_1)
_, rad_vxc, rad_fxc, _ = ni.eval_xc("PBE, PBE", np.concatenate([rad_rho_0[None, :], rad_rho_1]), deriv=2)
rad_fr, rad_fg = rad_vxc[:2]
rad_frr, rad_frg, rad_fgg = rad_fxc[:3]
rad_xc = (
    + rad_fr
    - 2 * np.einsum("g, rg, rg -> g", rad_frg, rad_rho_1, rad_rho_1)
    - 2 * np.einsum("g, rg, rg -> g", rad_fgg, rad_gamma_1, rad_rho_1)
    - 2 * np.einsum("g, gr -> g", rad_fg, rad_rho_2.diagonal())
)
rad_xc.shape
(1000,)

最后,我们就可以对其进行绘制。图中所述的复现图片,是指 Gaiduk, Staroverov et al. [3] 的 Figure 5。

fig, ax = plt.subplots(figsize=(3, 4))
ax.plot(rad_x, rad_xc)
ax.set_xscale("log")
ax.set_xlim(1e-2, 1e1), ax.set_ylim(-11, 1)
ax.set_xlabel(r"$r$ (Bohr)")
ax.set_ylabel(r"$v_\mathrm{x}^\mathsf{PBE} (r)$ (Hartree)")
ax.set_title("Reproduction of Figure 5")
fig.tight_layout()

通过密度反推 Kohn-Sham 势函数:消除 Gaussian 基组扰动现象#

创建时间:2020-05-22

我们这里会尝试学习与复现下述 Gaiduk, Staroverov et al. [1] 文章的一些结果。

这篇文章比较接近一篇技术讨论文章。在 KS (Kohn-Sham) 框架下,以及有了 KS 轨道能与轨道函数的情况下,我们能反推其 KS 势函数 \(v_\mathrm{xc} (\boldsymbol{r})\)。但若使用 Gaussian 基组反推 \(v_\mathrm{xc} (\boldsymbol{r})\),则会出现严重的振荡。这篇文章希望能解决这种振荡现象。

准备工作#

这篇文档统一使用原子单位。文档统一使用 RKS (Restricted Kohn-Sham),即占据轨道的占据数统一为 2。因此,一些公式上可能在细节上,与 Gaiduk 文章细微出入。

%matplotlib notebook

from pyscf import gto, scf, dft, lib
import numpy as np
from functools import partial
import warnings
from matplotlib import pyplot as plt

np.einsum = partial(np.einsum, optimize=["greedy", 1024 ** 3 * 4 / 8])
np.allclose = partial(np.allclose, atol=1e-6, rtol=1e-4)
np.set_printoptions(5, linewidth=150, suppress=True)
warnings.filterwarnings("ignore")

ni 是 PySCF 中用于进行格点积分的实用的变量。其使用方式可以参考 pyxdh 文档 [2]

ni = dft.numint.NumInt()

在文章初期,我们讨论的分子始终是单个 Ne 原子的 6-311G 基组。

mol = gto.Mole()
mol.atom = """Ne"""
mol.basis = "6-311G"
mol.verbose = 0
mol.build()
<pyscf.gto.mole.Mole at 0x7f5525aad0a0>

对于当前分子,我们使用的格点 grids 会稍许特别一些。我们使用角向密度相当高的格点 (100, 5810)。同时,我们对格点的坐标与权重作缩放,使得格点相对来说比较富集在院子周围。之所以设定的角向格点密度相当高,是因为我们需要保证 Hartree 势能 \(v_\mathrm{H} (\boldsymbol{r})\) 的精度。之后我们会说明。

grids = dft.Grids(mol)
grids.atom_grid = (100, 5810)
grids.prune = None
grids.build()
grids.weights.size
581000

作为对比,我们会用 grids_coarse 来表示较低的角向密度格点。尽管该格点 (99, 590) 通常情况下足够应对量化计算了,但我们仍然会指出该格点大小的不足。

grids_coarse = dft.Grids(mol)
grids_coarse.atom_grid = (99, 590)
grids_coarse.build()
<pyscf.dft.gen_grid.Grids at 0x7f5525aa72e0>

我们在开始讨论的时候,会仅使用 Slater 交换能进行计算。等到后面讨论时,我们再作更多其它泛函的说明。

mf = dft.RKS(mol)
mf.xc = "Slater"
mf.grids = grids
mf.run()
<pyscf.dft.rks.RKS at 0x7f54f3d67eb0>

我们定义下述变量:

  • nmo \(n_\mathrm{MO}\), nao \(n_\mathrm{AO}\):分子轨道数量或原子轨道数量

  • nocc \(n_\mathrm{occ}\):占据轨道数量

  • natm \(n_\mathrm{atom}\):原子数量

  • so:占据轨道分割,用于程序的编写

nmo, nao, nocc, natm = mol.nao, mol.nao, mol.nelec[0], mol.natm
so = slice(0, nocc)
  • D \(D_{\mu \nu}\):密度矩阵

  • C \(C_{\mu p}\), Co \(C_{\mu i}\):系数轨道矩阵

  • e \(\varepsilon_p\), eo \(\varepsilon_i\):Canonical Kohn-Sham 轨道能

D, C, e = mf.make_rdm1(), mf.mo_coeff, mf.mo_energy
Co, eo = C[:, so], e[so]
  • rho_0 \(\rho(\boldsymbol{r}_g)\):电子态密度,其中 \(g\) 代表格点。我们这里不采用 pyxdh 文档 [2] 的记号方式,并且在出现格点积分时会明确地写出格点权重 \(w_g\)

ao_0 = ni.eval_ao(mol, grids.coords, deriv=0)
rho_0 = ni.eval_rho(mol, ao_0, D)
  • rad_ngrid:径向格点数量,用于作图

  • rad_x:径向格点的 \(x\) 坐标分量 (格点到原子核间距离),生成方式为从 \(10^{-3}\)\(10^{1}\) 的长度为 1000 的等比数列

  • rad_coord:径向格点的三维坐标

rad_ngrid = 1000
rad_x = np.logspace(-3, 1, rad_ngrid)
rad_coord = np.array([rad_x, np.zeros(rad_ngrid), np.zeros(rad_ngrid)]).T

以上述的径向格点 (即 \(\boldsymbol{r}_g = (x_g, 0, 0)\)),我们可以生成原子轨道函数的数值大小。

  • rad_ao_0 \(\phi_{\mu} (\boldsymbol{r}_g)\) 原子轨道 \(\mu\) 函数在格点上的数值

  • rad_ao_2 \(\phi_{ts} (\boldsymbol{r}_g)\) 原子轨道 \(\mu\) 函数的二阶导数 \(\partial_t \partial_s \phi_{\mu} (\boldsymbol{r}_g)\) 在格点上的数值,其中 \(t, s\) 代表电子坐标的分量

rad_ao = ni.eval_ao(mol, rad_coord, deriv=2)
rad_ao_0 = rad_ao[0]
rad_ao_2 = np.array([
    [rad_ao[4], rad_ao[5], rad_ao[6]],
    [rad_ao[5], rad_ao[7], rad_ao[8]],
    [rad_ao[6], rad_ao[8], rad_ao[9]],
])
  • rad_occ_0 \(\phi_i (\boldsymbol{r}_g)\) 分子轨道 \(i\) 函数在格点上的数值,通过 \(\phi_i = \sum_\mu \phi_\mu C_{\mu i}\) 得到

  • rad_occ_2 \(\phi_{tsi} (\boldsymbol{r}_g)\),等价于 \(\partial_t \partial_s \phi_i (\boldsymbol{r}_g)\)

  • rad_rho_0 \(\rho (\boldsymbol{r}_g)\),但该密度只表示在径向 \(\boldsymbol{r}_g = (x_g, 0, 0)\) 上的格点,与 rho_0 尽管物理意义相同,但后者的格点是全空间的,可以被积分的格点

rad_occ_0 = np.einsum("gu, ui -> gi", rad_ao_0, Co)
rad_occ_2 = np.einsum("tsgu, ui -> tsgi", rad_ao_2, Co)
rad_rho_0 = np.einsum("gu, gv, uv -> g", rad_ao_0, rad_ao_0, D)

逆向求取 Kohn-Sham 交换相关势#

我们从 Kohn-Sham 方程出发:

\[ \left[ - \frac{1}{2} \nabla^2 + v_\mathrm{eff} (\boldsymbol{r}) \right] \phi_i (\boldsymbol{r}) = \varepsilon_i \phi_i (\boldsymbol{r}) \]

对上式的左边乘上 \(\phi_i (\boldsymbol{r})\),得到 (我们这里始终在实空间下讨论)

\[ - \frac{1}{2} \phi_i (\boldsymbol{r}) \nabla^2 \phi_i (\boldsymbol{r}) + v_\mathrm{eff} (\boldsymbol{r}) \phi_i^2 (\boldsymbol{r}) = \varepsilon_i \phi_i^2 (\boldsymbol{r}) \]

留意到,若对于 RKS (Restricted Kohn-Sham) 而言,每个占据轨道的占据数都是 2,因此

\[ \rho(\boldsymbol{r}) = \sum_{i} 2 \phi_i^2 (\boldsymbol{r}) \]
np.allclose(2 * (rad_occ_0**2).sum(axis=-1), rad_rho_0)
True

那么,我们对上上式的角标 \(i\) 求和并乘以 2,得到

\[ - \sum_i \phi_i (\boldsymbol{r}) \nabla^2 \phi_i (\boldsymbol{r}) + \rho(\boldsymbol{r}) v_\mathrm{eff} (\boldsymbol{r}) = \sum_i 2 \varepsilon_i \phi_i^2 (\boldsymbol{r}) \]

整理上式,得到

\[ v_\mathrm{eff} (\boldsymbol{r}) = \frac{1}{\rho(\boldsymbol{r})} \sum_{i} \left[ \phi_i (\boldsymbol{r}) \nabla^2 \phi_i (\boldsymbol{r}) + 2 \varepsilon_i \phi_i^2 (\boldsymbol{r}) \right] \]

从实现的角度上来讲,上式应写成 rad_veff \(v_\mathrm{eff} (\boldsymbol{r}_g)\)

\[ v_\mathrm{eff} (\boldsymbol{r}_g) = \frac{1}{\rho(\boldsymbol{r}_g)} \sum_{i} \left[ \sum_t \phi_i (\boldsymbol{r}_g) \phi_{ti} (\boldsymbol{r}_g) + 2 \varepsilon_i \phi_i^2 (\boldsymbol{r}_g) \right] \]
rad_veff = (
    + np.einsum("gi, git -> g", rad_occ_0, rad_occ_2.diagonal())
    + 2 * np.einsum("i, gi, gi -> g", eo, rad_occ_0, rad_occ_0)
) / rad_rho_0
fig, ax = plt.subplots(figsize=(4, 3))
ax.plot(rad_x, rad_veff)
ax.set_xscale("log")
ax.set_xlim(1e-3, 1e1)
ax.set_xlabel(r"$r$ (Bohr)")
ax.set_ylabel(r"$v_\mathrm{eff} (r)$ (Hartree)")
fig.tight_layout()

但上述势能并非交换相关势。根据交换相关势的定义,我们会写

\[ v_\mathrm{xc} (\boldsymbol{r}) = v_\mathrm{eff} (\boldsymbol{r}) - v_\mathrm{ext} (\boldsymbol{r}) - v_\mathrm{H} (\boldsymbol{r}) \]

其中,我们已经获得了有效势 rad_veff \(v_\mathrm{eff} (\boldsymbol{r}_g)\);外势 rad_vext \(v_\mathrm{ext} (\boldsymbol{r}_g)\) 被定义为电子与原子核的静电吸引作用:

\[ v_\mathrm{ext} (\boldsymbol{r}_g) = \sum_{A} \frac{- Z_A}{|\boldsymbol{r}_g - \boldsymbol{A}|} \]

其中,斜体的 \(A\) 代表分子中的原子,粗斜体的 \(\boldsymbol{A}\) 代表原子坐标。当然,我们现在考虑的分子还只是 Ne 原子,因此只有一个原子:

rad_vext = np.zeros_like(rad_veff)
for A in range(natm):
    rad_vext += - mol.atom_charge(A) / np.linalg.norm(rad_coord - mol.atom_coord(A), axis=-1)

Hartree 势 rad_vH \(v_\mathrm{H} (\boldsymbol{r}_g)\) 是电子之间相互静电排斥的势能,有时也会称为静电排斥势 (electrostatic potential) 或库伦排斥势 (Coulomb potential)。其定义为

\[ v_\mathrm{H} (\boldsymbol{r}) = \int \frac{\rho(\boldsymbol{r}')}{| \boldsymbol{r} - \boldsymbol{r}' |} \, \mathrm{d} \boldsymbol{r}' \]

现在,如果我们将沿着 \(x\) 轴的径向格点标记为角标 \(g\),全空间格点标记为角标 \(G\),那么上述连续积分可以写为格点积分

\[ v_\mathrm{H} (\boldsymbol{r}_g) = \sum_G w_G \frac{\rho(\boldsymbol{r}_G)}{| \boldsymbol{r}_g - \boldsymbol{r}_G |} \]
def get_vH_at_r(r):
    inverse_coords = np.copy(grids.coords)
    inverse_coords[:, 0] -= r
    return (grids.weights * rho_0 / np.linalg.norm(inverse_coords, axis=-1)).sum()

譬如说,如果现在我们考察距离原子 \(10^{-2}\) Bohr 处的 Hartree 势 \(v_\mathrm{H}\),那么我们可以通过下述代码给出:

get_vH_at_r(1e-2)
30.89707871844751

我们用下述的程序可以生成径向的 Hartree 势 rad_vH \(v_\mathrm{H}\)

rad_vH = np.array([get_vH_at_r(r) for r in rad_x])

我们之前提起过,为了要准确计算 Hartree 势,我们需要增大角向格点。如果不增大角向格点,譬如用 (99, 590) 格点 (对于一般的 DFT 计算而言是足够大的格点了),生成的 Hartree 势则记为 rad_vH_coarse

rho_0_coarse = ni.eval_rho(mol, ni.eval_ao(mol, grids_coarse.coords, deriv=0), D)
def get_vH_coarse_at_r(r):
    inverse_coords = np.copy(grids_coarse.coords)
    inverse_coords[:, 0] -= r
    return (rho_0_coarse / np.linalg.norm(inverse_coords, axis=-1) * grids_coarse.weights).sum()
rad_vH_coarse = np.array([get_vH_coarse_at_r(r) for r in rad_x])

我们绘制这两种不同的格点所生成的 Hartree 势:

fig, ax = plt.subplots(figsize=(4, 3))
ax.plot(rad_x, rad_vH_coarse, label="rad_vH_coarse")
ax.plot(rad_x, rad_vH, label="rad_vH")
ax.set_xscale("log"), ax.legend()
ax.set_xlim(1e-3, 1e1), ax.set_ylim(0, 35)
ax.set_xlabel(r"$r$ (Bohr)")
ax.set_ylabel(r"$v_\mathrm{H} (r)$ (Hartree)")
fig.tight_layout()

因此,对于当前的体系而言,我们不一定需要很高的径向格点,但一定需要很高的角向格点,才能描述好 Hartree 势。但事实上,即使是橙色的曲线,也有粗糙和突跃的地方。这很有可能表明计算 Hartree 势函数可能不适合用格点积分,而应当用离散傅里叶变换更为合适。但作者限于能力,暂时还不会使用离散傅里叶变换处理当前的问题。

# For Fourier transformation of this problem, the following html could be instructive. However, I haven't tried that out.
# https://www.tcm.phy.cam.ac.uk/~pdh1001/thesis/node39.html

那既然我们已经知道了外势与 Hartree 势,我们就可以求出交换相关势了 rad_vxc \(v_\mathrm{xc} (\boldsymbol{r}_g)\)

\[ v_\mathrm{xc} (\boldsymbol{r}_g) = v_\mathrm{eff} (\boldsymbol{r}_g) - v_\mathrm{ext} (\boldsymbol{r}_g) - v_\mathrm{H} (\boldsymbol{r}_g) \]
rad_vxc = rad_veff - rad_vext - rad_vH

或许读者会奇怪,我们上面所用的自洽场方法是 Slater 交换能,而没有使用到相关泛函。需要指出,如果自洽场中,用到的是交换相关泛函,那么上面的过程可以一样地处理。我们在这里可以用 PySCF 的程序给出 Slater 泛函的形式 rad_vx_slater \(v_\mathrm{x}^\mathsf{Slater} (\boldsymbol{r}_g)\),当然也可以通过 Slater 泛函的定义给出:

# PySCF approach: rad_vx_slater = ni.eval_xc("Slater", rad_rho_0, deriv=1)[1][0]
rad_vx_slater = - (3 / np.pi)**(1 / 3) * rad_rho_0**(1 / 3) 

原文的 Figure 1 对我们通过轨道、轨道能给出的外势、进而重构的交换相关势 (reconstructed) rad_vxc 与真实的交换相关势 (original) rad_vx_slater 做了比较。可以看出,我们重构的交换相关势的振荡相当严重。

fig, ax = plt.subplots(figsize=(5, 3.6))
ax.plot(rad_x, rad_vxc, label="reconstructed")
ax.plot(rad_x, rad_vx_slater, label="original")
ax.set_xscale("log"), ax.legend()
ax.set_xlim(1e-3, 1e1), ax.set_ylim(-9, 1)
ax.set_xlabel(r"$r$ (Bohr)")
ax.set_ylabel(r"$v_\mathrm{x}^\mathsf{Slater} (r)$ (Hartree)")
ax.set_title("Reproduction of Figure 1")
fig.tight_layout()

振荡的特性#

这样的振荡可以定量为

\[ \Delta v_\mathrm{osc} (\boldsymbol{r}) = v_\mathrm{xc}^\mathrm{reconstructed} (\boldsymbol{r}) - v_\mathrm{xc}^\mathrm{original} (\boldsymbol{r}) \]

这样的振荡是有规律可循的。我们定义下述函数,其输入 mol 是分子的实例,xc 是交换相关泛函的字符串表达;输出 rad_vxc_reconstructed 是通过 Gaiduk 文中所述过程得到的 (强烈振荡的) 交换相关势函数格点 \(v_\mathrm{xc}^\mathrm{reconstructed} (\boldsymbol{r}_g)\)rad_vxc_original 是真实的交换相关势函数格点 \(v_\mathrm{xc}^\mathrm{original} (\boldsymbol{r}_g)\)

PBE 的交换相关势函数的导出并非相当容易,它不像 LDA 型的泛函一样可以通过泛函核关于密度的偏导数直接获得。前一份文档 就是为了解决如何获取 PBE 精确的交换相关势而写的文档。

def gen_rad_xc(mol, xc, rad_x=None):
    ni = dft.numint.NumInt()       # DFT numerical integral engine
    # === Grid Build ===
    grids = dft.Grids(mol)         #
    grids.atom_grid = (100, 5810)  # set grid with radial 100, angular 5810
    grids.prune = None             # do not prune grid, i.e. don't change angular grid point size on the sphere too far or too close to neucleu
    grids.build()                  #
    # === SCF Instance ===
    mf = dft.RKS(mol)              #
    mf.xc = xc                     # set exchange-correlation code (like `Slater`, `PBE`) to SCF instance
    mf.grids = grids               # set grid to SCF instance
    mf.run()                       # run SCF instance to generate density, molecular orbital coefficient
    nmo, nao, nocc, natm = mol.nao, mol.nao, mol.nelec[0], mol.natm
    so = slice(0, nocc)
    D, C, e = mf.make_rdm1(), mf.mo_coeff, mf.mo_energy
    Co, eo = C[:, so], e[so]
    # === Grided Density Generation === (for computation, not for plot)
    ao_0 = ni.eval_ao(mol, grids.coords, deriv=0)
    rho_0 = ni.eval_rho(mol, ao_0, D)
    # === Radial Grid Generation ===
    rad_ngrid = 1000
    if rad_x is None:
        rad_x = np.logspace(-3, 1, rad_ngrid)
    else:
        rad_ngrid = rad_x.size
    rad_coord = np.array([rad_x, np.zeros(rad_ngrid), np.zeros(rad_ngrid)]).T
    # === Radial Grid Atomic Orbital and its Derivative ===
    rad_ao = ni.eval_ao(mol, rad_coord, deriv=2)
    rad_ao_0, rad_ao_1 = rad_ao[0], rad_ao[1:4]
    rad_ao_2 = np.array([
        [rad_ao[4], rad_ao[5], rad_ao[6]],
        [rad_ao[5], rad_ao[7], rad_ao[8]],
        [rad_ao[6], rad_ao[8], rad_ao[9]],
    ])
    # === Radial Grid Density, Gamma and their Derivative ===
    rad_rho_0 = np.einsum("gu, gv, uv -> g", rad_ao_0, rad_ao_0, D)
    rad_rho_1 = 2 * np.einsum("rgu, gv, uv -> rg", rad_ao_1, rad_ao_0, D)
    rad_rho_2 = (
        + 2 * np.einsum("rwgu, gv, uv -> rwg", rad_ao_2, rad_ao_0, D)
        + 2 * np.einsum("rgu, wgv, uv -> rwg", rad_ao_1, rad_ao_1, D)
    )
    rad_gamma_0 = np.einsum("rg, rg -> g", rad_rho_1, rad_rho_1)
    rad_gamma_1 =  2 * np.einsum("rwg, wg -> rg", rad_rho_2, rad_rho_1)
    # === Radial Grid Occupied Molecular Orbital and its Derivative ===
    rad_occ_0 = np.einsum("gu, ui -> gi", rad_ao_0, Co)
    rad_occ_2 = np.einsum("tsgu, ui -> tsgi", rad_ao_2, Co)
    # === Veff (generated from eq(2) in article) ===
    rad_veff = (
        + np.einsum("gi, git -> g", rad_occ_0, rad_occ_2.diagonal())
        + 2 * np.einsum("i, gi, gi -> g", eo, rad_occ_0, rad_occ_0)
    ) / rad_rho_0
    # === Vxc (generated from eq(3) in article) ===
    rad_vext = np.zeros_like(rad_veff)
    for A in range(natm):
        rad_vext += - mol.atom_charge(A) / np.linalg.norm(rad_coord - mol.atom_coord(A), axis=-1)
    def get_vH_at_r(r):
        inverse_coords = np.copy(grids.coords)
        inverse_coords[:, 0] -= r
        return (grids.weights * rho_0 / np.linalg.norm(inverse_coords, axis=-1)).sum()
    rad_vH = np.array([get_vH_at_r(r) for r in rad_x])
    rad_vxc_reconstructed = rad_veff - rad_vext - rad_vH
    # === Generate True Vxc ===
    _, rad_vxc, rad_fxc, _ = ni.eval_xc(xc, np.concatenate([rad_rho_0[None, :], rad_rho_1]), deriv=2)
    rad_fr, rad_fg = rad_vxc[:2]
    rad_frr, rad_frg, rad_fgg = rad_fxc[:3]
    rad_vxc_original = np.copy(rad_fr)
    if rad_fg is not None:
        rad_vxc_original += (
            - 2 * np.einsum("g, rg, rg -> g", rad_frg, rad_rho_1, rad_rho_1)
            - 2 * np.einsum("g, rg, rg -> g", rad_fgg, rad_gamma_1, rad_rho_1)
            - 2 * np.einsum("g, gr -> g", rad_fg, rad_rho_2.diagonal())
        )
    # === Return === 1. Vxc from eq(3); 2. True Potential
    return rad_vxc_reconstructed, rad_vxc_original

我们可以获得 Slater 泛函下的计算与精确的交换相关势:

rad_vxc_reconstructed_slater, rad_vxc_original_slater = gen_rad_xc(mol, "Slater")

我们也可以获得 PBE 泛函下的对应:

rad_vxc_reconstructed_pbe, rad_vxc_original_pbe = gen_rad_xc(mol, "PBE")

我们随后绘制 Slater 泛函所产生的振荡 \(\Delta v_\mathrm{osc}^\mathsf{Slater} (\boldsymbol{r})\) 与 PBE 泛函所产生的振荡 \(\Delta v_\mathrm{osc}^\mathsf{PBE} (\boldsymbol{r})\)。可以看出,这两者之间的振荡形状相当的接近,即

\[ \Delta v_\mathrm{osc}^\mathsf{Slater} (\boldsymbol{r}) \simeq v_\mathrm{osc}^\mathsf{PBE} (\boldsymbol{r}) \]
fig, ax = plt.subplots(figsize=(4, 3))
ax.plot(rad_x, rad_vxc_reconstructed_slater - rad_vxc_original_slater, label="Slater")
ax.plot(rad_x, rad_vxc_reconstructed_pbe - rad_vxc_original_pbe, label="PBE", linestyle="--")
ax.set_xscale("log"), ax.legend()
ax.set_xlim(1e-3, 1e1), ax.set_ylim(-9.4, 8)
ax.set_xlabel(r"$r$ (Bohr)")
ax.set_ylabel(r"$\Delta v_\mathrm{osc} (r)$ (Hartree)")
ax.set_title("Reproduction of Figure 3")
fig.tight_layout()

平滑振荡的有效势#

如果,我们现在假设我们不知道 PBE 交换相关泛函的真实构造,那么依据上面的结论,有

\[ \Delta v_\mathrm{osc}^\mathsf{Slater} (\boldsymbol{r}) \simeq v_\mathrm{osc}^\mathsf{PBE} (\boldsymbol{r}) = v_\mathrm{reconstructed}^\mathsf{PBE} (\boldsymbol{r}) - v_\mathrm{original}^\mathsf{PBE} (\boldsymbol{r}) \]

依据 Gaiduk 文章的 eq (8),定义

\[ v_\mathrm{corrected}^\mathsf{PBE} (\boldsymbol{r}) = v_\mathrm{reconstructed}^\mathsf{PBE} (\boldsymbol{r}) - \Delta v_\mathrm{osc}^\mathsf{Slater} (\boldsymbol{r}) \]

我们应当预期,\(v_\mathrm{corrected}^\mathsf{PBE} (\boldsymbol{r}) \simeq v_\mathrm{original}^\mathsf{PBE} (\boldsymbol{r})\)。下图就展示,这两者确实非常接近。

fig, ax = plt.subplots(1, 2, figsize=(6, 4))
ax[0].plot(rad_x, rad_vxc_original_pbe, label="Original")
ax[0].plot(rad_x, rad_vxc_reconstructed_pbe, label="Reconstructed", linestyle="--", linewidth=0.7)
ax[0].set_xscale("log"), ax[0].legend(loc="lower right")
ax[0].set_xlim(1e-2, 1e1), ax[0].set_ylim(-11, 1)
ax[0].set_xlabel(r"$r$ (Bohr)")
ax[0].set_ylabel(r"$v_\mathrm{xc}^\mathsf{PBE} (r)$ (Hartree)")
ax[0].set_title("Reproduction of Figure 5")
ax[1].plot(rad_x, rad_vxc_original_pbe, label="Original")
ax[1].plot(rad_x, rad_vxc_reconstructed_pbe - (rad_vxc_reconstructed_slater - rad_vxc_original_slater), label="Corrected", linestyle="--", linewidth=0.7)
ax[1].set_xscale("log"), ax[1].legend(loc="lower right")
ax[1].set_xlim(1e-2, 1e1), ax[1].set_ylim(-11, 1)
ax[1].set_xlabel(r"$r$ (Bohr)")
fig.tight_layout()

尽管平滑后的 \(v_\mathrm{corrected}^\mathsf{PBE} (\boldsymbol{r})\) 与真实的 \(v_\mathrm{original}^\mathsf{PBE} (\boldsymbol{r})\) 已经很接近了,但我们会发现,特别是在靠近核处的交换相关泛函,与真实值还是稍有差距。如果我们选用更大的基组 (或许是对靠近原子核部分描述更好的基组),譬如 UGBS 基组,那么平滑后与真实交换相关势会更为相近。我们将 UGBS 基组所生成的分子写为 mol_UGBS

mol_UGBS = gto.Mole()
mol_UGBS.atom = """Ne"""
with open("ugbs_Ar.txt", "r") as f:
    mol_UGBS.basis = gto.basis.parse(f.read())
mol_UGBS.verbose = 0
mol_UGBS.build()
<pyscf.gto.mole.Mole at 0x7f54f395da60>

下面我们就生成以 UGBS 基组构造的 Ne 分子生成的 Slater 与 PBE 的真实的以及反推演出来的交换相关势,并对 Gaiduk 文章的 Figure 6 进行绘制:

rad_vxc_reconstructed_slater, rad_vxc_original_slater = gen_rad_xc(mol_UGBS, "Slater")
rad_vxc_reconstructed_pbe, rad_vxc_original_pbe = gen_rad_xc(mol_UGBS, "PBE")
fig, ax = plt.subplots(1, 2, figsize=(6, 4))
ax[0].plot(rad_x, rad_vxc_original_pbe, label="Original")
ax[0].plot(rad_x, rad_vxc_reconstructed_pbe, label="Reconstructed", linestyle="--", linewidth=0.7)
ax[0].set_xscale("log"), ax[0].legend(loc="lower right")
ax[0].set_xlim(3e-3, 1e1), ax[0].set_ylim(-13, 1)
ax[0].set_xlabel(r"$r$ (Bohr)")
ax[0].set_ylabel(r"$v_\mathrm{xc}^\mathsf{PBE} (r)$ (Hartree)")
ax[0].set_title("Reproduction of Figure 6")
ax[1].plot(rad_x, rad_vxc_original_pbe, label="Original")
ax[1].plot(rad_x, rad_vxc_reconstructed_pbe - (rad_vxc_reconstructed_slater - rad_vxc_original_slater), label="Corrected", linestyle="--", linewidth=0.7)
ax[1].set_xscale("log"), ax[1].legend(loc="lower right")
ax[1].set_xlim(3e-3, 1e1), ax[1].set_ylim(-13, 1)
ax[1].set_xlabel(r"$r$ (Bohr)")
fig.tight_layout()

不只是原子适用于这种方法;对于分子也同样适用。Gaiduk 文章的图 8 就展示了这种可能性。原文中尝试给出 CO 在 Un-contracted 6-311G* 基组下进行讨论 PBE 的交换相关势;但 PySCF 中若将基组完全拆开,容易在密度初猜过程中出现矩阵不可求逆的情况。因此,我们就使用普通的 6-311G* 基组来作绘图。下面图片中,左边奇点代表碳原子,而右边奇点代表氧原子。

rad_x_CO = np.linspace(-3, 5, 2000)

mol_CO = gto.Mole()
mol_CO.atom = """
C -0.6017097690606921 0. 0.
O  0.5264960462082796 0. 0.
"""
mol_CO.basis = "6-311G*"
mol_CO.verbose = 0
mol_CO.build()
<pyscf.gto.mole.Mole at 0x7f54f3b4bdc0>
rad_vxc_reconstructed_slater, rad_vxc_original_slater = gen_rad_xc(mol_CO, "Slater", rad_x=rad_x_CO)
rad_vxc_reconstructed_pbe, rad_vxc_original_pbe = gen_rad_xc(mol_CO, "PBE", rad_x=rad_x_CO)
fig, ax = plt.subplots(2, 1, figsize=(6, 6))
ax[0].plot(rad_x_CO, rad_vxc_reconstructed_pbe, label="Reconstructed", linestyle="--", linewidth=0.7)
ax[0].plot(rad_x_CO, rad_vxc_original_pbe, label="Original")
ax[0].legend()
ax[0].set_xlim(-3, 5), ax[0].set_ylim(-13, 1)
ax[0].set_ylabel(r"$v_\mathrm{x}^\mathsf{PBE} (x)$ (Hartree)")
ax[0].set_title("Reproduction of Figure 8")
ax[1].plot(rad_x_CO, rad_vxc_reconstructed_pbe - (rad_vxc_reconstructed_slater - rad_vxc_original_slater), label="Corrected", linestyle="--", linewidth=0.7)
ax[1].plot(rad_x_CO, rad_vxc_original_pbe, label="Original")
ax[1].legend()
ax[1].set_xlim(-3, 5), ax[1].set_ylim(-13, 1)
ax[1].set_xlabel(r"$x$ (Bohr)")
ax[1].set_ylabel(r"$v_\mathrm{x}^\mathsf{PBE} (x)$ (Hartree)")
fig.tight_layout()

未尽事项#

在 Gaiduk 文章中,还提及了两个消除 Gaussian 基组扰动的交换相关势的应用与验证。其一是 Figure 9 所呈现的对没有具体交换相关形式的方法 (譬如 Hartree-Fock),要如何得到平滑且近乎正确的交换相关势;其二是 Figure 10 中验证的通过平滑之后的交换相关势,在自洽场过程下,确实能得到相同的密度格点。前者由于利用到 Density-to-Potential Mapping 方法,在对其进行理解后,可能会在以后的文档作补充。


简单理解两种 Direct RPA 相关能程序实现等价性#

创建时间:2020-08-02

这篇文档中,我们会讨论基于 Pure-GGA 参考态的 Direct RPA (dRPA) 相关能计算。理论推导着重从类 TD-KS 方程出发,而程序实现会讨论两种 \(O(N^6)\) 与一种 \(O(N^4)\) 方法。

Direct RPA 可以看作是 Rung 5th 的密度泛函方法,其精度一般来说高于一般的杂化泛函 (B3LYP, PBE0 等),计算复杂度大约为双杂化泛函 (XYG3, B2PLYP 等) 或更低一些,被认为可能对一些近简并体系能较为圆滑地处理。

密度泛函的近似着重处理交换相关能。Direct RPA 的交换能一般通过 Exact 交换能 (不准确地说是 Hartree-Fock 交换能) 获得,而相关能的计算相对特殊。几乎所有量子化学软件具有计算 Direct RPA 的能力,但发行版中明确拥有这一特性的软件较少,一般来说有 Ren Xinguo 为代表的 FHI-Aims [1] 与 Furche 为代表的 Turbomole [2]。在较小的模型体系下,该文档的计算结果与这两个软件的结果作对比与核验。

这篇文档中,既不引用也不加以证明的公式,有 TD-KS (Casida) 方程、Wick 二次量子化算符交换规则、Cauchy-Goursat 积分定理等。

%matplotlib notebook

from pyscf import gto, dft, scf, tdscf, df
import numpy as np
import scipy
from scipy.linalg import fractional_matrix_power
from functools import partial
from matplotlib import pyplot as plt
from IPython.display import Image

np.set_printoptions(5, linewidth=120, suppress=True)
np.einsum = partial(np.einsum, optimize=True)

分子体系与 PBE 能量计算#

我们使用 cc-pVTZ 基组的水分子举例。格点使用 PySCF 的默认格点,即 prune 的氧原子 (75, 302),氢原子 (50, 302) 格点。该分子实例记在 mol 中。

mol = gto.Mole()
mol.atom = """
O  0. 0. 0.
H  0. 0. 1.
H  0. 1. 0.
"""
mol.basis = "cc-pVTZ"
mol.verbose = 0
mol.build()
<pyscf.gto.mole.Mole at 0x7f5480a324f0>

我们在后文会使用到 RI (Resolution of Identity) 方法,因此需要定义 DF (Density Fitting) 基组。我们使用的 DF 基组是 cc-pVTZ-ri。该分子实例记在 mol_df 中。

mol_df = mol.copy()
mol_df.basis = "cc-pVTZ-ri"
mol_df.build()
<pyscf.gto.mole.Mole at 0x7f54b1a98850>

我们首先计算分子的 PBE 能量,其目的是得到 PBE 的分子轨道。该计算实例记在 mf 中。出于便利,我们使用未经过 DF (Density Fitting) 的自洽场。

mf = dft.RKS(mol, xc="PBE").run()
mf.e_tot
-76.36780110085748

我们随后需要定义与分子或方法有关的变量。我们使用 \(i, j\) 表示占据分子轨道,\(a, b\) 表示非占分子轨道,\(p, q, r, s\) 表示全部分子轨道,\(\mu, \nu, \kappa, \lambda\) 表示原子轨道,\(P, Q\) 表示 DF 轨道。

  • nocc \(n_\mathrm{occ}\) 占据轨道数

  • nvir \(n_\mathrm{vir}\) 未占轨道数

  • nmo \(n_\mathrm{MO}\) 分子轨道数,等于占据轨道数 nao \(n_\mathrm{AO}\)

  • naux \(n_\mathrm{aux}\) Density Fitting 基组轨道数 (或者也称辅助基组 Auxiliary)

  • so, sv, sa 占据、未占、全轨道分割 (用于程序编写)

  • eri0_ao \((\mu \nu | \kappa \lambda)\) 原子轨道双电子排斥积分

nocc, nmo, nao = mol.nelec[0], mol.nao, mol.nao
naux = mol_df.nao
nvir = nmo - nocc
so, sv, sa = slice(0, nocc), slice(nocc, nmo), slice(0, nmo)
eri0_ao = mol.intor("int2e")
  • e, eo, ev \(e_{p}\) 全、占据、未占 PBE 轨道能

  • C, Co, Cv \(C_{\mu p}\) 全、占据、未占 PBE 轨道系数

以往的文档中都会对密度矩阵作定义;但这篇文档中会有另一处记号与密度矩阵非常相似,并且通篇不太使用密度矩阵,因此不做定义。

e, C = mf.mo_energy, mf.mo_coeff
eo, ev = e[so], e[sv]
Co, Cv = C[:, so], C[:, sv]
  • eri0_mo \((pq|rs)\) 分子轨道双电子排斥积分

  • eri0_iajb \((ia|jb)\) 上述张量的占据-非占-占据-非占的分割

eri0_mo = np.einsum("uvkl, up, vq, kr, ls -> pqrs", eri0_ao, C, C, C, C)
eri0_iajb = eri0_mo[so, sv, so, sv]

对于 Direct RPA,其矫正能并不是对 PBE 自洽场能直接作矫正,而是如下的形式:

\[ E^\mathsf{dRPA}_\mathrm{tot} = E^\mathsf{PBE}_\mathrm{tot} - E^\mathsf{PBE}_\mathrm{xc} + E^\mathsf{exact}_\mathrm{x} + E^\mathsf{dRPA}_\mathrm{c} \]

我们首先给出 PBE 交换相关能 eng_xc \(E^\mathsf{PBE}_\mathrm{xc}\)

ni = dft.numint.NumInt()
eng_xc = ni.nr_vxc(mol, mf.grids, "PBE", mf.make_rdm1())[1]
eng_xc
-9.218732082968408

以及 Exact 交换能 eng_exactX \(E^\mathsf{exact}_\mathrm{x}\)

\[ E^\mathsf{exact}_\mathrm{x} = - \sum_{\mu \nu \kappa \lambda} C_{\mu i} C_{\nu i} (\mu \kappa | \nu \lambda) C_{\kappa j} C_{\lambda j} \]
eng_exactX = - np.einsum("ui, vi, ukvl, kj, lj ->", Co, Co, eri0_ao, Co, Co)
eng_exactX
-8.887856003073837

那么 dRPA 中,除了 \(E^\mathsf{dRPA}_\mathrm{c}\) 之外的部分都是可求的。定义其为 HXX 能量:

\[ E^\mathsf{dRPA}_\mathrm{tot} = E^\mathsf{HXX} + E^\mathsf{dRPA}_\mathrm{c} \]
eng_HXX = mf.e_tot - eng_xc + eng_exactX
eng_HXX
-76.03692502096291

这份文档随后的任务就是求取 \(E^\mathsf{dRPA}_\mathrm{c}\)

程序实现:类 TD-KS 方法#

PySCF 程序实现#

我们可以使用 PySCF 现成的类简单地实现 \(E^\mathsf{dRPA}_\mathrm{c}\)。在 PySCF 中,通过计算 PySCF 所计算的 dRPA 与 dTDA 激发能,并对两者相减求和,就得到了 dRPA 相关能:

\[ E^\mathsf{dRPA}_\mathrm{c} = \frac{1}{2} \sum_n (\omega_n^\mathsf{dRPA} - \omega_n^\mathsf{dTDA}) \tag{1} \]

上式是对下标 \(n\) 求和,其中 \(n\) 表示基态向上的第 \(n\) 个激发态,\(\omega_n^\mathsf{dRPA}\) 表示 dRPA 激发近似下的第 \(n\) 个激发态的激发能;\(\omega_n^\mathsf{dTDA}\) 类似。

但需要留意,PySCF (以及绝大多数量化软件) 的 TD-KS 的解通常只求 20 个以内,但我们原则上需要计算所有的激发能并求和,且最多求取 \(n_\mathrm{occ} \times n_\mathrm{vir}\)。由于算力在当前体系下不成问题,因此我们就求取最多的激发态能量数。

mf_dRPA = tdscf.dRPA(mf)
mf_dRPA.nstates = nvir * nocc
mf_dRPA.run()
<pyscf.tdscf.rks.dRPA at 0x7f547ee55370>
mf_dTDA = tdscf.dTDA(mf)
mf_dTDA.nstates = nvir * nocc
mf_dTDA.run()
<pyscf.tdscf.rks.dTDA at 0x7f547d1d2c70>
eng_dRPA_tdks = 0.5 * (mf_dRPA.e - mf_dTDA.e).sum()
eng_dRPA_tdks
-0.4313792211677736

既然已经求出相关能,那么我们就可以求出总的 dRPA 能量 \(E^\mathsf{dRPA}_\mathrm{tot}\)

eng_dRPA_tot = eng_HXX + eng_dRPA_tdks
eng_dRPA_tot
-76.46830424213069

在这一段的最后,我们看一下每个激发态对相关能的贡献:

fig, ax = plt.subplots(figsize=(8, 3))
ax.plot(np.arange(nvir * nocc), 0.5 * (mf_dRPA.e - mf_dTDA.e))
ax.set_xlabel("Excite States $n$")
ax.set_ylabel("Contribution to $E^\mathsf{dRPA}_\mathrm{c}$ / a.u.")
fig.tight_layout()

从这张图上,尽管能看到前若干个态的相关能贡献较大,但并不能简单地认为后几个态的贡献较小以至于能忽略。因此,我们必须计算所有 PySCF 中所定义的 dRPA 与 dTDA 激发能,才能得到相对来说精确的总 dRPA 相关矫正能。

PySCF 的 dRPA 与 dTDA 激发能#

在 PySCF 中,dRPA 能量是通过下述方程获得。首先,我们定义 dRPA 的激发与退激发张量:

\[\begin{split} \begin{align} A_{ia, jb}^\mathsf{dRPA} &= - (\varepsilon_i - \varepsilon_a) \delta_{ij} \delta_{ab} + 2 (ia|jb) \\ B_{ia, jb}^\mathsf{dRPA} &= 2 (ia|jb) \tag{2} \end{align} \end{split}\]

随后求解下述本征问题:

\[\begin{split} \begin{equation} \begin{pmatrix} \mathbf{A}^\mathsf{dRPA} & \mathbf{B}^\mathsf{dRPA} \\ - \mathbf{B}^\mathsf{dRPA} & - \mathbf{A}^\mathsf{dRPA} \end{pmatrix} \begin{pmatrix} \mathbf{X}_n \\ \mathbf{Y}_n \end{pmatrix} = \pm \omega_n^\mathsf{dRPA} \begin{pmatrix} \mathbf{X}_n \\ \mathbf{Y}_n \end{pmatrix} \tag{3} \end{equation} \end{split}\]

我们首先定义 dRPA 的 A_dRPA \(A_{ia, jb}^\mathsf{dRPA}\)B_dRPA \(B_{ia, jb}^\mathsf{dRPA}\),它们尽管是以张量形式定义,但实际计算时使用 \((n_\mathrm{occ} n_\mathrm{vir}, n_\mathrm{occ} n_\mathrm{vir})\) 维度的矩阵来表达。

delta_ij, delta_ab = np.eye(nocc), np.eye(nvir)
A_dRPA = (
    np.einsum("ia, ij, ab -> iajb", - eo[:, None] + ev[None, :], delta_ij, delta_ab)
    + 2 * eri0_iajb)
B_dRPA = 2 * eri0_iajb
A_dRPA, B_dRPA = A_dRPA.reshape(nocc*nvir, nocc*nvir), B_dRPA.reshape(nocc*nvir, nocc*nvir)

该本征问题会求出正负对应的激发能。我们只需要求取其正激发能 \(\omega_n^\mathsf{dRPA}\) 部分即可。

AB_dRPA = np.block([
    [ A_dRPA,  B_dRPA],
    [-B_dRPA, -A_dRPA],
])

计算得到的激发能将从小到大储存到 e_dRPA 中。

e_dRPA = np.linalg.eig(AB_dRPA)[0]
e_dRPA.sort()
e_dRPA = e_dRPA[nocc*nvir:]

该激发能可以与 PySCF 所的到的激发能对照:

np.allclose(e_dRPA, mf_dRPA.e)
True

同样地,对 dTDA 激发也能作相应的验证。与 dRPA 激发的区别在于激发与退激发张量构造上有所不同:

\[\begin{split} \begin{align} A_{ia, jb}^\mathsf{dTDA} &= - (\varepsilon_i - \varepsilon_a) \delta_{ij} \delta_{ab} + 2 (ia|jb) = A_{ia, jb}^\mathsf{dRPA} \\ B_{ia, jb}^\mathsf{dTDA} &= 0 \end{align} \end{split}\]

尽管其本征问题从构造上与 dRPA 激发可以一致,但也可以简化为

\[ \begin{equation} \mathbf{A}^\mathsf{dTDA} \mathbf{X}_n = \omega^\mathsf{dTDA}_n \mathbf{X}_n \tag{4} \end{equation} \]
A_dTDA = A_dRPA
e_dTDA = np.linalg.eig(A_dRPA)[0]
e_dTDA.sort()
np.allclose(e_dTDA, mf_dTDA.e)
True

程序实现:\(O(N^6)\) 直接实现方式#

程序实现#

从公式表达上,类似 TD-KS 方程的解并不是比较方便的写法。一种比较早期的实现方式在 Furche 的文章 [3] 中提及,但比较友好的表达公式在 Eshuis, Furche et al. [4] (eq.76)。其实现方式是

\[\begin{split} \begin{align} \mathbf{M} &= (\mathbf{A}^\mathsf{dRPA} - \mathbf{B}^\mathsf{dRPA})^{1/2} (\mathbf{A}^\mathsf{dRPA} + \mathbf{B}^\mathsf{dRPA}) (\mathbf{A}^\mathsf{dRPA} - \mathbf{B}^\mathsf{dRPA})^{1/2} \\ E^\mathsf{dRPA}_\mathrm{c} &= \frac{1}{2} \mathrm{tr} (\mathbf{M}^{1/2} - \mathbf{A}^\mathsf{dTDA}) \tag{5} \end{align} \end{split}\]

其程序实现也非常方便。

AmB_1p2 = fractional_matrix_power(A_dRPA - B_dRPA, 0.5)
M = AmB_1p2 @ (A_dRPA + B_dRPA) @ AmB_1p2
eng_dRPA_direct = 0.5 * np.trace(fractional_matrix_power(M, 0.5) - A_dTDA)
eng_dRPA_direct
-0.4313792211663592

之所以说是 \(O(N^6)\) 计算复杂度,是因为其中使用到了矩阵的分数幂次与乘积;而矩阵的长与宽都是 \(n_\mathrm{occ} n_\mathrm{vir}\),因此其计算量复杂度是 \(O(n_\mathrm{occ}^3 n_\mathrm{vir}^3)\) 的。

与类 TD-KS 实现等价性的数学说明#

首先,我们作一些线性代数的基础知识补充。我们先定义两个 5 维度任意张量 X, Y \(\mathbf{X}, \mathbf{Y}\),来表述问题。

np.random.seed(0)
X = np.random.randn(5, 5)
Y = np.random.randn(5, 5)

(1) 矩阵迹的和可以直接作拆分:

\[ \mathrm{tr} (\mathbf{X} + \mathbf{Y}) = \mathrm{tr} (\mathbf{X}) + \mathrm{tr} (\mathbf{Y}) \]
np.allclose((X + Y).trace(), X.trace() + Y.trace())
True

(2) 交换律:

\[ \mathrm{tr} (\mathbf{X} \mathbf{Y}) = \mathrm{tr} (\mathbf{Y} \mathbf{X}) \]
np.allclose((X @ Y).trace(), (Y @ X).trace())
True

(2) 矩阵的迹等于其本征值的和:

\[\begin{split} \begin{align} \mathbf{X} \boldsymbol{f}_n &= \omega_n \boldsymbol{f}_n \\ \mathrm{tr} (\mathbf{X}) &= \sum_n \omega_n \end{align} \end{split}\]
eig, F = np.linalg.eig(X)
np.allclose(X.trace(), eig.sum())
True

说明如下。如果我们定义矩阵 F \(\mathbf{F}\) 为列向量 \(\{ \boldsymbol{f}_n \}\) 的并,以及 O \(\mathbf{\Omega}\) 是以本征值 w \(\{ \omega_n \}\) 为对角元的对角矩阵,那么

\[ \mathbf{X} \mathbf{F} = \mathbf{F} \mathbf{\Omega} \]
O = np.diag(eig)
np.allclose(X @ F, F @ O)
True

因此,

\[ \sum_n \omega_n \equiv \sum_n \Omega_{nn} = \mathrm{tr} (\mathbf{\Omega}) = \mathrm{tr} (\mathbf{F}^{-1} \mathbf{X} \mathbf{F}) = \mathrm{tr} (\mathbf{F}^{-1} \mathbf{F} \mathbf{X}) = \mathrm{tr} (\mathbf{X}) \]

有上述的基础知识后,我们可以说明式 (5) 的 \(O(N^6)\) 的公式表达与式 (1) 的类 TD-KS 方程实现是等价的。

(a) \(\omega_n^\mathsf{dRPA}\)

我们先回顾式 (3) 所导出的 dRPA 激发能:

\[\begin{split} \begin{pmatrix} \mathbf{A}^\mathsf{dRPA} & \mathbf{B}^\mathsf{dRPA} \\ - \mathbf{B}^\mathsf{dRPA} & - \mathbf{A}^\mathsf{dRPA} \end{pmatrix} \begin{pmatrix} \mathbf{X}_n \\ \mathbf{Y}_n \end{pmatrix} = \pm \omega_n^\mathsf{dRPA} \begin{pmatrix} \mathbf{X}_n \\ \mathbf{Y}_n \end{pmatrix} \end{split}\]

从矩阵转换为方程则表达为 (我们只考虑正激发能结果,因此去除上式的正负号 \(\pm\)):

\[\begin{split} \begin{align} \mathbf{A}^\mathsf{dRPA} \mathbf{X}_n + \mathbf{B}^\mathsf{dRPA} \mathbf{Y}_n &= \omega_n^\mathsf{dRPA} \mathbf{X}_n \\ \mathbf{B}^\mathsf{dRPA} \mathbf{X}_n + \mathbf{A}^\mathsf{dRPA} \mathbf{Y}_n &= - \omega_n^\mathsf{dRPA} \mathbf{Y}_n \end{align} \end{split}\]

将上两式作加减法,得到

\[\begin{split} \begin{align} (\mathbf{A}^\mathsf{dRPA} + \mathbf{B}^\mathsf{dRPA}) (\mathbf{X}_n + \mathbf{Y}_n) &= \omega_n^\mathsf{dRPA} (\mathbf{X}_n - \mathbf{Y}_n) \\ (\mathbf{A}^\mathsf{dRPA} - \mathbf{B}^\mathsf{dRPA}) (\mathbf{X}_n - \mathbf{Y}_n) &= \omega_n^\mathsf{dRPA} (\mathbf{X}_n + \mathbf{Y}_n) \end{align} \end{split}\]

将上两式左右相乘,可以得到

\[ \big( (\mathbf{A}^\mathsf{dRPA} + \mathbf{B}^\mathsf{dRPA}) (\mathbf{A}^\mathsf{dRPA} - \mathbf{B}^\mathsf{dRPA}) \big) (\mathbf{X}_n^2 - \mathbf{Y}_n^2) = (\omega_n^\mathsf{dRPA})^2 (\mathbf{X}_n^2 - \mathbf{Y}_n^2) \]

对上式左右开根号,得到

\[ \big( (\mathbf{A}^\mathsf{dRPA} + \mathbf{B}^\mathsf{dRPA}) (\mathbf{A}^\mathsf{dRPA} - \mathbf{B}^\mathsf{dRPA}) \big)^{1/2} (\mathbf{X}_n^2 - \mathbf{Y}_n^2)^{1/2} = \omega_n^\mathsf{dRPA} (\mathbf{X}_n^2 - \mathbf{Y}_n^2)^{1/2} \]

由于矩阵的迹等于其本征值的和,因此

\[\begin{split} \begin{align} \sum_{n} \omega_n^\mathsf{dRPA} &= \mathrm{tr} \big[ \big( (\mathbf{A}^\mathsf{dRPA} + \mathbf{B}^\mathsf{dRPA}) (\mathbf{A}^\mathsf{dRPA} - \mathbf{B}^\mathsf{dRPA}) \big)^{1/2} \big] \\ &= \mathrm{tr} \big[ \big( (\mathbf{A}^\mathsf{dRPA} - \mathbf{B}^\mathsf{dRPA})^{1/2} (\mathbf{A}^\mathsf{dRPA} + \mathbf{B}^\mathsf{dRPA}) (\mathbf{A}^\mathsf{dRPA} - \mathbf{B}^\mathsf{dRPA})^{1/2} \big)^{1/2} \big] \\ &\equiv \mathrm{tr} (\mathbf{M}^{1/2}) \end{align} \end{split}\]

(2) \(\omega^\mathsf{dTDA}_n\)

回顾式 (5) 所导出的 dTDA 激发能:

\[ \mathbf{A}^\mathsf{dTDA} \mathbf{X}_n = \omega^\mathsf{dTDA}_n \mathbf{X}_n \]

我们能立即利用矩阵的迹等于其本征值的和,得到

\[ \sum_n \omega^\mathsf{dTDA}_n = \mathrm{tr} (\mathbf{A}^\mathsf{dTDA}) \]

总结上述的结果,可以得到

\[ E^\mathsf{dRPA}_\mathrm{c} = \frac{1}{2} \sum_n (\omega_n^\mathsf{dRPA} - \omega_n^\mathsf{dTDA}) = \frac{1}{2} \big( \mathrm{tr} (\mathbf{M}^{1/2}) - \mathrm{tr} (\mathbf{A}^\mathsf{dTDA}) \big) = \frac{1}{2} \mathrm{tr} (\mathbf{M}^{1/2} - \mathbf{A}^\mathsf{dTDA}) \]

简单理解 dRPA 相关能 (1):绝热路径与密度涨落#

这里我们主要使用 Eshuis, Furche et al. [4] 的文章的思路来叙述;但这一段本身是 DFT 理论的基础,因此不仅是 dRPA,其它类型的相关能 (GGA, Hybrid, Double Hybrid) 都可以用下述框架作为基础推导。

这里并非是对 DFT 理论的回顾,而是在对 DFT 理论有基础了解之后的一些补充理解。

我们之后会提及,dRPA 具有两种解释方法。一种为类 TD-KS 方法,另一种为涨落耗散定理。从实现上,我们已经对类 TD-KS 方法做了说明。我们以后还会对涨落耗散定理的方法的实现作说明,并且会对这两种方法作推导。尽管这两种方法在公式表达上有巨大的差别,但结果相同;且其推导前提都是绝热路径与密度涨落。

这一段完全是理论性的,不包含任何程序,也不包含任何近似 (除了 Kohn-Sham 图景本身或 Levy Constrained Search 之外)。

绝热路径 (1):定义#

关于绝热路径,较为基础但清晰的综述是 Perdew, Kurth [5] (p.16)。我们首先定义关于变量 \(\alpha\) 的电子态波函数哈密顿算符 (不包含核互斥部分) (后文中的 \(\alpha\) 一般都不指代向上自旋的含义)

\[ \hat H^\alpha = \hat T + \alpha \hat V_\mathrm{ee} + \hat V_\mathrm{ext} + \hat V^\alpha \]

其中,\(\hat T\) 为动能算符,\(\hat V_\mathrm{ee}\) 为电子互斥算符 (下两式的 \(i\) 暂时代表电子角标而非占据轨道),\(\hat V_\mathrm{ext}\) 在无外场的情况下定义为电子与原子互斥势算符 (\(A\) 代表原子角标):

\[\begin{split} \begin{align} \hat T &= - \frac{1}{2} \sum_{i} \nabla_{\boldsymbol{r}_i}^2 \\ \hat V_\mathrm{ee} &= \frac{1}{2} \sum_{i \neq j} \frac{1}{| \boldsymbol{r}_i - \boldsymbol{r}_j |} \\ \hat V_\mathrm{exc} &= \sum_i v_\mathrm{exc} (\boldsymbol{r}_i) = - \sum_{Ai} \frac{Z_A}{| \boldsymbol{r}_i - \boldsymbol{r}_A |} \end{align} \end{split}\]

而第三个算符 \(\hat V^\alpha\) 定义如下。若 \(\alpha = 1\),则定义为零。这种情况下,\(\hat H^{\alpha = 1}\) 恰好是精确的电子态哈密顿算符 \(\hat T + \hat V_\mathrm{ee} + \hat V_\mathrm{ext}\);而若 \(\alpha = 0\),在 DFT 下则是需要被定义的量,各种 DFT 的近似的目标也是希望描述清楚 \(\hat V_\mathrm{xc}\);在我们的实际计算中,\(\alpha = 0\) 的情况对应 PBE 参考态的计算。

即定义为交换相关势相关量。这对应的是 Kohn-Sham 电子态哈密顿算符,且不包含交换部分。我们之前计算所用到的 PBE,或者 BLYP, SVWN5 等即可以看作对该算符 \(\hat V^{\alpha = 0}\) 的近似;但 B3LYP 则包含 0.2 份了交换,因此近似的对象不是 \(\hat V^{\alpha = 0}\) (近似对象很接近 \(\hat V^{\alpha = 0.2}\),但严格来说并非如此,因为 \(0.2 \hat V_\mathrm{ee}\) 本质是双电子算符,因此其精确求解还是与 \(\alpha = 1\) 的情形没有本质区别,而不可能是杂化 DFT 近似的做法),也因此不再这篇文档的讨论范畴中。

若 Kohn-Sham 图景下可以构造出完全精确的交换相关势,那么 \(\hat H^{\alpha = 0}\) 即使与 \(\hat V^{\alpha = 1}\) 具有不同的形式,导出不同的波函数

\[\begin{split} \begin{align} \hat H^{\alpha = 1} | \hat \Psi^{\alpha = 1} \rangle &= E_\mathrm{elec} | \hat \Psi^{\alpha = 1} \rangle \\ \hat H^{\alpha = 0} | \hat \Psi^{\alpha = 0} \rangle &= E_\mathrm{elec} | \hat \Psi^{\alpha = 0} \rangle \end{align} \end{split}\]

其中,\(E_\mathrm{elec}\) 是基态能量。

之所以要这么绕弯子,是因为我们无法在较少计算量下得到真实的电子态波函数。从程序实现的角度讲,在 Kohn-Sham 图景下,假设真实的电子态密度 \(\rho = \langle \Psi^{\alpha = 1} | \hat \rho | \Psi^{\alpha = 1} \rangle\) 能够通过相互正交的、电子数量的关于电子坐标 \(\boldsymbol{r}\) 的函数 \(\phi_{i} (\boldsymbol{r})\) 之和表示:\(\rho = \langle \Psi^{\alpha = 0} | \hat \rho | \Psi^{\alpha = 0} \rangle\)。那么就存在势函数 \(v^\mathrm{xc} (\boldsymbol{r}_i)\),使得 \(\hat H^{\alpha = 0} | \hat \Psi^{\alpha = 0} \rangle\) 波函数的求解可以通过类似于 Hartree-Fock 的自洽场过程给出而不需要进行大计算量的 Post-HF 过程,但在这些有限的计算量下给出正确的密度。

基于上述的定义,我们可以给出波函数 \(| \Psi^{\alpha} \rangle\) 关于 \(\alpha\) 变量的绝热路径。该“绝热路径”的守恒量是电子态密度,即对于任意 \(\alpha\)

\[ \rho = \langle \Psi^\alpha | \hat \rho | \Psi^\alpha \rangle \]

是完全相等的。

绝热路径 (2):与相关能的联系#

DFT 近似的主要目标是给出交换相关能;上述的绝热路径是一种从波函数角度出发一窥交换相关能构造的做法。

首先,总电子态能量 \(E_\mathrm{elec}\) (不包含核互斥能) 可以拆分为单电子轨道动能 \(T_\mathrm{s} [\rho]\)、外势能 \(V_\mathrm{ext} [\rho]\)、库伦积分 \(J [\rho]\)

\[\begin{split} \begin{align} T_\mathrm{s} [\rho] &= \langle \Psi^{\alpha = 0} | \hat T | \Psi^{\alpha = 0} \rangle \\ V_\mathrm{ext} [\rho] &= \int \rho(\boldsymbol{r}) v_\mathrm{ext} (\boldsymbol{r}) \, \mathrm{d} \boldsymbol{r} \\ U [\rho] &= \frac{1}{2} \iint \frac{\rho(\boldsymbol{r}) \rho(\boldsymbol{r}')}{| \boldsymbol{r} - \boldsymbol{r}' |} \, \mathrm{d} \boldsymbol{r} \, \mathrm{d} \boldsymbol{r}' \end{align} \end{split}\]

上述三式是比较容易求取的。除此之外,定义交换能为

\[ E_\mathrm{x} [\rho] = \langle \Psi^{\alpha = 0} | \hat V_\mathrm{ee} | \Psi^{\alpha = 0} \rangle - U[\rho] \]

交换能可以通过类似与 Hartree-Fock 的方法给出。因此,上述四项都是程序容易实现,且应当仅与密度有关。

下面我们就要给出相关能的表达式。它可以定义为

\[\begin{split} \begin{align} E_\mathrm{c} [\rho] &= E_\mathrm{elec} [\rho] - \langle \Psi^{\alpha = 0} | \hat H^{\alpha = 1} | \Psi^{\alpha = 0} \rangle \\ &= \langle \Psi^{\alpha = 0} | \hat H^{\alpha = 0} | \Psi^{\alpha = 0} \rangle - \langle \Psi^{\alpha = 0} | \hat H^{\alpha = 1} | \Psi^{\alpha = 0} \rangle \\ &= \langle \Psi^{\alpha = 0} | \hat V_\mathrm{xc} | \Psi^{\alpha = 0} \rangle - E_\mathrm{x} [\rho] \tag{6} \end{align} \end{split}\]

也可以定义为

\[\begin{split} \begin{align} E_\mathrm{c} [\rho] &= \langle \Psi^{\alpha = 1} | \hat H^{\alpha = 1} | \Psi^{\alpha = 1} \rangle - \langle \Psi^{\alpha = 0} | \hat H^{\alpha = 1} | \Psi^{\alpha = 0} \rangle \\ &= \langle \Psi^{\alpha = 1} | \hat T + \hat V_\mathrm{ee} | \Psi^{\alpha = 1} \rangle - T_\mathrm{s} [\rho] - U[\rho] - E_\mathrm{x} [\rho] \\ &= \langle \Psi^{\alpha = 1} | \hat T + \hat V_\mathrm{ee} | \Psi^{\alpha = 1} \rangle - \langle \Psi^{\alpha = 0} | \hat T | \Psi^{\alpha = 0} \rangle - \langle \Psi^{\alpha=0} | \hat V_\mathrm{ee} | \Psi^{\alpha=0} \rangle \tag{7} \end{align} \end{split}\]

上式的前两项可以看作是关于 \(\alpha\) 定积分的上下界,因此上式也可以表达为

\[ E_\mathrm{c} [\rho] = \int_0^1 \frac{\mathrm{d}}{\mathrm{d} \alpha} \langle \Psi^{\alpha} | \hat T + \alpha \hat V_\mathrm{ee} | \Psi^{\alpha} \rangle \, \mathrm{d} \alpha - \langle \Psi^{\alpha=0} | \hat V_\mathrm{ee} | \Psi^{\alpha=0} \rangle \]

根据 (变分情形而非本征情形的) Hellmann-Feynman 定理,有

\[\begin{split} \begin{align} E_\mathrm{c} [\rho] &= \int_0^1 \frac{\mathrm{d}}{\mathrm{d} \alpha} \langle \Psi^{\alpha} | \alpha \hat V_\mathrm{ee} | \Psi^{\alpha} \rangle \, \mathrm{d} \alpha - \langle \Psi^{\alpha=0} | \hat V_\mathrm{ee} | \Psi^{\alpha=0} \rangle \\ &= \int_0^1 \langle \Psi^{\alpha} | \hat V_\mathrm{ee} | \Psi^{\alpha} \rangle \, \mathrm{d} \alpha - U[\rho] - E_\mathrm{x} [\rho] \tag{8} \end{align} \end{split}\]

式 (6) 是 Kohn-Sham 方式下最直观的交换能定义;它不包含真实波函数 \(| \Psi^{\alpha = 1} \rangle\) 的信息,但有目前只能依靠近似才能获得的 \(\hat V_\mathrm{xc}\) 算符。式 (7) 或 (8) 则不包含 \(\hat V_\mathrm{xc}\),但具有 \(| \Psi^{\alpha = 1} \rangle\)。我们或许会认为后者的公式包含的已知或可知信息更多一些 (获取 \(| \Psi^{\alpha = 1} \rangle\) 计算量巨大的,避免获取之也是 DFT 近似的发展动力,但它在小体系有限基组下仍然是可求得的,并且更容易进行后自洽行为的分析),因此我们将着重对式 (8) 进行考察。

二次量子化 (1):\(\hat V_\mathrm{ee}\)\(\hat \rho (\boldsymbol{r})\) 的定义#

这里我们参考 Helgaker et al. [6] 的说明。由于我们推导的是闭壳层的情形,因此我们需要引入单重激发算符 (仅在下式中的 \(\alpha\) 代表自旋的含义)

\[ \hat E_{pq} = a_{p \alpha}^\dagger a_{q \alpha} + a_{p \beta}^\dagger a_{q \beta} \]

这里规定,当 \(a\) 字母不在角标处时,则表示产生或湮灭算符。由此出发,我们能定义双电子算符 (Helgaker, eq 2.2.15):

\[ \hat V_\mathrm{ee} = \frac{1}{2} \sum_{\sigma, \sigma'} \sum_{pq, rs} (pq|rs) a_{p \sigma}^\dagger a_{r \sigma}^\dagger a_{s \sigma'} a_{q \sigma'} = \frac{1}{2} \sum_{pq, rs} (pq|rs) (\hat E_{pq} \hat E_{rs} - \delta_{pr} \hat E_{qs}) \]

随后,我们定义密度算符 \(\hat \rho(\boldsymbol{r})\) (Eshuis, Furche [4], eq 11) (需要留意,Helgaker 的算符定义应都出于程序实现的要求,不会出现关于坐标呈变值的算符):

\[ \hat \rho (\boldsymbol{r}) = \sum_{pq} \hat E_{pq} \phi_p (\boldsymbol{r}) \phi_q (\boldsymbol{r}) \]

其中,\(\phi_p (\boldsymbol{r})\) 表示轨道 \(p\) 的分子轨道函数。这里的分子轨道指代的是作为 \(\alpha = 0\) 参考态的轨道。容易知道密度算符具有下述性质:

\[ \rho (\boldsymbol{r}) = \langle \Psi^{\alpha} | \hat \rho(\boldsymbol{r}) | \Psi^{\alpha} \rangle \]

但需要注意到,下述不等号一般情况下是成立的:

\[ \rho (\boldsymbol{r}) \rho (\boldsymbol{r}') \neq \langle \Psi^{\alpha} | \hat \rho(\boldsymbol{r}) \hat \rho(\boldsymbol{r}') | \Psi^{\alpha} \rangle \]

我们或许还需要定义双坐标密度算符 \(\hat \rho (\boldsymbol{r}, \boldsymbol{r}')\)

\[ \hat \rho (\boldsymbol{r}, \boldsymbol{r}') = \sum_{pq} \hat E_{pq} \phi_p (\boldsymbol{r}) \phi_q (\boldsymbol{r}') \]

以及 delta 算符 \(\hat \delta (\boldsymbol{r}, \boldsymbol{r}')\),注意到下述定义与 Dirac Delta 函数应当是等价的:

\[ \hat \delta (\boldsymbol{r}, \boldsymbol{r}') = \sum_{pq} \delta_{pq} \phi_p (\boldsymbol{r}) \phi_q (\boldsymbol{r}') = \delta (\boldsymbol{r} - \boldsymbol{r}') \]

因此,我们可以重述算符 \(\hat V_\mathrm{ee}\)

\[\begin{split} \begin{align} \hat V_\mathrm{ee} &= \frac{1}{2} \iint \frac{\hat \rho(\boldsymbol{r}) \hat \rho(\boldsymbol{r}') - \hat \delta (\boldsymbol{r}, \boldsymbol{r}') \hat \rho(\boldsymbol{r}, \boldsymbol{r}')}{| \boldsymbol{r} - \boldsymbol{r}' |} \, \mathrm{d} \boldsymbol{r} \, \mathrm{d} \boldsymbol{r}' \\ &= \frac{1}{2} \iint \frac{\hat \rho(\boldsymbol{r}) \hat \rho(\boldsymbol{r}') - \delta(\boldsymbol{r} - \boldsymbol{r}') \hat \rho(\boldsymbol{r})}{| \boldsymbol{r} - \boldsymbol{r}' |} \, \mathrm{d} \boldsymbol{r} \, \mathrm{d} \boldsymbol{r}' \end{align} \end{split}\]

上述等式中或许存在一项非常特殊的表达式:

\[ \hat V_\mathrm{ee} \leftarrow - \frac{1}{2} \iint \frac{\delta(\boldsymbol{r} - \boldsymbol{r}') \hat \rho(\boldsymbol{r})}{| \boldsymbol{r} - \boldsymbol{r}' |} \, \mathrm{d} \boldsymbol{r} \, \mathrm{d} \boldsymbol{r}' = - \frac{1}{2} \int \frac{\hat \rho(\boldsymbol{r})}{0} \, \mathrm{d} \boldsymbol{r} = \mathrm{NaN} \]

这项中,无论左乘右乘何种波函数,\(\hat \rho (\boldsymbol{r})\) 具体表达式如何,其结果一定为非数 (NaN, Not a Number)。如果将 \(\delta\) 函数当作一种逼近极限的话,应当认为上述值比起 NaN 来说更接近 \(- \infty\)

显然,我们知道 \(\langle \Psi^{\alpha} | \hat V_\mathrm{ee} | \Psi^{\alpha} \rangle\) 是有界的,且在 \(\alpha = 0\) 时代表库伦积分与交换能之和。但上述分项为 NaN,那么一定也就意味着

\[ \hat V_\mathrm{ee} \leftarrow - \frac{1}{2} \iint \frac{\hat \rho(\boldsymbol{r}) \hat \rho(\boldsymbol{r}')}{| \boldsymbol{r} - \boldsymbol{r}' |} \, \mathrm{d} \boldsymbol{r} \, \mathrm{d} \boldsymbol{r}' = \mathrm{NaN} \]

二次量子化 (2):密度涨落算符 \(\Delta \hat \rho (\boldsymbol{r})\)#

随后我们引入密度涨落算符

\[ \Delta \hat \rho(\boldsymbol{r}) = \hat \rho(\boldsymbol{r}) - \rho(\boldsymbol{r}) \]

密度涨落算符并不是一定要引入的算符;这我们会在后面一段推导时会提及。类似地,我们也能了解到性质

\[ 0 = \langle \Psi^{\alpha} | \Delta \hat \rho(\boldsymbol{r}) | \Psi^{\alpha} \rangle \]

将密度涨落算符的定义代入,我们还可以重述算符 \(\hat V_\mathrm{ee}\)

\[\begin{split} \begin{align} \hat V_\mathrm{ee} &= \frac{1}{2} \iint \frac{\Delta \hat \rho(\boldsymbol{r}) \Delta \hat \rho(\boldsymbol{r}')}{| \boldsymbol{r} - \boldsymbol{r}' |} \, \mathrm{d} \boldsymbol{r} \, \mathrm{d} \boldsymbol{r}' \\ &\quad + \frac{1}{2} \iint \frac{\Delta \hat \rho(\boldsymbol{r}) \rho(\boldsymbol{r}')}{| \boldsymbol{r} - \boldsymbol{r}' |} \, \mathrm{d} \boldsymbol{r} \, \mathrm{d} \boldsymbol{r}' + \frac{1}{2} \iint \frac{\rho(\boldsymbol{r}) \Delta \hat \rho(\boldsymbol{r}')}{| \boldsymbol{r} - \boldsymbol{r}' |} \, \mathrm{d} \boldsymbol{r} \, \mathrm{d} \boldsymbol{r}' \\ &\quad - \frac{1}{2} \iint \frac{\delta(\boldsymbol{r} - \boldsymbol{r}') \hat \rho(\boldsymbol{r})}{| \boldsymbol{r} - \boldsymbol{r}' |} \, \mathrm{d} \boldsymbol{r} \, \mathrm{d} \boldsymbol{r}' + U[\rho] \end{align} \end{split}\]

我们在这里指出,由于后四个贡献项都仅与单个密度算符、或密度涨落算符有关,因此在左乘 \(\langle \Psi^{\alpha} |\) 右乘 \(| \Psi^{\alpha} \rangle\) 后,不论 \(\alpha\) 的值是多少,其结果都应当相等 (因为对任意 \(\alpha\)\(| \Psi^{\alpha} \rangle\),其密度相等)。举其中一个例子而言,

\[ \frac{1}{2} \langle \Psi^\alpha | \iint \frac{\Delta \hat \rho(\boldsymbol{r}) \rho(\boldsymbol{r}')}{| \boldsymbol{r} - \boldsymbol{r}' |} \, \mathrm{d} \boldsymbol{r} \, \mathrm{d} \boldsymbol{r}' | \Psi^\alpha \rangle = \iint \frac{\rho(\boldsymbol{r}')}{| \boldsymbol{r} - \boldsymbol{r}' |} \langle \Psi^\alpha | \Delta \hat \rho(\boldsymbol{r}) | \Psi^\alpha \rangle \, \mathrm{d} \boldsymbol{r}\, \mathrm{d} \boldsymbol{r}' = 0 \]

之所以上式中对 \(\boldsymbol{r}, \boldsymbol{r}'\) 的积分,与左右矢符号可以交换,是借了二次量子化算符 \(a_p^\dagger, a_p\) 等算符与电子坐标 \(\boldsymbol{r}\) 无关的便利。因此,能真正区别不同波函数的算符贡献项,只有上式中的第一项。

根据上述说明,我们可以对绝热路径与相关能的关联式 (8) 重新写为

\[\begin{split} \begin{align} E_\mathrm{c} [\rho] &= \int_0^1 \langle \Psi^{\alpha} | \hat V_\mathrm{ee} | \Psi^{\alpha} \rangle \, \mathrm{d} \alpha - \langle \Psi^{\alpha=0} | \hat V_\mathrm{ee} | \Psi^{\alpha=0} \rangle \\ &= \int_0^1 \big( \langle \Psi^{\alpha} | \hat V_\mathrm{ee} | \Psi^{\alpha} \rangle - \langle \Psi^{\alpha=0} | \hat V_\mathrm{ee} | \Psi^{\alpha=0} \rangle \big) \, \mathrm{d} \alpha \\ &= \frac{1}{2} \int_0^1 \, \mathrm{d} \alpha \iint \, \mathrm{d} \boldsymbol{r}\, \mathrm{d} \boldsymbol{r}' \frac{\langle \Psi^{\alpha} | \Delta \hat \rho (\boldsymbol{r}) \Delta \hat \rho (\boldsymbol{r}') | \Psi^{\alpha} \rangle - \langle \Psi^{\alpha=0} | \Delta \hat \rho (\boldsymbol{r}) \Delta \hat \rho (\boldsymbol{r}') | \Psi^{\alpha=0} \rangle}{| \boldsymbol{r} - \boldsymbol{r}' |} \tag{9} \end{align} \end{split}\]

幺元分解:建立相关能与激发态的联系#

在波函数空间下,我们将所有满足定态方程

\[ \hat H^\alpha | \Psi_n^\alpha \rangle = (E_\mathrm{elec} + \omega_n^\alpha) | \Psi_n^\alpha \rangle \]

的波函数 \(| \Psi_n^\alpha \rangle\) 归并在一起 \(\{ | \Psi_n^\alpha \rangle \}\);该解空间与 \(\{ | \Psi^\alpha \rangle \}\) 的并集假定认为是完备的。其中,\(\omega_n^\alpha\) 称作在绝热路径 \(\alpha\) 基态波函数上的第 \(n\) 个激发态的激发能量。我们不把基态列入 \(n\) 的角标范围中,或者说 \(n\) 不能取到 0;这种记号可能有别于一些文章。

定义了上述记号并作完备性的假定后,尽管不很严谨,但我们指出存在下述幺元分解 (RI, Resolution of Identity):

\[ | \Psi^\alpha \rangle \langle \Psi^\alpha | + \sum_{n} | \Psi_n^\alpha \rangle \langle \Psi_n^\alpha | = 1 \]

因此,我们可以将式 (9) 中的一部分分项写作

\[\begin{split} \begin{align} \langle \Psi^{\alpha} | \Delta \hat \rho (\boldsymbol{r}) \Delta \hat \rho (\boldsymbol{r}') | \Psi^{\alpha} \rangle &= \langle \Psi^{\alpha} | \Delta \hat \rho (\boldsymbol{r}) | \Psi^\alpha \rangle \langle \Psi^\alpha | \Delta \hat \rho (\boldsymbol{r}') | \Psi^{\alpha} \rangle + \sum_n \langle \Psi^{\alpha} | \Delta \hat \rho (\boldsymbol{r}) | \Psi_n^\alpha \rangle \langle \Psi_n^\alpha | \Delta \hat \rho (\boldsymbol{r}') | \Psi^{\alpha} \rangle \\ &= \sum_{n} \langle \Psi^{\alpha} | \Delta \hat \rho (\boldsymbol{r}) | \Psi_n^\alpha \rangle \langle \Psi_n^\alpha | \Delta \hat \rho (\boldsymbol{r}') | \Psi^{\alpha} \rangle \end{align} \end{split}\]

上式的第二个等号利用的是基态波函数的 \(\langle \Psi^{\alpha} | \Delta \hat \rho (\boldsymbol{r}) | \Psi^\alpha \rangle = 0\)。这样,我们就将相关能与激发态之间建立了联系。我们也会记跃迁密度为

\[ \rho_{0n}^\mathrm{\alpha} (\boldsymbol{r}) = \langle \Psi^{\alpha} | \Delta \hat \rho (\boldsymbol{r}) | \Psi_n^\alpha \rangle = \langle \Psi^{\alpha} | \hat \rho (\boldsymbol{r}) | \Psi_n^\alpha \rangle \]

之所以说“完备性”是假定,是因为并不是所有激发态都被囊括了进来;譬如三重态激发在推导与程序中就不在其中。当然,我们考虑一般的跃迁密度时,单重到三重的激发是禁阻的,因此三重激发也不需要出现在幺元分解式中。因此,这种“完备性”尽管一点都不完备,但基本是够用的。

事实上,从上式中能看出,由于在描述基态到激发态的跃迁过程时,\(\Delta \hat \rho (\boldsymbol{r})\)\(\hat \rho (\boldsymbol{r})\) 没有本质区别,因此会说额外定义密度涨落算符不一定非常实际的意义;当然,用涨落算符会在公式中更加凸显相关能与密度波动之间的联系。

最后,我们需要指出,单看下式的话,

\[ \langle \Psi^{\alpha} | \Delta \hat \rho (\boldsymbol{r}) \Delta \hat \rho (\boldsymbol{r}') | \Psi^{\alpha} \rangle = \mathrm{NaN} \]

如果从跃迁密度的角度讲,上式相当于对所有激发态的跃迁密度平方的求和:

\[ \langle \Psi^{\alpha} | \Delta \hat \rho (\boldsymbol{r}) \Delta \hat \rho (\boldsymbol{r}') | \Psi^{\alpha} \rangle = \sum_{n} \rho_{0n}^{\alpha} (\boldsymbol{r}) \rho_{0n}^{\alpha, \dagger} (\boldsymbol{r}') \]

上式的 \(\dagger\) 表示的是复共轭。显然,每个激发态相对于基态密度的扰动都是比较大的,因此每个跃迁密度本身不可能是无穷小量;加之激发态数量无限,因此上述求和也是无限的,不可能是有界量。(当然,对于有限基组而言这显然是有界量)

而如果从 \(\hat V_\mathrm{ee}\) 的拆分角度来讲也是同样的。我们之前指出过,

\[ \hat V_\mathrm{ee} \leftarrow - \frac{1}{2} \iint \frac{\hat \rho(\boldsymbol{r}) \hat \rho(\boldsymbol{r}')}{| \boldsymbol{r} - \boldsymbol{r}' |} \, \mathrm{d} \boldsymbol{r} \, \mathrm{d} \boldsymbol{r}' = \mathrm{NaN} \]

只需要一些简单的分析,应当不难知道

\[ \frac{1}{2} \langle \Psi^\alpha | \iint \frac{\hat \rho(\boldsymbol{r}) \hat \rho(\boldsymbol{r}')}{| \boldsymbol{r} - \boldsymbol{r}' |} \, \mathrm{d} \boldsymbol{r} \, \mathrm{d} \boldsymbol{r}' | \Psi^\alpha \rangle = \frac{1}{2} \langle \Psi^\alpha | \iint \frac{\Delta \hat \rho(\boldsymbol{r}) \Delta \hat \rho(\boldsymbol{r}')}{| \boldsymbol{r} - \boldsymbol{r}' |} \, \mathrm{d} \boldsymbol{r} \, \mathrm{d} \boldsymbol{r}' | \Psi^\alpha \rangle + U[\rho] \]

因此我们应当预期 \(\Delta \hat \rho(\boldsymbol{r}) \Delta \hat \rho(\boldsymbol{r}')\)\(\hat \rho(\boldsymbol{r}) \hat \rho(\boldsymbol{r}')\) 具有比较接近的性质。

因此,单就讨论 \(\langle \Psi^{\alpha} | \Delta \hat \rho (\boldsymbol{r}) \Delta \hat \rho (\boldsymbol{r}') | \Psi^{\alpha} \rangle\) 本身是不能得到结论的,而需要与 \(\langle \Psi^{\alpha = 0} | \Delta \hat \rho (\boldsymbol{r}) \Delta \hat \rho (\boldsymbol{r}') | \Psi^{\alpha = 0} \rangle\) 相减才能得到有界的相关能。最后指出,我们可以将相关能式 (9) 写作

\[ \begin{align} E_\mathrm{c} [\rho] &= \frac{1}{2} \int_0^1 \, \mathrm{d} \alpha \iint \, \mathrm{d} \boldsymbol{r}\, \mathrm{d} \boldsymbol{r}' \left( \sum_{n} \frac{\rho_{0n}^{\alpha} (\boldsymbol{r}) \rho_{0n}^{\alpha, \dagger} (\boldsymbol{r}')}{| \boldsymbol{r} - \boldsymbol{r}' |} - \sum_{n} \frac{\rho_{0n}^{\alpha=0} (\boldsymbol{r}) \rho_{0n}^{\alpha=0, \dagger} (\boldsymbol{r}')}{| \boldsymbol{r} - \boldsymbol{r}' |} \right) \tag{10} \end{align} \]

从程序实现来说,跃迁密度不会是非实数量,因此共轭记号 \(\dagger\) 一般是可以去掉的。

简单理解 dRPA 相关能 (2):类 TD-KS 方法#

dRPA 相关能的推导一般可以有三种方式,为类 TD-KS (Time-Dependent Kohn-Sham) 方法、涨落耗散理论 (Fluctuation Dissipation Theory)、以及格林函数 (GW, Green’s Wavefunction) 途径。在这篇文档中,我们只讨论前两种方法;他们的共同基础是上面的绝热路径与密度涨落。通过密度涨落,我们建立了相关能与激发态的联系。

激发态很容易地与 TD-KS 方法产生直观的联系。我们下面就会讨论如何从 TD-KS 近似得到 dRPA 相关能。我们仍然按照 Eshuis, Furche [4] 的思路来理解。

TD Hartree 近似与跃迁密度的联系#

在这里我们不陈述 TD-KS 方程是如何推导的,只说明结论。

首先,如前所述,dRPA 相关能的交换部分是通过精确交换能表达式给出的;因此之后我们的讨论中就不引入交换能。我们考察的波函数 \(| \Psi^{\alpha = 0} \rangle\) 作为仅包含单电子算符贡献的 Kohn-Sham 等效哈密顿算符 \(\hat H^{\alpha = 0}\) 的本征态,其形式并不取用 Slater 行列式,而令其为 Hartree 连乘积。

对于波函数 \(| \Psi^{\alpha = 0} \rangle\),其 TD-KS 方程 (Casida 方程) 列为

\[\begin{split} \begin{equation} \begin{pmatrix} \mathbf{A}^\alpha & \mathbf{B}^\alpha \\ - \mathbf{B}^\alpha & - \mathbf{A}^\alpha \end{pmatrix} \begin{pmatrix} \mathbf{X}_n^\alpha \\ \mathbf{Y}_n^\alpha \end{pmatrix} = \pm \omega_n^\alpha \begin{pmatrix} \mathbf{X}_n^\alpha \\ \mathbf{Y}_n^\alpha \end{pmatrix} \tag{11} \end{equation} \end{split}\]

其中,

\[\begin{split} \begin{align} A_{ia, jb}^\alpha &= - (\varepsilon_i - \varepsilon_a) \delta_{ij} \delta_{ab} + 2 \alpha (ia|jb) + f^{\mathrm{xc}, \alpha}_{ia, jb} \\ B_{ia, jb}^\alpha &= 2 \alpha (ia|jb) + f^{\mathrm{xc}, \alpha}_{ia, jb} \tag{12} \end{align} \end{split}\]

如果我们将交换相关能对 TD-KS 方程的贡献全部抹去,那么上式就化为 TD Hartree 的情况:

\[\begin{split} \begin{align} A_{ia, jb}^\alpha &\simeq - (\varepsilon_i - \varepsilon_a) \delta_{ij} \delta_{ab} + 2 \alpha (ia|jb) \\ B_{ia, jb}^\alpha &\simeq 2 \alpha (ia|jb) \tag{13} \end{align} \end{split}\]

尽管这一段大体是公式推导,但我们顺便在此定义一些程序计算用的函数。定义函数 A \(A_{ia, jb}^\alpha\), B \(B_{ia, jb}^\alpha\),它们通过输入参数 \(\alpha\) 给出张量。

def A(alpha):
    return (np.einsum("ia, ij, ab -> iajb", - eo[:, None] + ev[None, :], delta_ij, delta_ab) + 2 * alpha * eri0_iajb).reshape(nocc*nvir, nocc*nvir)

def B(alpha):
    return (2 * alpha * eri0_iajb).reshape(nocc*nvir, nocc*nvir)

同时,我们定义函数 omega_list,通过分别输入 A 与 B 张量对应的 \(\alpha\) 值,给出其 TD 方程的正频率列表。

def omega_list(alpha1, alpha2=None):
    alpha2 = alpha2 if alpha2 is not None else alpha1
    A_, B_ = A(alpha1), B(alpha2)
    eig = np.linalg.eig((A_ + B_) @ (A_ - B_))[0]
    eig.sort()
    return np.sqrt(eig)

上述函数能直接计算 dRPA 相关能:

\[ E^\mathsf{dRPA}_\mathrm{c} = \frac{1}{2} \sum_n (\omega_n^\mathsf{dRPA} - \omega_n^\mathsf{dTDA}) \]

我们留意到 dRPA 激发使用的是 \(A_{ia, jb}^{\alpha = 1}\), \(B_{ia, jb}^{\alpha = 1}\),而 dTDA 激发使用的是 \(A_{ia, jb}^{\alpha = 1}\), \(B_{ia, jb}^{\alpha = 0}\)

0.5 * (omega_list(1).sum() - omega_list(1, 0).sum())
-0.4313792211675036

我们在这篇文档中不对作为本征向量的 \(X_{n, ia}^\alpha\)\(Y_{n, ia}^\alpha\) 作程序上的定义,仅作讨论。该本征向量在 PySCF 的程序中作下述归一化定义:

\[ \sum_{ia} \big( (X_{n, ia}^\alpha)^2 - (Y_{n, ia}^\alpha)^2 \big) = \frac{1}{2} \]

基于上述定义,闭壳层下的 TD 跃迁密度可以定义为 (这也参考 PySCF 的跃迁偶极矩定义):

\[ \rho_{0n}^\alpha (\boldsymbol{r}) = 2 \sum_{ia} (X_{n, ia}^\alpha + Y_{n, ia}^\alpha) \phi_i (\boldsymbol{r}) \phi_a (\boldsymbol{r}) \]

其中的 2 倍源于闭壳层下的两种自旋贡献之和。

TD 左右矢定义#

在后文中,为了公式推导与表达的便利,这里借用 Eshuis, Furche [4] 中的符号;但需要留意,实际闭壳层程序与原文开壳层的推导在一些细节上有出入。

我们可以定义下述与 TD 方程等价的表达式:

\[ ( \mathbf{\Lambda}^\alpha - \omega_n^\alpha \mathbf{\Delta} ) | \mathbf{X}_n^\alpha, \mathbf{Y}_n^\alpha \rangle = 0 \]

其中,上式定义

\[\begin{split} \mathbf{\Lambda}^\alpha = \begin{pmatrix} \mathbf{A}^\alpha & \mathbf{B}^\alpha \\ \mathbf{B}^\alpha & \mathbf{A}^\alpha \end{pmatrix} , \quad \mathbf{\Delta} = \begin{pmatrix} \mathbf{1} & \mathbf{0} \\ \mathbf{0} & -\mathbf{1} \end{pmatrix} \end{split}\]

以及右矢 \(| \mathbf{X}_n^\alpha, \mathbf{Y}_n^\alpha \rangle\) 可以看作列向量;相对应地,左矢 \(\langle \mathbf{X}_n^\alpha, \mathbf{Y}_n^\alpha |\) 可以看作行向量。

经过上述定义,特征向量的归一化条件可以写为

\[ \langle \mathbf{X}_n^\alpha, \mathbf{Y}_n^\alpha | \mathbf{\Delta} | \mathbf{X}_n^\alpha, \mathbf{Y}_n^\alpha \rangle = \frac{1}{2} \]

因此,我们可以从相关能表达式 (10) 出发,写出其中的第一项贡献:

\[\begin{split} \begin{align} E_\mathrm{c} [\rho] &\leftarrow \frac{1}{2} \int_0^1 \, \mathrm{d} \alpha \iint \, \mathrm{d} \boldsymbol{r}\, \mathrm{d} \boldsymbol{r}' \sum_{n} \frac{\rho_{0n}^{\alpha} (\boldsymbol{r}) \rho_{0n}^{\alpha, \dagger} (\boldsymbol{r}')}{| \boldsymbol{r} - \boldsymbol{r}' |} \\ &= \frac{1}{2} \cdot 4 \int_0^1 \, \mathrm{d} \alpha \iint \, \mathrm{d} \boldsymbol{r}\, \mathrm{d} \boldsymbol{r}' \sum_{n} (X_{n, ia}^\alpha + Y_{n, ia}^\alpha) \cdot \sum_{ia, jb} \frac{\phi_i (\boldsymbol{r}) \phi_a (\boldsymbol{r}) \phi_j (\boldsymbol{r}') \phi_b (\boldsymbol{r}')}{| \boldsymbol{r} - \boldsymbol{r}' |} \cdot (X_{n, jb}^\alpha + Y_{n, jb}^\alpha) \\ &= \frac{1}{2} \cdot 4 \int_0^1 \sum_{n} \langle \mathbf{X}_n^\alpha, \mathbf{Y}_n^\alpha | \mathbf{g} \mathbf{I} | \mathbf{X}_n^\alpha, \mathbf{Y}_n^\alpha \rangle \, \mathrm{d} \alpha \end{align} \end{split}\]

其中,我们定义了双电子张量以及分块单位矩阵

\[\begin{split} g_{ia, jb} = (ia|jb), \quad \mathbf{I} = \begin{pmatrix} \mathbf{1} & \mathbf{1} \\ \mathbf{1} & \mathbf{1} \end{pmatrix} \end{split}\]

那么我们会写

\[\begin{split} \mathbf{g} \mathbf{I} = \begin{pmatrix} \mathbf{g} & \mathbf{g} \\ \mathbf{g} & \mathbf{g} \end{pmatrix} \end{split}\]

以及上式的 4 倍是从两个闭壳层跃迁密度的 2 倍系数相乘获得的。

对于式 (10) 的第二项同理。因此,我们可以写

\[ \begin{equation} E_\mathrm{c} [\rho] = \frac{1}{2} \cdot 4 \int_0^1 \sum_{n} \langle \mathbf{X}_n^\alpha, \mathbf{Y}_n^\alpha | \mathbf{gI} | \mathbf{X}_n^\alpha, \mathbf{Y}_n^\alpha \rangle \, \mathrm{d} \alpha - \frac{1}{2} \cdot 4 \sum_{n} \langle \mathbf{X}_n^{\alpha=0}, \mathbf{Y}_n^{\alpha=0} | \mathbf{gI} | \mathbf{X}_n^{\alpha=0}, \mathbf{Y}_n^{\alpha=0} \rangle \tag{14} \end{equation} \]

TD 方程的 Hellmann-Feynman 定理应用#

对于上式 (14),其第一项中的关于绝热路径参数 \(\alpha\) 的积分相对来说比较难处理。但很有意思的是,对 TD 方程应用 Hellmann-Feynman 定理求取关于 \(\alpha\) 的母函数,可以解决这个问题。

\[\begin{split} \begin{align} 0 &= \frac{\mathrm{d}}{\mathrm{d} \alpha} \langle \mathbf{X}_n^\alpha, \mathbf{Y}_n^\alpha | ( \mathbf{\Lambda}^\alpha - \omega_n^\alpha \mathbf{\Delta} ) | \mathbf{X}_n^\alpha, \mathbf{Y}_n^\alpha \rangle \\ &= \langle \mathbf{X}_n^\alpha, \mathbf{Y}_n^\alpha | \frac{\mathrm{d}}{\mathrm{d} \alpha} ( \mathbf{\Lambda}^\alpha - \omega_n^\alpha \mathbf{\Delta} ) | \mathbf{X}_n^\alpha, \mathbf{Y}_n^\alpha \rangle \\ &= \langle \mathbf{X}_n^\alpha, \mathbf{Y}_n^\alpha | \frac{\mathrm{d}}{\mathrm{d} \alpha} \mathbf{\Lambda}^\alpha | \mathbf{X}_n^\alpha, \mathbf{Y}_n^\alpha \rangle - \frac{1}{2} \frac{\mathrm{d} \omega_n^\alpha}{\mathrm{d} \alpha} \end{align} \end{split}\]

我们之前提到过,在 dRPA 相关能计算中会将 \(f^{\mathrm{xc}, \alpha}_{ia, jb}\) 完全近似为零,因此

\[\begin{split} \frac{\mathrm{d}}{\mathrm{d} \alpha} \mathbf{\Lambda}^\alpha = \frac{\mathrm{d}}{\mathrm{d} \alpha} \begin{pmatrix} \mathbf{A}^\alpha & \mathbf{B}^\alpha \\ \mathbf{B}^\alpha & \mathbf{A}^\alpha \end{pmatrix} \simeq 2 \mathbf{g} \begin{pmatrix} \mathbf{1} & \mathbf{1} \\ \mathbf{1} & \mathbf{1} \end{pmatrix} = 2 \mathbf{gI} \end{split}\]

因此,

\[ \frac{\mathrm{d} \omega_n^\alpha}{\mathrm{d} \alpha} \simeq 4 \langle \mathbf{X}_n^\alpha, \mathbf{Y}_n^\alpha | \mathbf{gI} | \mathbf{X}_n^\alpha, \mathbf{Y}_n^\alpha \rangle \]

基于上述推导,我们可以将式 (14) 的相关能再写作

\[\begin{split} \begin{align} E_\mathrm{c} [\rho] &\simeq \frac{1}{2} \int_0^1 \sum_{n} \frac{\mathrm{d} \omega_n^\alpha}{\mathrm{d} \alpha} \, \mathrm{d} \alpha - \frac{1}{2} \cdot 4 \sum_{n} \langle \mathbf{X}_n^{\alpha=0}, \mathbf{Y}_n^{\alpha=0} | \mathbf{gI} | \mathbf{X}_n^{\alpha=0}, \mathbf{Y}_n^{\alpha=0} \rangle \\ &= \frac{1}{2} \left( \sum_{n} \omega_n^{\alpha = 1} - \sum_{n} \omega_n^{\alpha = 0} \right) - \frac{1}{2} \cdot 4 \sum_{n} \langle \mathbf{X}_n^{\alpha=0}, \mathbf{Y}_n^{\alpha=0} | \mathbf{gI} | \mathbf{X}_n^{\alpha=0}, \mathbf{Y}_n^{\alpha=0} \rangle \tag{15} \end{align} \end{split}\]

上式的 \(\alpha = 1\) 的情形对应的即是 PySCF 的 dRPA 方法,这在前文中已经作了足够多描述了。而事实上,上式中所有 \(\alpha = 0\) 部分的贡献之和的相反数恰好是 PySCF 的 dTDA 方法对应的激发频率之和。我们马上说明这个问题。

绝热路径 \(\alpha = 0\) 贡献与 dTDA 激发频率的关系#

式 (15) 所表达的相关能从表达上,显然可以简化为

\[ E_\mathrm{c} [\rho] \simeq \frac{1}{2} \left( \sum_{n} \omega_n^{\alpha = 1} - \sum_{n} \omega_n^{\alpha = 0} \right) - \frac{1}{2} \sum_{n} \frac{\mathrm{d} \omega_n^{\alpha = 0}}{\mathrm{d} \alpha} \]

但我们刻意没有使用该式。我们知道,对于 \(\omega_n^{\alpha = 1}, \omega_n^{\alpha = 0}\),是可以用类 TD-KS 方程求解的,并且我们将 \(f_{ia, jb}^{\mathrm{xc}, \alpha}\) 近似为零,因此相当于是已知量了。但 \(\mathrm{d} \omega_n^{\alpha = 0} / \mathrm{d} \alpha\) 表现的是一种随着 \(\alpha\) 值变化的趋势;我们对 \(\alpha\) 固定的情况可以有充分的了解,但其变化我们很难把握。事实上,之前的公式都是在尽力避免出现可变的 \(\alpha\) 值,而尽量将 \(\alpha\) 变为 0 或 1。因此,我们之后的尝试不应是对表现 \(\alpha\) 趋势的 \(\mathrm{d} \omega_n^{\alpha = 0} / \mathrm{d} \alpha\) 作讨论,而是对固定了 \(\alpha = 0\)\(\langle \mathbf{X}_n^{\alpha=0}, \mathbf{Y}_n^{\alpha=0} | \mathbf{g} | \mathbf{X}_n^{\alpha=0}, \mathbf{Y}_n^{\alpha=0} \rangle\) 作讨论。

我们首先考察 \(\alpha = 0\) 的 A、B 张量:

\[ A_{ia, jb}^{\alpha = 0} = - (\varepsilon_i - \varepsilon_a) \delta_{ij} \delta_{ab}, \quad B_{ia, jb}^\alpha = 0 \]

对于有限基组,\(n\) 作为激发态的角标,其维度是 \(n_\mathrm{occ} n_\mathrm{vir}\);并且事实上,该角标在 \(\alpha = 0\) 是确实地可以与 \(ia\) 角标作对应;我们应当很容易地定义并推知,

\[ \omega_{ia}^{\alpha = 0} = - (\varepsilon_i - \varepsilon_a) \]

就等价于轨道能的差减。其本征函数也是非常容易推知的:

\[ X_{jb, ia}^{\alpha = 0} = \frac{1}{\sqrt{2}} \delta_{ij} \delta_{ab}, \quad Y_{jb, ia}^{\alpha = 0} = 0 \]

上式的 \(1/\sqrt{2}\) 的意义在于满足归一化条件 \(\langle \mathbf{X}_n^{\alpha = 0}, \mathbf{Y}_n^{\alpha = 0} | \mathbf{\Delta} | \mathbf{X}_n^{\alpha = 0}, \mathbf{Y}_n^{\alpha = 0} \rangle = 1 / 2\)。因此,

\[\begin{split} \begin{align} &\quad 4 \sum_{n} \langle \mathbf{X}_n^{\alpha=0}, \mathbf{Y}_n^{\alpha=0} | \mathbf{gI} | \mathbf{X}_n^{\alpha=0}, \mathbf{Y}_n^{\alpha=0} \rangle \\ &= 4 \sum_{ia, jb, n=kc} (X_{kc, ia}^{\alpha = 0} + Y_{kc, ia}^{\alpha = 0}) (ia|jb) (X_{kc, jb}^{\alpha = 0} + Y_{kc, jb}^{\alpha = 0}) \\ &= 2 \sum_{ia, jb, kc} \delta_{ki} \delta_{ca} (ia|jb) \delta_{kj} \delta_{cb} = 2 \sum_{kc} (kc|kc) \\ &= 2 \sum_{ia} (ia|ia) \end{align} \end{split}\]

因此,

\[ \sum_{n \neq 0} \big( \omega_n^{\alpha = 0} + 4 \langle \mathbf{X}_n^{\alpha=0}, \mathbf{Y}_n^{\alpha=0} | \mathbf{gI} | \mathbf{X}_n^{\alpha=0}, \mathbf{Y}_n^{\alpha=0} \rangle \big) = \sum_{ia} \big( - (\varepsilon_i - \varepsilon_a) + 2 (ia|ia) \big) \]

我们再回顾一下 dTDA 激发能的公式:

\[ \sum_n \omega^\mathsf{dTDA}_n = \mathrm{tr} (\mathbf{A}^\mathsf{dTDA}) \]

\(A_{ia, jb}^\mathsf{dTDA} = - (\varepsilon_i - \varepsilon_a) \delta_{ij} \delta_{ab} + 2 (ia|jb)\),因此其迹恰好就有

\[ \sum_{n \neq 0} \big( \omega_n^{\alpha = 0} + 4 \langle \mathbf{X}_n^{\alpha=0}, \mathbf{Y}_n^{\alpha=0} | \mathbf{gI} | \mathbf{X}_n^{\alpha=0}, \mathbf{Y}_n^{\alpha=0} \rangle \big) = \mathrm{tr} (\mathbf{A}^\mathsf{dTDA}) = \sum_n \omega^\mathsf{dTDA}_n \]

而我们又陈述过 \(\omega_n^\alpha = \omega_n^\mathsf{dRPA}\),因此式 (15) 所表达的相关能则可以写为

\[ E_\mathrm{c} [\rho] = \frac{1}{2} \sum_{n} ( \omega_n^\mathsf{dRPA} - \omega_n^\mathsf{dTDA} ) \]

事实上,上述推导过程并没有真正地用到 dTDA 激发近似的原理和思路,只是结论上恰好一致而已。真正用到的近似是 dRPA 激发近似,即将 \(f_{ia, jb}^{\mathrm{xc}, \alpha}\) 近似为零。

至此,从类 TD-KS 方程角度出发解释的相关能推导,就已经全部完成了。

程序实现:\(O(N^4)\) 的 RI 方法#

关于该方法的程序实现,主要是参考 Ren, Scheffler et al. [1] 的文献。但该文献中的一些公式的表达不太友好,也有部分公式错误,阅读时可能需要留些心眼。

拓展的 Gauss-Legendre 格点#

关于该 RI 的实现,我们需要先对格点积分作了解。在这里我们采用 Gauss-Legendre 格点。

Gauss-Legendre 格点的使用区间是 \((-1, 1)\)。如果我们定义格点下标是 \(g\),以及坐标是 \(x_g\) 与对应的权重 \(w_g\),那么连续积分可以近似为格点积分

\[ \int_{-1}^1 f(x) \, \mathrm{d} x \simeq \sum_g w_g f(x_g) \]

Gauss-Legendre 格点坐标 x 与权重 w 在 numpy 中可以通过如下方式生成 (定义格点数量为 ngrid \(n_\mathrm{grid} = 100\)):

ngrid = 100
x, w = np.polynomial.legendre.leggauss(ngrid)

我们可以拿 \(f(x) = x^2\) 作尝试。其在 \((-1, 1)\) 区间中的积分值应为 \(2/3\)

f = lambda x: x**2
(w * f(x)).sum()
0.6666666666666605

但我们实际需要处理的格点是 \((0, +\infty)\) 的区间。为此,我们需要对格点作一些变动。首先我们作映射 \(h: (-1, 1) \mapsto (0, +\infty)\)

\[ \tilde \omega = h(x) = \frac{1}{2} \frac{1 + x}{1 - x} \]

那么原先的积分就可以写为

\[ \int_0^{+\infty} f(\tilde \omega) \, \mathrm{d} \tilde \omega = \int_{-1}^1 f(\tilde \omega) h'(x) \, \mathrm{d} x \simeq \sum_g h'(x_g) w_g f(\tilde \omega_g) \]

我们就随后定义作映射后的坐标格点 ot \(\tilde \omega_g\) 与权重格点 wt \(\tilde w_g\)。其定义很容易从上两式推知:

\[ \tilde \omega_g = \frac{1}{2} \frac{1 + x_g}{1 - x_g}, \quad \tilde w_g = h'(x_g) w_g = \frac{w_g}{{1 - x_g}^2} \]
ot = 0.5 * (1 + x) / (1 - x)
wt = w / (1 - x)**2

我们可以拿 \(f(\tilde \omega) = \exp(- \tilde \omega^2)\) 作尝试。其在 \((0, +\infty)\) 区间中的积分值应为 \(\sqrt{\pi} / 2\)

f = lambda x : np.exp(-x**2)
(wt * f(ot)).sum()
0.8862269254527606
np.sqrt(np.pi) / 2
0.8862269254527579

以后的积分中,会使用到上述经过拓展的 Gauss-Legendre 格点坐标 \(\tilde \omega_g\) 与权重 \(\tilde w_g\) 组成的数值导数。

程序实现#

我们首先要给出 DF (Density Fitting) 下的原子轨道与分子轨道积分。这里用的是与 Density Fitting MP2 比较相似的记号与变量名。

  • int2c2e \(\displaystyle J_{PQ} = (P|r_{12}^{-1}|Q) = \iint \frac{\chi_P (\boldsymbol{r}) \chi_Q (\boldsymbol{r}')}{| \boldsymbol{r} - \boldsymbol{r}' |} \, \mathrm{d} \boldsymbol{r} \, \mathrm{d} \boldsymbol{r}'\)

  • int3c2e \(\displaystyle (\mu \nu | P) = \iint \frac{\phi_\mu (\boldsymbol{r}) \phi_\nu (\boldsymbol{r}) \chi_Q (\boldsymbol{r}')}{| \boldsymbol{r} - \boldsymbol{r}' |} \, \mathrm{d} \boldsymbol{r} \, \mathrm{d} \boldsymbol{r}'\)

  • int2c2e_half \(\mathbf{J}^{1/2}\);分数幂次的定义可以具有多种,这里的定义是通过 Cholesky 分解得到的下三角矩阵,满足性质 \(\mathbf{J}^{1/2} (\mathbf{J}^{1/2})^\dagger = \mathbf{J}\)

  • V_df_mp2 \(V_{\mu \nu, P} = \sum_Q (\mu \nu | Q) (\mathbf{J}^{-1/2})_{QP}\)

  • V_df_ia \(V_{ia, P} = \sum_{\mu \nu} V_{\mu \nu, Q} C_{\mu i} C_{\nu a}\)

int2c2e = mol_df.intor("int2c2e")
int2c2e.shape
int3c2e = df.incore.aux_e2(mol, mol_df)
int2c2e_half = scipy.linalg.cholesky(int2c2e, lower=True)
V_df_mp2 = scipy.linalg.solve_triangular(int2c2e_half, int3c2e.reshape(-1, naux).T, lower=True).reshape(naux, nao, nao).transpose((1, 2, 0))
V_df_ia = np.einsum("uvP, ui, va -> iaP", V_df_mp2, Co, Cv)

其中,积分 V_df_ia \(V_{ia,P}\) 具有比较容易验证的下述性质:

\[ \sum_{P} V_{ia,P} V_{jb,P} \simeq (ia|jb) \]

由于格点求和与 RI 的近似是一种数值上的近似,其大小预期不会很大;因此我们自此以后对这些情况都会直接用等号而非近似等号。

上述积分中,求取计算复杂度最大的应是 \(V_{\mu \nu, P}\) 一项 (\(O(T n_\mathrm{AO}^2 n_\mathrm{aux}^2)\),其中 \(T\) 表示的是方程求解的迭代次数,体系较小时可以看作常数)。

随后我们定义函数 Pi \(\Pi_{PQ} (\tilde \omega)\) (输出矩阵维度为 \((P, Q)\))

\[ \Pi_{PQ} (\tilde \omega) = - \sum_{ia} \frac{4 V_{ia, P} V_{ia, Q} D_{ia}}{(D_{ia})^2 + \tilde \omega^2} \]

其中,D_ia \(D_{ia} = - \varepsilon_i + \varepsilon_a\),对应的是 \(\alpha = 0\) 时的激发能。注意该记号不是密度矩阵记号。处于方便,我们同时定义 D \(D_{ia, jb} = D_{ia} \delta_{ij} \delta_{ab}\),以后表示为矩阵的粗体 \(\mathbf{D}\) 一般指代以 \(D_{ia, jb}\) 为矩阵元的矩阵。

D_ia = - eo[:, None] + ev[None, :]
D = D_ia.flatten() * np.eye(nocc*nvir)

我们也定义变量 V \(V_{ia, P}\) 为两维度 \((ia, P)\)

V = V_df_ia.reshape(nocc*nvir, naux)

那么 Pi \(\Pi_{PQ} (\tilde \omega)\) 的函数就可以定义为 (注意到 \(\mathbf{D}\) 是对角阵,因此矩阵逆等价于元素倒数):

\[ \begin{equation} \mathbf{\Pi} (\tilde \omega) = - 4 \mathbf{V}^\dagger \mathbf{D}^{1/2} (\mathbf{D} + \tilde \omega^2 \mathbf{I})^{-1} \mathbf{D}^{1/2} \mathbf{V} \tag{16} \end{equation} \]
Pi = lambda omega: - 4 * V.T @ D**0.5 @ (np.eye(nocc*nvir) / (D**2 + omega**2)) @ D**0.5 @ V

上述计算的复杂度为 \(O(n_\mathrm{occ} n_\mathrm{vir} n_\mathrm{aux}^2)\);由于后续还有格点积分,因此该方法的总复杂度应为 \(O(n_\mathrm{grid} n_\mathrm{occ} n_\mathrm{vir} n_\mathrm{aux}^2)\)。但若我们将格点大小看作常量,那么该方法的计算量相当于是代价稍大的 \(O(N^4)\)

dRPA 相关能可以用下述方式给出 (Ren, NJP, eq 60):

\[\begin{split} \begin{align} E_\mathrm{c}^\mathsf{dRPA} [\rho] &= \frac{1}{2 \pi} \int_{0}^{+ \infty} \big( \log \det \big( \mathbf{1} - \mathbf{\Pi} (\tilde \omega) \big) + \mathrm{tr} \big( \mathbf{\Pi} (\tilde \omega) \big) \big) \, \mathrm{d} \tilde \omega \\ &= \frac{1}{2 \pi} \sum_{g} \tilde w_g \big( \log \det \big( \mathbf{1} - \mathbf{\Pi} (\tilde \omega_g) \big) + \mathrm{tr} \big( \mathbf{\Pi} (\tilde \omega_g) \big) \big) \end{align} \end{split}\]
eng_dRPA_RI = 0
for omega, weight in zip(ot, wt):
    eng_dRPA_RI += 1 / (2 * np.pi) * weight * (np.log(np.linalg.det(np.eye(naux) - Pi(omega))) + Pi(omega).trace())
eng_dRPA_RI
-0.4312694046712164

这是非常有意思的结论。我们不妨对照一下 \(O(N^6)\) 的计算式 (5),只是用 \(\alpha = 1\) 替换了方法上标:

\[ E^\mathsf{dRPA}_\mathrm{c} = \frac{1}{2} \mathrm{tr} \big( ((\mathbf{A}^{\alpha = 1} - \mathbf{B}^{\alpha = 1}) (\mathbf{A}^{\alpha = 1} + \mathbf{B}^{\alpha = 1}))^{1/2} - \mathbf{A}^{\alpha = 1} \big) \]
0.5 * (fractional_matrix_power((A(1) - B(1)) @ (A(1) + B(1)), 0.5) - A(1)).trace()
-0.43137922116708344

这两个值非常接近;只是 RI 与格点积分上有数值上的误差,其中 RI 的误差应当认为要更大;但从公式的表达上,相差可谓巨大。事实上,如果我们定义下述 RI 近似下的 \(\mathbf{A}^{\alpha = 1}\)\(\mathbf{B}^{\alpha = 1}\),这两种方法之间的值近乎于是一致的 (误差小于 \(10^{-9}\)),即从数学的表达上,两种计算方法严格等价而非近似相等。

A_RI = D + 2 * V @ V.T
B_RI = 2 * V @ V.T
0.5 * np.trace(fractional_matrix_power((A_RI - B_RI) @ (A_RI + B_RI), 0.5) - A_RI)
-0.4312694046826844

数学说明:RI 方法与直接方法等价#

如果我们用涨落耗散理论 (FDT, Fluctuation Dissipation Theorem),那么或许会很容易地导出 \(E_\mathrm{c}^\mathsf{dRPA} [\rho]\) 关于拟频率 \(\tilde \omega\) 的积分表达式 (之所以称为拟频率,是因为它本身未必具有真实的物理意义)。我们之后也会提到涨落耗散理论的推导方式,它与类 TD-KS 方程一样,都是将 \(f_{ia, jb}^\mathrm{xc}\) 完全近似为零;但严格的等价性未必容易证明。

这里用数学的方式,对这两种不同途径的等价性作较为严格的证明。

背景知识:矩阵函数#

我们需要一些加深的矩阵代数知识。首先我们要对矩阵函数进行考察。但不像数学的推导,我们会大幅地以当前的问题来限制讨论的范畴,譬如要求一些变量具有对称实方形矩阵、只处理复函数的一个单值支、函数具有大体全纯的性质等等。

若有函数 \(f : \mathbb{C} \mapsto \mathbb{C}\),那么定义 \(f(\mathbf{S})\) 为方形矩阵到其相同维度矩阵的映射:

\[ f(\mathbf{S}) = \mathbf{F} f(\mathbf{\Lambda}) \mathbf{F}^\dagger \]

其中,\(\mathbf{S}\) 为对称方形矩阵,\(\mathbf{\Lambda}\)\(\mathbf{S}\) 的本征值为对角元的对角矩阵,\(\lambda_i\) 表示其本征值,\(\mathbf{F}\) 为其对应的本征向量的列并矩阵;对于对角矩阵 \(\mathbf{\Lambda}\),其矩阵函数定义为 (这里的 \(i\) 仅仅是任意角标的意义,与占据轨道暂时无关)

\[ f(\mathbf{\Lambda})_{ii} = f(\lambda_{i}) \]

上式左边是矩阵函数,右边则是复函数。

一个容易证明的性质,是矩阵函数与复函数在级数展开上具有近乎相同的形式。如果复函数具有展开式 (Taylor)

\[ f(z) = \sum_{n = 0}^\infty \frac{a_n}{n!} z^n \]

那么就有

\[ f(\mathbf{\Lambda})_{ii} = f(\lambda_i) = \sum_{n = 0}^\infty \frac{a_n}{n!} \lambda_i^n = \sum_{n = 0}^\infty \frac{a_n}{n!} (\mathbf{\Lambda}^n)_{ii} = \left( \sum_{n = 0}^\infty \frac{a_n}{n!} \mathbf{\Lambda}^n \right)_{ii} \]

\[ \quad f(\mathbf{\Lambda}) = \sum_{n = 0}^\infty \frac{a_n}{n!} \mathbf{\Lambda}^n \]

从而,任意对称方形矩阵下,

\[ f(\mathbf{S}) = \mathbf{F} f(\mathbf{\Lambda}) \mathbf{F}^\dagger = \sum_{n = 0}^\infty \frac{a_n}{n!} \mathbf{F} \mathbf{\Lambda}^n \mathbf{F}^\dagger = \sum_{n = 0}^\infty \frac{a_n}{n!} (\mathbf{F} \mathbf{\Lambda} \mathbf{F}^\dagger)^n = \sum_{n = 0}^\infty \frac{a_n}{n!} \mathbf{S}^n \]

尽管若使用级数展开形式定义矩阵函数会更加直观,但由于收敛域,有许多函数是无法通过 Taylor 展开式在全复平面定义,譬如 \(1 / (1 - z)\)\(\log (1 - z)\) (它们的收敛域都是 \(U(0, 1)\) 即以 \(0\) 为圆心,\(1\) 为半径的区域)。在后续的推导中,我们也会利用到这些函数;因此,我们不能用普通的 Taylor 级数来定义矩阵函数。

下面我们举一个实际的例子。我们令 S \(\mathbf{S}\) 为对称矩阵,其本征向量列并矩阵为 F \(\mathbf{F}\),本征值为 eig \(\mathbf{\lambda}\)

S = X + X.T
S
array([[ 3.5281 , -0.57712,  1.12278,  2.57457, -0.68543],
       [-0.57712,  1.90018,  1.30292,  1.39086,  1.06422],
       [ 1.12278,  1.30292,  1.52208, -0.08348,  1.3083 ],
       [ 2.57457,  1.39086, -0.08348,  0.62614, -1.59626],
       [-0.68543,  1.06422,  1.3083 , -1.59626,  4.53951]])
eig, F = np.linalg.eigh(S)
eig
array([-2.04798,  0.5282 ,  2.32882,  4.94627,  6.36069])

我们注意到其中一个本征值为负值。现在我们希望求取 \(\log (\mathbf{S})\)。这里的 \(\log\) 是自然对数;矩阵的自然对数可以通过 scipy.linalg.logm 实现:

scipy.linalg.logm(S)
array([[ 1.3316 +0.54623j,  0.03852+0.58344j,  0.30552-0.33227j,  0.32127-0.96179j, -0.25551-0.20449j],
       [ 0.03852+0.58344j,  0.91848+0.62318j,  0.3704 -0.35491j, -0.01582-1.02731j,  0.15069-0.21842j],
       [ 0.30552-0.33227j,  0.3704 -0.35491j,  0.0302 +0.20212j,  0.48283+0.58506j,  0.7177 +0.12439j],
       [ 0.32127-0.96179j, -0.01582-1.02731j,  0.48283+0.58506j,  0.8125 +1.69351j, -0.42136+0.36006j],
       [-0.25551-0.20449j,  0.15069-0.21842j,  0.7177 +0.12439j, -0.42136+0.36006j,  1.27993+0.07655j]])

可以看出这是一个复数矩阵。如果我们定义 \(\log\) 函数为 (该定义可以通过 np.lib.scimath.log 实现)

\[ \log (z) = \log(r) + i \theta \]

其中,\(z = r e^{i \theta}\)\(r\), \(\theta\) 分别是复数 \(z\) 的模与幅角,\(\theta \in (-\pi, \pi]\)

np.lib.scimath.log(eig)
array([ 0.71685+3.14159j, -0.63828+0.j     ,  0.84536+0.j     ,  1.59863+0.j     ,  1.85014+0.j     ])

那么我们通过 \(\log (\mathbf{S}) = \mathbf{F} \log (\mathbf{\Lambda}) \mathbf{F}^\dagger\) 就可以给出任意实对称矩阵的对数了。

np.allclose(
    F @ np.diag(np.lib.scimath.log(eig)) @ F.T,
    scipy.linalg.logm(S)
)
True

最后指出,对于非对称的方形矩阵,可以使用 Jordan 标准型来定义。因此,对于任意复数的矩阵 \(\mathbf{S} \in \mathbb{C}^{n \times n}\),其函数矩阵一般总是可以被定义。当然,我们之后还是讨论对称实方形矩阵的情况。

矩阵函数性质:对数迹与行列式的关系#

我们接下来陈述一个结论:

\[ \mathrm{tr} (\log(\mathbf{S})) = \log (\det (\mathbf{S})) \]
scipy.linalg.logm(S).trace()
(4.37270601078762+3.141592653589795j)
np.lib.scimath.log(np.linalg.det(S))
(4.372706010787613+3.141592653589793j)

简述如下。注意到作为本征向量列并矩阵,有性质 \(\mathbf{F} \mathbf{F}^\dagger = \mathbf{F}^\dagger \mathbf{F} = \mathbf{I}\),因此

\[\begin{split} \begin{align} \mathrm{tr} (\log (\mathbf{S})) &= \mathrm{tr} (\log (\mathbf{F \Lambda F}^\dagger)) = \mathrm{tr} (\mathbf{F} \log (\mathbf{\Lambda}) \mathbf{F}^\dagger) = \mathrm{tr} (\log (\mathbf{\Lambda})) \\ &= \sum_i \log (\lambda_i) = \log \left( \prod_i \lambda_i \right) \\ &= \log (\det (\mathbf{\Lambda})) = \log (\det (\mathbf{S})) \tag{17} \end{align} \end{split}\]

Cauchy 积分定理与 \(z^{-1/2}\) 的积分展开#

我们首先陈述复函数的 Cauchy-Goursat 积分定理:

\[ f(z) = \frac{1}{2 \pi i} \int_{\partial \Omega} \frac{f(\zeta)}{\zeta - z} \, \mathrm{d} \zeta \]

上式中,\(\Omega\) 为复平面的开集,其边界是分段光滑的 (简单的),其边界有向曲线令为 \(\partial \Omega\) (定义边界的左边围起区域 \(\Omega\))。\(f \in \mathscr{O} (\Omega)\) 即在 \(\Omega\) 上全纯 (或称为解析的,analytical,即复可微或称满足 Cauchy-Riemann 方程的),在闭集 \(\bar \Omega = \Omega \cup \partial \Omega\) 上连续。

在继续讨论之前,我们指出上述定理是著名的 Cauchy 积分定理的强化。Cauchy 积分定理的条件是 \(f \in \mathscr{O} (\bar \Omega)\),条件更为苛刻也更为适用。我们在这篇文档中只会用到 Cauchy 积分定理,但了解 Cauchy-Goursat 的应用条件仍然是有意义的。

我们实际会使用到的例子是 \(f(z) = z^{-1/2}\),其中 \(z > 0\)。由于我们讨论的是复平面函数,因此我们有必要对该函数作更为清晰的定义。

Image(filename="assets/integral_path.png", width=400)
_images/5ba40000af46ae36b15b7d4df1357dfdc13f8aabab78f820a4560f112900e118.png

该函数是一个多值函数,需要确定其单值支。如果我们仍然令 \(z = r e^{i \theta}\),其中模长 \(r \geqslant 0\),幅角 \(\theta \in (-\pi, \pi]\)。以及,

\[ f(z) = \frac{1}{\sqrt{z}} = \frac{1}{\sqrt{r}} e^{- i \frac{\theta}{2}} \]

由于该函数存在单值支的问题,在 \(\{ z = r e^{i \theta} | r = 0 \text{ or } \theta = \pi \}\) 下 (即上图的红色点线),函数是不连续的。在 \(r = 0\) 处是因为 \(r^{-1/2}\) 处未定义;\(\theta = \pi\) 处是因为举例来说,对于复平面上比较相邻的两个数 \(\omega + i \eta, \omega - i \eta\) (实数轴上方与下方些微处的两点),其中 \(\omega < 0, \eta > 0\),则

\[ \lim_{\eta \rightarrow 0^+} \big( f(\omega + i \eta) - f(\omega - i \eta) \big) = - \pi \]

因此显然在 \(z = x < 0\) 点处是不可导的。对于其它位置,\(f(x)\) 是全纯的,以及单值支的确切定义,在此不作说明。

接下来,我们讨论当 \(z > 0\) 时,利用 Cauchy 积分定理计算 \(f(z)\) 的值的问题。我们定义如上图所示的 \(\partial \Omega = \Gamma_1 \cup \Gamma_r \cup \Gamma_2 \cup \Gamma_R\) 为下述有向曲线 (围道):

\[\begin{split} \begin{equation}\begin{aligned} \Gamma_r &: \zeta(t) = r e^{- i t} && t \in [- \pi + \arccos(\eta / r), \pi - \arccos(\eta / r)] \\ \Gamma_R &: \zeta(t) = R e^{i t} && t \in [- \pi + \arccos(\eta / R), \pi - \arccos(\eta / R)] \\ \Gamma_1 &: \zeta(t) = \omega + i \eta, t = \omega && t \in (-R, -\sqrt{r^2 - \eta^2}) \\ \Gamma_2 &: \zeta(t) = \omega - i \eta, t = -\omega && t \in (-R, -\sqrt{r^2 - \eta^2}) \\ \end{aligned}\end{equation} \end{split}\]

其中,\(0 < \eta < r < z < R\),且 \(\eta, r\) 应尽可能小,\(R\) 应尽可能大。

我们一根一根有向曲线分析。对于 \(\Gamma_r\) 而言,

\[\begin{split} \begin{align} f(z) &\leftarrow \lim_{\eta < r \rightarrow 0^+} \left| \frac{1}{2 \pi i} \int_{\Gamma_r} \frac{f(\zeta)}{\zeta - z} \, \mathrm{d} \zeta \right| \\ &= \lim_{\eta < r \rightarrow 0^+} \left| \frac{1}{2 \pi i} \int_{- \pi + \arccos (\eta / r)}^{\pi - \arccos (\eta / r)} \frac{r^{-1/2} e^{it / 2}}{r e^{-it} - z} r (-i) e^{-it} \, \mathrm{d} t \right| \\ &= \lim_{\eta < r \rightarrow 0^+} \left| - \frac{1}{2 \pi} \frac{|r^{1/2} e^{- it / 2}|}{|r e^{-it} - z|} \int_{- \pi + \arccos (\eta / r)}^{\pi - \arccos (\eta / r)} \, \mathrm{d} t \right| \\ &\leqslant \lim_{\eta < r \rightarrow 0^+} \frac{1}{2 \pi} \frac{r^{1/2}}{r + z} (2 \pi - 2 \arccos(\eta / r)) \\ &< \lim_{r \rightarrow 0^+} \frac{r^{1/2}}{r + z} = 0 \end{align} \end{split}\]

\(\Gamma_R\) 的分析也是非常类似的,可以证明其贡献也为零。

随后我们考虑 \(\Gamma_1\) 上的积分:

\[\begin{split} \begin{align} f(z) &\leftarrow \lim_{\substack{\eta < r \rightarrow 0^+ \\ R \rightarrow + \infty}} \frac{1}{2 \pi i} \int_{\Gamma_1} \frac{f(\zeta)}{\zeta - z} \, \mathrm{d} \zeta \\ &= \lim_{\substack{\eta < r \rightarrow 0^+ \\ R \rightarrow + \infty}} \frac{1}{2 \pi i} \int_{-R}^{-\sqrt{r^2 - \eta^2}} \frac{(\omega + i \eta)^{-1/2}}{\omega + i \eta - z} \, \mathrm{d} \omega \\ &= \lim_{r \rightarrow 0^+} \frac{1}{2 \pi i} \int_{-\infty}^{-r} \frac{\omega^{-1/2}}{\omega - z} \, \mathrm{d} \omega \end{align} \end{split}\]

如果我们令 \(\omega = (i \tilde \omega)^2\),其中 \(\tilde \omega > 0\),则 \(\Gamma_1\) 的贡献值为

\[\begin{split} \begin{align} f(z) &\leftarrow \lim_{r \rightarrow 0^+} \frac{1}{2 \pi i} \int_{+\infty}^{\sqrt{r}} \frac{- i \tilde \omega^{-1}}{- \tilde \omega^2 - z} (- \tilde \omega) \, \mathrm{d} \tilde \omega = \lim_{r \rightarrow 0^+} \frac{1}{\pi} \int_{\sqrt{r}}^{+\infty} \frac{1}{\tilde \omega^2 + z} \, \mathrm{d} \tilde \omega \\ &= \frac{1}{\pi} \int_{0}^{+\infty} \frac{1}{\tilde \omega^2 + z} \, \mathrm{d} \tilde \omega \end{align} \end{split}\]

对于 \(\Gamma_2\) 的积分也是相同的。因此,最终有

\[ \begin{equation} f(z) = \sqrt{\frac{1}{z}} = \frac{2}{\pi} \int_0^{+\infty} \frac{1}{\tilde \omega^2 + z} \, \mathrm{d} \tilde \omega \tag{18} \end{equation} \]

这看起来是一个非常平凡的结论,因为反向的推导是非常容易的:

\[ \int \frac{1}{\tilde \omega^2 + z} \, \mathrm{d} \tilde \omega = \sqrt{\frac{1}{z}} \arctan \sqrt{\frac{w^2}{z}} + C \]

但它的意义在于,它可以对无法通过 Taylor (或者更广泛地 Laurent) 展开的函数 \(z^{-1/2}\) (或者类似地,\(z^{-1} \log z\)),通过积分的方式化为可展开的形式。

\(\mathbf{M}^{-1/2}\) 的积分展开#

当引入矩阵函数后,我们按照式 (18),对于任意实对称 正定 矩阵 \(\mathbf{S}\),可以写为

\[ \begin{equation} \mathbf{S}^{-1/2} = \frac{2}{\pi} \int_0^{+\infty} \frac{1}{\mathbf{S} + \tilde \omega^2 \mathbf{I}} \, \mathrm{d} \tilde \omega \tag{19} \end{equation} \]

这里我们引入了正定矩阵的要求,是因为之前我们的讨论中一直要求 \(z > 0\),否则 \(\Gamma_1\), \(\Gamma_2\) 的围道积分就不成立了;因此到矩阵函数时,就要求本征值大于零。比如之前定义的 S 矩阵,就有一个小于零的本征值,因此不满足上式:

mat = np.zeros((5, 5))
for (omega, weight) in zip(ot, wt):
    mat += weight * 2 / np.pi * np.linalg.inv(S + omega**2 * np.eye(5))
np.allclose(mat, fractional_matrix_power(S, -0.5))
False

如果我们定义 (回顾上一节 \(O(N^4)\) 程序实现部分的矩阵定义)

\[\begin{split} \begin{align} \mathbf{M} (\alpha) &= (\mathbf{A}^\alpha - \mathbf{B}^\alpha)^{1/2} (\mathbf{A}^\alpha + \mathbf{B}^\alpha) (\mathbf{A}^\alpha - \mathbf{B}^\alpha)^{1/2} \\ &= \mathbf{D}^{1/2} (\mathbf{D} + 2 \alpha \mathbf{V} \mathbf{V}^\dagger) \mathbf{D}^{1/2} \\ &= \mathbf{D}^2 + 2 \alpha \mathbf{D}^{1/2} \mathbf{V} \mathbf{V}^\dagger \mathbf{D}^{1/2} \tag{20} \end{align} \end{split}\]

对于 \(\alpha = 1, 0\) 的情形,它就是正定的矩阵;因此满足式 (18);我们拿 \(\alpha = 1\) 的情形来验证:

mat = np.zeros((nocc*nvir, nocc*nvir))
for (omega, weight) in zip(ot, wt):
    mat += weight * 2 / np.pi * np.linalg.inv(M + omega**2 * np.eye(nocc*nvir))
np.allclose(mat, fractional_matrix_power(M, -0.5))
True

建立关于耦合常数 \(\alpha\) 的积分#

我们已经建立了从 \(\mathbf{M}^{-1/2}\)\(\mathbf{M}\) 的联系,即将分数幂次转为整数幂次的积分展开。但我们记得相关能表达式为

\[\begin{split} \begin{align} E_\mathrm{c}^\mathsf{dRPA} [\rho] &= \frac{1}{2} \mathrm{tr} \big( (\mathbf{M}^{1/2}) - \mathrm{tr} (\mathbf{A}^{\alpha = 1}) \big) \\ &= \frac{1}{2 \pi} \int_{0}^{+ \infty} \big( \log \det \big( \mathbf{1} - \mathbf{\Pi} (\tilde \omega_g) \big) + \mathrm{tr} \big( \mathbf{\Pi} (\tilde \omega_g) \big) \big) \, \mathrm{d} \tilde \omega \end{align} \end{split}\]

因此我们还要建立某种联系,使得表达式中存在 \(\log\)

这种联系的构建的方式与 Hellmann-Feynman 定理非常相似。我们首先表明,

\[ A_{ia, ia}^{\alpha = 1} = - \varepsilon_i + 2 \varepsilon_a + (ia|ia) = D_{ia} + 2 \sum_P V_{ia, P} V_{ia, P} \]

因此,

\[ \mathrm{tr} (\mathbf{A}^{\alpha = 1}) = \mathrm{tr} (\mathbf{D} + 2 \mathbf{V} \mathbf{V}^\dagger) = \mathrm{tr} ((\mathbf{D}^2)^{1/2}) + 2 \mathrm{tr} (\mathbf{V}^\dagger \mathbf{V}) \]

我们留意到上一小节 \(\mathbf{M}(\alpha)\) 的定义,知道 \(\mathbf{M}(0) = \mathbf{D}^2, \mathbf{M}(1) = \mathbf{M}\),因此相关能可以写为

\[\begin{split} \begin{align} E_\mathrm{c}^\mathsf{dRPA} [\rho] &= \frac{1}{2} \big( \mathrm{tr} (\mathbf{M}^{1/2}) - \mathrm{tr} (\mathbf{A}^{\alpha = 1}) \big) = \frac{1}{2} \big( \mathrm{tr} (\mathbf{M}(1)^{1/2}) - \mathrm{tr} (\mathbf{M}(0)^{1/2}) - 2 \mathrm{tr} (\mathbf{V}^\dagger \mathbf{V}) \big) \\ &= \frac{1}{2} \mathrm{tr} \left( \int_0^1 \frac{\mathrm{d} \mathbf{M} (\alpha)^{1/2}}{\mathrm{d} \alpha} \, \mathrm{d} \alpha \right) - \mathrm{tr} (\mathbf{V}^\dagger \mathbf{V}) \tag{21} \end{align} \end{split}\]

我们回顾到在推导 \(O(N^6)\) 计算量方法时,曾经使用 Hellmann-Feynman 定理,将 \(\mathrm{d} \omega_n^\alpha / \mathrm{d} \alpha\) 转为 \(\omega_n^{\alpha = 1} - \omega_n^{\alpha = 0}\),从而将不容易求取的关于 \(\alpha\) 的积分转为可以通过类 CP-KS 方程可以求得的频率。但我们在 \(O(N^4)\) 方法时,由于矩阵的导数比较容易求取,因此反而使用了相反的过程。

首先,我们处理矩阵的导数迹。现在对于矩阵及其本征分解式 \(\mathbf{S} = \mathbf{F} \mathbf{\Lambda} \mathbf{F}^\dagger\),如果 \(\mathbf{S}\) 可以看作关于 \(\alpha\) 的矩阵变量,那么

\[\begin{split} \begin{align} \mathrm{tr} \left( \frac{\mathrm{d} \mathbf{S}}{\mathrm{d} \alpha} \right) &= \mathrm{tr} \left( \frac{\mathrm{d} \mathbf{F}}{\mathrm{d} \alpha} \mathbf{\Lambda} \mathbf{F}^\dagger + \mathbf{F} \frac{\mathrm{d} \mathbf{\Lambda}}{\mathrm{d} \alpha} \mathbf{F}^\dagger + \mathbf{F} \mathbf{\Lambda} \frac{\mathrm{d} \mathbf{F}^\dagger}{\mathrm{d} \alpha} \right) \\ &= \mathrm{tr} \left( \frac{\mathrm{d} (\mathbf{FF}^\dagger)}{\mathrm{d} \alpha} \mathbf{\Lambda} \right) + \mathrm{tr} \left( \frac{\mathrm{d} \mathbf{\Lambda}}{\mathrm{d} \alpha} \right) \\ &= \mathrm{tr} \left( \frac{\mathrm{d} \mathbf{\Lambda}}{\mathrm{d} \alpha} \right) \end{align} \end{split}\]

类似地,

\[ \begin{align} \mathrm{tr} \left( \frac{\mathrm{d} \mathbf{S}^{1/2}}{\mathrm{d} \alpha} \right) = \mathrm{tr} \left( \frac{\mathrm{d} \mathbf{\Lambda}^{1/2}}{\mathrm{d} \alpha} \right) = \frac{1}{2} \mathrm{tr} \left( \frac{\mathrm{d} \mathbf{\Lambda}}{\mathrm{d} \alpha} \mathbf{\Lambda}^{-1/2} \right) = \frac{1}{2} \mathrm{tr} \left( \frac{\mathrm{d} \mathbf{S}}{\mathrm{d} \alpha} \mathbf{S}^{-1/2} \right) \end{align} \]

同时注意到 \(\mathbf{M} (\alpha)\) 的定义式 (20),可知

\[ \frac{\mathrm{d} \mathbf{M(\alpha)}}{\mathbf{d} \alpha} = 4 \mathbf{D}^{1/2} \mathbf{V} \mathbf{V}^\dagger \mathbf{D}^{1/2} \]

将 (19) 式代入可知,

\[\begin{split} \begin{align} &\quad \frac{1}{2} \mathrm{tr} \left( \int_0^1 \frac{\mathrm{d} \mathbf{M} (\alpha)^{1/2}}{\mathrm{d} \alpha} \, \mathrm{d} \alpha \right) = \frac{1}{4} \int_0^1 \mathrm{tr} \left( \frac{\mathrm{d} \mathbf{M} (\alpha)}{\mathrm{d} \alpha} \mathbf{M} (\alpha)^{-1/2} \right) \, \mathrm{d} \alpha \\ &= \frac{1}{2 \pi} \int_{0}^{+\infty} \, \mathrm{d} \tilde \omega \int_0^1 \, \mathrm{d} \alpha \cdot \mathrm{tr} \left( 4 \mathbf{D}^{1/2} \mathbf{V} \mathbf{V}^\dagger \mathbf{D}^{1/2} (\mathbf{D}^2 + \tilde \omega^2 \mathbf{I} + 4 \alpha \mathbf{D}^{1/2} \mathbf{V} \mathbf{V}^\dagger \mathbf{D}^{1/2})^{-1} \right) \end{align} \end{split}\]

如果我们令对称矩阵 (对比式 (16) 所定义的 \(\mathbf{\Pi} (\tilde \omega)\))

\[ \begin{equation} \tilde{\mathbf{\Pi}} (\tilde \omega) = - 4 (\mathbf{D} + \tilde \omega^2 \mathbf{I})^{-1/2} \mathbf{D}^{1/2} \mathbf{V} \mathbf{V}^\dagger \mathbf{D}^{1/2} (\mathbf{D} + \tilde \omega^2 \mathbf{I})^{-1/2} \tag{22} \end{equation} \]

那么,

\[ \frac{1}{2} \mathrm{tr} \left( \int_0^1 \frac{\mathrm{d} \mathbf{M} (\alpha)^{1/2}}{\mathrm{d} \alpha} \, \mathrm{d} \alpha \right) = - \frac{1}{2 \pi} \int_{0}^{+\infty} \, \mathrm{d} \tilde \omega \int_0^1 \, \mathrm{d} \alpha \cdot \mathrm{tr} \left( \tilde{\mathbf{\Pi}} (\tilde \omega) (\mathbf{I} - \alpha \tilde{\mathbf{\Pi}} (\tilde \omega) )^{-1} \right) \]

如果我们现在令

\[ \tilde f(x) = \frac{x}{1 - \alpha x} \]

有必要指出,

\[\begin{split} \begin{align} \tilde f(\mathbf{S}) &= \mathbf{F} \tilde f(\mathbf{\Lambda}) \mathbf{F}^\dagger = \mathbf{F} \mathbf{\Lambda} (\mathbf{I} - \alpha \mathbf{\Lambda})^{-1} \mathbf{F}^\dagger \\ &= \mathbf{F} \mathbf{\Lambda} \mathbf{F}^\dagger (\mathbf{I} - \alpha \mathbf{F}^\dagger \mathbf{\Lambda} \mathbf{F})^{-1} \\ &\neq \mathbf{S} (\mathbf{I} - \alpha \mathbf{S})^{-1} \end{align} \end{split}\]

但根据 \(\mathbf{F}\) 作为正交矩阵的性质,容易推知

\[ \mathrm{tr} \big( \tilde f (\mathbf{S}) \big) = \mathrm{tr} \big( \mathbf{S} (\mathbf{I} - \alpha \mathbf{S})^{-1} \big) \]

同时,我们注意到

\[ \int_0^1 \frac{x}{1 - \alpha x} \, \mathrm{d} \alpha = - \log (1 - x) , \quad x < 1 \]

同时利用到式 (17) 的 \(\mathrm{tr} \log = \log \det\) 的关系式,可知

\[\begin{split} \begin{align} &\quad \frac{1}{2} \mathrm{tr} \left( \int_0^1 \frac{\mathrm{d} \mathbf{M} (\alpha)^{1/2}}{\mathrm{d} \alpha} \, \mathrm{d} \alpha \right) = \frac{1}{2 \pi} \int_{0}^{+\infty} \, \mathrm{d} \tilde \omega \cdot \mathrm{tr} \left( \int_0^1 \tilde f(\tilde{\mathbf{\Pi}} (\tilde \omega)) \, \mathrm{d} \alpha \right) \\ &= \frac{1}{2 \pi} \int_{0}^{+\infty} \mathrm{tr} \log (\mathbf{I} - \tilde{\mathbf{\Pi}}(\tilde \omega)) \, \mathrm{d} \tilde \omega = \frac{1}{2 \pi} \int_{0}^{+\infty} \log \det (\mathbf{I} - \tilde{\mathbf{\Pi}}(\tilde \omega)) \, \mathrm{d} \tilde \omega \end{align} \end{split}\]

最终结果的导出#

最后我们作收尾工作。首先,依据下述结论 (姚慕生、吴泉水 [7] 6.1 节习题 9):

\[ \det (\lambda \mathbf{I} - \mathbf{X} \mathbf{Y}) = \lambda^{m - n} \det (\lambda \mathbf{I} - \mathbf{Y} \mathbf{X}) \]

上式中,\(\mathbf{X} \in \mathbb{R}^{m \times n}, \mathbf{Y} \in \mathbb{R}^{n \times m}, m \geqslant n\),两处 \(\mathbf{I}\) 是不同的。以此可以表明,\(\mathbf{XY}\) 的本征值与 \(\mathbf{YX}\) 的本征值“相同”,即 \(\mathbf{XY}\)\(n\) 个本征值是 \(\mathbf{YX}\) 的本征值,而剩下的 \(m - n\) 个本征值是 0。

套用在我们目前的问题中,则表现为 \(\tilde{\mathbf{\Pi}} (\tilde \omega)\)\(\mathbf{\Pi} (\tilde \omega)\) 在相同的 \(\tilde \omega\) 情况下,\(n_\mathrm{aux}\) 本征值相同;其它 \(n_\mathrm{occ} n_\mathrm{vir} - n_\mathrm{aux}\) 个本征值则为零。这可以很容易地令 \(\mathbf{X} = \mathbf{Y}^\dagger = (\mathbf{D} + \tilde \omega^2 \mathbf{I})^{-1/2} \mathbf{D}^{1/2} \mathbf{V}\) 来说明。

也因此,\(\mathbf{I} - \tilde{\mathbf{\Pi}} (\tilde \omega)\)\(\mathbf{\Pi} (\tilde \omega)\)\(n_\mathrm{aux}\) 本征值相同,其它 \(n_\mathrm{occ} n_\mathrm{vir} - n_\mathrm{aux}\) 个本征值则为 1;从而这些本征值的乘积 (即行列式值) 相等。因此,

\[ \log \det (\mathbf{I} - \tilde{\mathbf{\Pi}}(\tilde \omega)) = \log \det (\mathbf{I} - {\mathbf{\Pi}}(\tilde \omega)) \]

另一个需要证明的问题是式 (21) 中出现的

\[ \mathrm{tr} (\mathbf{V}^\dagger \mathbf{V}) = - \frac{1}{2 \pi} \int_{0}^{+ \infty} \mathrm{tr} \big( \mathbf{\Pi} (\tilde \omega_g) \big) \, \mathrm{d} \tilde \omega \]

这只需要插入

\[ \mathbf{D}^{-1} = (\mathbf{D}^2)^{-1/2} = \frac{2}{\pi} \int_0^{+\infty} \frac{1}{\mathbf{D}^2 + \tilde \omega^2 \mathbf{I}} \, \mathrm{d} \tilde \omega \]

那么

\[\begin{split} \begin{align} \mathrm{tr} (\mathbf{V}^\dagger \mathbf{V}) &= \mathrm{tr} (\mathbf{V}^\dagger \mathbf{D}^{1/2} \mathbf{D}^{-1} \mathbf{D}^{1/2} \mathbf{V}) \\ &= - \frac{1}{2 \pi} \int_0^{+\infty} \mathrm{tr} \left( - \mathbf{V}^\dagger \mathbf{D}^{1/2} \frac{4}{\mathbf{D}^2 + \tilde \omega^2 \mathbf{I}} \mathbf{D}^{1/2} \mathbf{V} \right) \, \mathrm{d} \tilde \omega \\ &= - \frac{1}{2 \pi} \int_{0}^{+ \infty} \mathrm{tr} \big( \mathbf{\Pi} (\tilde \omega_g) \big) \, \mathrm{d} \tilde \omega \end{align} \end{split}\]

至此,\(O(N^4)\)\(O(N^6)\) 两种计算复杂度表达式

\[ E_\mathrm{c}^\mathsf{dRPA} [\rho] = \frac{1}{2} \big( \mathrm{tr} (\mathbf{M}^{1/2}) - \mathrm{tr} (\mathbf{A}^{\alpha = 1}) \big) = \frac{1}{2 \pi} \int_{0}^{+ \infty} \big( \log \det \big( \mathbf{1} - \mathbf{\Pi} (\tilde \omega_g) \big) + \mathrm{tr} \big( \mathbf{\Pi} (\tilde \omega_g) \big) \big) \, \mathrm{d} \tilde \omega \]

的等价性证明就完成了。


简单理解最初步的 ONIOM 能量计算#

创建时间:2021-06-10

在这篇文档中,我们会使用 PySCF 实现 ONIOM 计算的最初步的结果,即单点能计算。ONIOM 方法的原始文献之一是 Dapprich, Frisch et al. [1]

由于单点能本身是没有实际物理价值的,因此相对能量、或者光谱性质、几何构型才是真正有价值的输出量。但得到这些输出量会比较复杂,特别是大多数 (除了 Gaussian 外) 以代价较大的第一性为卖点的量化程序,对半经验方法的光谱信息的支持并不好。我们并不对这些问题作更细致的讨论。正因为 Gaussian 对 ONIOM 的各种光谱数据、结构性质和溶剂化的支持非常充分,而其它软件在没有特化程序时似乎只能计算单点能;因此这些特色使得 Gaussian 确实地成为适合药物设计的软件之一。

尽管 PySCF 确实可以给出 ONIOM 的计算结果,但为了方便地得到 ONIOM 的分块信息,我们仍然还需要 Gaussian 的支持。

from pyscf import gto, scf, mp, semiempirical
import numpy as np

HARTREE2KCAL = semiempirical.mopac_param.HARTREE2KCAL

简单问题:三氟代乙醛的 2-layer 计算#

该问题的 Gaussian 输入卡置于 2-layer.gjf 中。

! cat 2-layer.gjf
#p ONIOM(MP2(Full)/6-31G:HF/6-31G)=InputFiles NoSymm
 
2-layer ONIOM, modified from Gaussian keyword list
 
0 1 0 1 0 1
  F     -1.041506214819     0.000000000000    -2.126109488809 L
  F     -2.033681935634    -1.142892069126    -0.412218766901 L
  F     -2.033681935634     1.142892069126    -0.412218766901 L
  C     -1.299038105677     0.000000000000    -0.750000000000 L H 5 0.7 0.8
  C      0.000000000000     0.000000000000     0.000000000000 H
  H      0.000000000000     0.000000000000     1.100000000000 H
  O      1.125833024920     0.000000000000    -0.650000000000 H

计算完毕后,最关键的 Gaussian 的输出是

ONIOM: gridpoint  1 method:  low   system:  model energy:  -113.796286020376
ONIOM: gridpoint  2 method:  high  system:  model energy:  -114.023466233349
ONIOM: gridpoint  3 method:  low   system:  real  energy:  -449.289045248409
ONIOM: extrapolated energy =    -449.516225461381

我们可以知道,最终能量的计算是通过下式完成的:

\[ E_\mathrm{ONIOM2} = E_\mathrm{low} (\mathrm{real}) + E_\mathrm{high} (\mathrm{model}) - E_\mathrm{low} (\mathrm{model}) \]

对于 2-layer 的 ONIOM 计算,它要细分成三个计算任务:

任务序号

计算级别 (method)

计算体系 (system)

能量 / Hartree

1

HF/6-31G (low)

(H)CHO (model)

-113.796286020376

2

MP2(Full)/6-31G (high)

(H)CHO (model)

-114.023466233349

3

HF/6-31G (low)

CF3-CHO (real)

-449.289045248409

注意到我们指定的模型层 (model) 是最后三个原子;但实际上,由于模型层 (model) 与全局层 (real) 之间有键相互作用 (第 4 个 real 层甲基碳原子与第 5 个 model 层醛碳原子),因此不能简单粗暴地在此处断键。

上面的 Gaussian 输入卡会在计算模型层时引入氢原子;其引入并非直接将第 4 个碳原子替换为氢,而同时还要缩放键长度。在上述表格中,人为补上去的氢原子也由括号作标识。H 5 0.7 0.8 即指在低计算级别 (low) 缩放到 0.7 倍,而在高计算级别 (high) 缩放到 0.8 倍。

任务 1:\(E_\mathrm{low} (\mathrm{model})\) 计算#

低计算级别 (low) 与高计算级别 (high) 都需要计算模型层 (model) 的分子片段。但由于氢原子添补方式的不同,使得我们需要分别定义这两个分子,及其后续计算。我们首先给出 high 级别的计算结果。

Low 级别下,引入的氢原子键长是对应 4 号碳与 5 号碳的 0.7 倍。

mol_model_low = gto.Mole()
mol_model_low.atom = """
  H     -0.90932667         0.                -0.525     
  C      0.000000000000     0.000000000000     0.000000000000 
  H      0.000000000000     0.000000000000     1.100000000000 
  O      1.125833024920     0.000000000000    -0.650000000000 
"""
mol_model_low.basis = "6-31G"
mol_model_low.verbose = 0
mol_model_low.build()
<pyscf.gto.mole.Mole at 0x7f277b9a56a0>
mf_model_low = scf.RHF(mol_model_low).run()
eng_model_low = mf_model_low.e_tot
eng_model_low
-113.79628602351522

任务 2:\(E_\mathrm{high} (\mathrm{model})\) 计算#

High 级别下,引入的氢原子键长是对应 4 号碳与 5 号碳的 0.8 倍。

mol_model_high = gto.Mole()
mol_model_high.atom = """
  H     -1.03923048         0.                -0.6
  C      0.000000000000     0.000000000000     0.000000000000 
  H      0.000000000000     0.000000000000     1.100000000000 
  O      1.125833024920     0.000000000000    -0.650000000000 
"""
mol_model_high.basis = "6-31G"
mol_model_high.verbose = 0
mol_model_high.build()
<pyscf.gto.mole.Mole at 0x7f276ae590a0>
mf_model_high = mp.MP2(mol_model_high).run()
eng_model_high = mf_model_high.e_tot
eng_model_high
-114.02346625303319

任务 3:\(E_\mathrm{low} (\mathrm{real})\) 计算#

mol_real = gto.Mole()
mol_real.atom = """
  F     -1.041506214819     0.000000000000    -2.126109488809 
  F     -2.033681935634    -1.142892069126    -0.412218766901 
  F     -2.033681935634     1.142892069126    -0.412218766901 
  C     -1.299038105677     0.000000000000    -0.750000000000 
  C      0.000000000000     0.000000000000     0.000000000000 
  H      0.000000000000     0.000000000000     1.100000000000 
  O      1.125833024920     0.000000000000    -0.650000000000 
"""
mol_real.basis = "6-31G"
mol_real.verbose = 0
mol_real.build()
<pyscf.gto.mole.Mole at 0x7f276ae597c0>
mf_real_low = scf.RHF(mol_real).run()
eng_real_low = mf_real_low.e_tot
eng_real_low
-449.28904521819294

能量的统合#

回顾 2-layer ONIOM 的能量统合方式:

\[ E_\mathrm{ONIOM2} = E_\mathrm{low} (\mathrm{real}) + E_\mathrm{high} (\mathrm{model}) - E_\mathrm{low} (\mathrm{model}) \]
eng_real_low + eng_model_high - eng_model_low
-449.5162254477109

Gaussian 的结果是 -449.516225461381。

较复杂问题:丙醛的 3-layer 计算#

该问题的 Gaussian 输入卡置于 3-layer.gjf 中。

! cat 3-layer.gjf
#p ONIOM(MP2(Full)/6-311g*:HF/6-31g:MINDO3)=InputFiles NoSymm

3-layer ONIOM, modified from Gaussian test job 0679

   0   1   0   1   0   1   0   1   0   1   0   1   0   1
 C  -0.006049274275    0.000000000000    0.066754956170 H
 O   0.011403425950    0.000000000000    1.308239478983 H
 H   0.944762558657    0.000000000000   -0.507359536461 H
 C  -1.307562483867    0.000000000000   -0.766510748030 M H 1 0.723886 0.723886 0.723886
 C  -1.047480751885    0.000000000000   -2.301387120377 L H 4 0.723886 0.723886 0.723886
 H  -1.903669606697   -0.885256630266   -0.468844831106 M
 H  -1.903669606697    0.885256630266   -0.468844831106 M
 H  -1.988817319373    0.000000000000   -2.842389774687 L
 H  -0.482972255230    0.881286097766   -2.591806824941 L
 H  -0.482972255230   -0.881286097766   -2.591806824941 L

计算完毕后,最关键的 Gaussian 的输出是

ONIOM: gridpoint  1 method:  low   system:  model energy:    -0.033309689782
ONIOM: gridpoint  2 method:  med   system:  model energy:  -113.805007046900
ONIOM: gridpoint  3 method:  low   system:  mid   energy:    -0.059535553265
ONIOM: gridpoint  4 method:  high  system:  model energy:  -114.255323473041
ONIOM: gridpoint  5 method:  med   system:  mid   energy:  -152.836036428501
ONIOM: gridpoint  6 method:  low   system:  real  energy:    -0.068015765875
ONIOM: extrapolated energy =    -153.294833067253

由于外推表达式比较复杂,原始文献也使用了简化的表达式作能量结果的表示 (需要注意,\(E_1 = E_\mathrm{low} (\mathrm{model})\) 没有在能量统合公式中):

\[ E_\mathrm{ONIOM3} = E_6 - E_3 + E_5 - E_2 + E_4 \]

对于 3-layer 的 ONIOM 计算,它要细分成 5 个计算任务:

任务序号

计算级别 (method)

计算体系 (system)

能量 / Hartree

2

HF/6-31g (med)

(H)CHO (model)

-113.805007046900

3

MINDO/3 (low)

(H)CH2-CHO (mid)

-0.059535553265

4

MP2(Full)/6-311g* (high)

(H)CHO (model)

-114.255323473041

5

HF/6-31g (med)

(H)CH2-CHO (mid)

-152.836036428501

6

MINDO/3 (low)

CH3-CH2-CHO (real)

-0.068015765875

使用 Gaussian 查看每个计算任务的分子构型#

一旦体系增大,模型 (model)、中间 (mid/intermediate) 与全局 (real) 层之间的断键数目增多,这时人为地确定具体的分子构型就会变得困难了。

一种比较简单粗暴的方式是使用 Gaussian ONIOM 关键词的 OnlyInputFiles 选项;这样就可以在不执行 ONIOM 具体计算的情况下,把每个需要计算的分子片段信息打印出来。如果加入关键词 InputFiles,那么可以同时打印分子片段以及计算 ONIOM。

我们以第 5 个格点为例 (中层 (mid/intermediate),中等级算量 (med))。其在 Gaussian 中的输出是 (略去 Gaussian 推断的成键关系)

ONIOM: generating point  5 -- med level on mid system.

--------------------------------------------------------------------------------
#P Test IOp(2/15=1,5/32=2,5/38=1) HF/6-31G

3-layer ONIOM, modified from Gaussian test job 0679
Point  5 -- med level on mid system.

0,1
 C                                                -0.006049274275      0.000000000000      0.066754956170
 O                                                 0.011403425950      0.000000000000      1.308239478983
 H                                                 0.944762558657      0.000000000000     -0.507359536461
 C                                                -1.307562483867      0.000000000000     -0.766510748030
 H(Iso=12)                                        -1.119292959229      0.000000000000     -1.877586265703
 H                                                -1.903669606697     -0.885256630266     -0.468844831106
 H                                                -1.903669606697      0.885256630266     -0.468844831106
 Bq-#1(Iso=1.00782504,Spin=1,ZNuc=1.,GFac=2.792846)         -1.988817319373      0.000000000000     -2.842389774687
 Bq-#1(Iso=1.00782504,Spin=1,ZNuc=1.,GFac=2.792846)         -0.482972255230      0.881286097766     -2.591806824941
 Bq-#1(Iso=1.00782504,Spin=1,ZNuc=1.,GFac=2.792846)         -0.482972255230     -0.881286097766     -2.591806824941

我们可以看出第 5 号位的氢原子实际上是替代了低层 (low) 的碳原子。之所以要设置为氢原子 12 质量的同位素,是为了跟进一步的分子力与频率分析作准备;在单纯讨论能量时不需要考虑原子质量的问题。同时,最后的三个低层 (low) 原子都被设置为 Bq 原子,即虚原子。我们实际需要带入能量计算的原子就是前 7 个原子了。

任务 2:\(E_2 = E_\mathrm{med} (\mathrm{model})\)#

mol_2 = gto.Mole()
mol_2.atom = """
C    -0.006049274275      0.000000000000      0.066754956170
O     0.011403425950      0.000000000000      1.308239478983
H     0.944762558657      0.000000000000     -0.507359536461
H    -0.948196465514      0.000000000000     -0.536434421381
"""
mol_2.basis = "6-31G"
mol_2.verbose = 0
mol_2.build()
<pyscf.gto.mole.Mole at 0x7f276ae59a30>
mf_2 = scf.RHF(mol_2).run()
eng_2 = mf_2.e_tot
eng_2
-113.80500704910236

任务 3:\(E_3 = E_\mathrm{low} (\mathrm{mid})\)#

需要注意,Gaussian 在半经验方法中输出的能量并非接近于单点能,而是接近于原子化能。因此,在使用 PySCF 时尽量不要直接用半经验的 e_tot 变量作结果输出。

mol_3 = gto.Mole()
mol_3.atom = """
C     -0.006049274275      0.000000000000      0.066754956170
O      0.011403425950      0.000000000000      1.308239478983
H      0.944762558657      0.000000000000     -0.507359536461
C     -1.307562483867      0.000000000000     -0.766510748030
H     -1.119292959229      0.000000000000     -1.877586265703
H     -1.903669606697     -0.885256630266     -0.468844831106
H     -1.903669606697      0.885256630266     -0.468844831106
"""
mol_3.verbose = 0
mol_3.build()
<pyscf.gto.mole.Mole at 0x7f276ae59a60>
mf_3 = semiempirical.MINDO3(mol_3).run()
eng_3 = mf_3.e_heat_formation / HARTREE2KCAL
eng_3
-0.059543662948324035

这与 Gaussian 的结果有略微差别,但差别在 5e-3 kcal/mol 上,我们可以认为这时可以忽略的差距了。

任务 4:\(E_4 = E_\mathrm{high} (\mathrm{model})\)#

需要注意,即使这个分子与 \(E_2 = E_\mathrm{med} (\mathrm{model})\) 所使用的分子相同 (因为使用了相同的断键补氢系数 \(g = 0.723886\));但 \(E_2\) 的基组是中等级 (med) 的 6-31G,而 \(E_4\) 则是高等级 (high) 的 6-311G*。

mol_4 = gto.Mole()
mol_4.atom = """
C    -0.006049274275      0.000000000000      0.066754956170
O     0.011403425950      0.000000000000      1.308239478983
H     0.944762558657      0.000000000000     -0.507359536461
H    -0.948196465514      0.000000000000     -0.536434421381
"""
mol_4.basis = "6-311G*"
mol_4.verbose = 0
mol_4.build()
<pyscf.gto.mole.Mole at 0x7f276ae598e0>
mf_4 = mp.MP2(mol_4).run()
eng_4 = mf_4.e_tot
eng_4
-114.25532346425724

任务 5:\(E_5 = E_\mathrm{med} (\mathrm{mid})\)#

mol_5 = gto.Mole()
mol_5.atom = """
C     -0.006049274275      0.000000000000      0.066754956170
O      0.011403425950      0.000000000000      1.308239478983
H      0.944762558657      0.000000000000     -0.507359536461
C     -1.307562483867      0.000000000000     -0.766510748030
H     -1.119292959229      0.000000000000     -1.877586265703
H     -1.903669606697     -0.885256630266     -0.468844831106
H     -1.903669606697      0.885256630266     -0.468844831106
"""
mol_5.basis = "6-31G"
mol_5.verbose = 0
mol_5.build()
<pyscf.gto.mole.Mole at 0x7f276ae77670>
mf_5 = scf.RHF(mol_5).run()
eng_5 = mf_5.e_tot
eng_5
-152.83603642639747

任务 6:\(E_6 = E_\mathrm{low} (\mathrm{real})\)#

mol_6 = gto.Mole()
mol_6.atom = """
C     -0.006049274275      0.000000000000      0.066754956170
O      0.011403425950      0.000000000000      1.308239478983
H      0.944762558657      0.000000000000     -0.507359536461
C     -1.307562483867      0.000000000000     -0.766510748030
C     -1.047480751885      0.000000000000     -2.301387120377
H     -1.903669606697     -0.885256630266     -0.468844831106
H     -1.903669606697      0.885256630266     -0.468844831106
H     -1.988817319373      0.000000000000     -2.842389774687
H     -0.482972255230      0.881286097766     -2.591806824941
H     -0.482972255230     -0.881286097766     -2.591806824941
"""
mol_6.verbose = 0
mol_6.build()
<pyscf.gto.mole.Mole at 0x7f276ae77d90>
mf_6 = semiempirical.MINDO3(mol_6).run()
eng_6 = mf_6.e_heat_formation / HARTREE2KCAL
eng_6
-0.06801695739152386

能量的统合#

回顾 3-layer ONIOM 的能量统合方式:

\[ E_\mathrm{ONIOM3} = E_6 - E_3 + E_5 - E_2 + E_4 \]
eng_6 - eng_3 + eng_5 - eng_2 + eng_4
-153.29482613599555

Gaussian 的结果是 -153.294833067253 Hartree。我们使用 PySCF 给出的计算结果与 Gaussian 相差 4e-3 kcal/mol。


频率分析 (1):从 fchk 文件得到分子频率与简正模式#

创建时间:2019-10-04;最后修改:2021-06-21

在这份文档中,我们将简单地讨论从 Gaussian 生成的 formated checkpoint 文件 (fchk 或 fch 后缀名),依据分子的 Hessian 矩阵,给出分子的振动频率与其对应的简正运动模式。

我们所计算的分子是以下显然没有优化到能量最低结构的 C2O4H+ 分子。之所以选择这样一个分子,是因为作者希望能正确地计算出虚频。

警告

不处于能量最低结构的分子一般来说不适合用作频率分析。此时绘制的分子光谱图从理论上是与不可能与实验相符的。

这份文档尽管使用了有虚频的分子,但若要进行真正的光谱绘制,仍然需要先对分子的结构进行优化。

警告

这篇文档以后有可能会作一定程度的修改。有兴趣的读者也应当参考 Gaussian 白皮书 Vibrational Analysis in Gaussian

分子结构如下:

from IPython.display import Image
Image(filename="assets/mol_fig.PNG", width=250)
_images/1769a3332c7096f15a56952d4cc72de96005bc6be9422faeb4448a207b647373.png

分子对应的输入卡 C2O4H.gjf、输出文件 C2O4H.out 与 fchk 文件 C2O4H.fchk 在链接中可供下载。这份文档的目标将是重复输出文件中的分子频率 Frequencies (单位 cm-1) 与简正坐标部分;而下一份文档将会重复红外强度 IR Inten (单位 km/mol) 与绘制红外光谱。以下是其中一部分频率分析的输出:

with open("C2O4H.out", "r") as f:
    while "and normal coordinates" not in f.readline(): continue
    for _ in range(17): print(f.readline()[:-1])
                     1                      2                      3
                     A                      A                      A
 Frequencies -- -3561.4012             -2816.7847              -111.1073
 Red. masses --     6.8911                 1.1249                14.6979
 Frc consts  --    51.4966                 5.2586                 0.1069
 IR Inten    --  9128.2276              4124.7910                 5.3095
 Raman Activ -- 90326.7283              3896.5620                15.3400
 Depolar (P) --     0.7167                 0.2640                 0.5287
 Depolar (U) --     0.8350                 0.4177                 0.6917
  Atom  AN      X      Y      Z        X      Y      Z        X      Y      Z
     1   6     0.54  -0.07  -0.05     0.04  -0.01   0.03    -0.02   0.16   0.01
     2   8    -0.14   0.02   0.01    -0.03   0.03  -0.03     0.03   0.42   0.26
     3   6    -0.28   0.10  -0.25    -0.04   0.02  -0.03    -0.01   0.10  -0.04
     4   8     0.03  -0.04   0.08    -0.02  -0.01   0.02     0.10  -0.33  -0.34
     5   8    -0.12  -0.03   0.09    -0.01   0.01  -0.02     0.04  -0.46  -0.20
     6   8     0.08   0.03   0.05     0.00  -0.01   0.01    -0.14   0.19   0.31
     7   1    -0.67  -0.18  -0.08     0.88  -0.32   0.33     0.01  -0.21  -0.18

备注

频率分析 (1) 文档的目的与卢天 (Sobereva) 的 Hess2freq 程序 [1] 的程序基本相同,文档的编写过程也受到不少启发。

但另一方面,这份文档将会解决投影整体平动和转动模式。因此,这份文档原则上应当能通过 Hessian 矩阵,给出更为接近 Gaussian 所输出的频率的结果。而任何量化软件通常都可以计算杂化 GGA 泛函级别的 Hessian,因此这份文档可以用于补足一些不进行频率分析的软件。

这份文档的一个问题会是无法对直线型或说 \(3N-5\) 型分子作频率分析。文档中 \(3N-6\)\(6\) 是 hardcoded 到代码中的。

备注

这篇文档不使用 Einstein Summation Convention。

注意

事实上,作者并不完全理解整个计算过程的原理;但这似乎是个可行的方案。

环境准备#

下述引入的包中,

  • FormchkInterface 可以用来读取 fchk 文件的信息;文件出自 pyxdh 项目。

  • 文档中我们可能会使用众多物理常数。这些由 SciPy 提供,数据来源是 CODATA 2014。

from formchk_interface import FormchkInterface
import numpy as np
from functools import partial
import scipy

np.set_printoptions(5, linewidth=150, suppress=True)
np.einsum = partial(np.einsum, optimize=["greedy", 1024 ** 3 * 2 / 8])
# https://docs.scipy.org/doc/scipy/reference/constants.html
from scipy.constants import physical_constants

E_h = physical_constants["Hartree energy"][0]
a_0 = physical_constants["Bohr radius"][0]
N_A = physical_constants["Avogadro constant"][0]
c_0 = physical_constants["speed of light in vacuum"][0]
e_c = physical_constants["elementary charge"][0]
e_0 = physical_constants["electric constant"][0]
mu_0 = physical_constants["mag. constant"][0]

现在我们准备分子的数据:

  • natm 原子数量 \(n_\mathrm{Atom}\)

  • mol_weight 原子质量 \(w_A\),向量长度 \((n_\mathrm{Atom},)\),单位 amu

  • mol_coord 原子坐标 \(A_t\),矩阵大小 \((n_\mathrm{Atom}, 3)\),单位 Bohr

  • mol_hess 坐标二阶梯度 (Hessian 矩阵) \(E_\mathrm{tot}^{A_t B_s}\),张量大小 \((n_\mathrm{Atom}, 3, n_\mathrm{Atom}, 3)\),单位 Eh Bohr-2

本文档中,\(A, B\) 指代原子,\(t, s\) 指代坐标分量 \(x, y\)\(z\)

fchk = FormchkInterface("C2O4H.fchk")
mol_weight = fchk.key_to_value("Real atomic weights")
natm = mol_weight.size
mol_coord = fchk.key_to_value("Current cartesian coordinates").reshape((natm, 3))
mol_hess = fchk.hessian()
mol_hess = (mol_hess + mol_hess.T) / 2
mol_hess = mol_hess.reshape((natm, 3, natm, 3))

包含平动、转动的频率#

这里按照 Hess2freq 程序的思路进行叙述。我们首先生成带原子质量权重的力常数张量 theta

\[ \Theta^{A_t B_s} = E_\mathrm{tot}^{A_t B_s} / \sqrt{w_A w_B} \]

但为了程序便利,我们重定义 theta 的维度信息为 \((3 n_\mathrm{Atom}, 3 n_\mathrm{Atom})\);单位是 Eh Bohr-2 amu-1

theta = np.einsum("AtBs, A, B -> AtBs", mol_hess, 1 / np.sqrt(mol_weight), 1 / np.sqrt(mol_weight)).reshape(3 * natm, 3 * natm)

随后,我们对其进行对角化,可以立即得到原始的分子频率 e 与简正坐标 q,且维度分别是 \((3 n_\mathrm{Atom}, 3 n_\mathrm{Atom})\)\((3 n_\mathrm{Atom},)\)。注意到 e 的单位是 Eh Bohr-2 amu-1,而 q 现在是无量纲量。

e, q = np.linalg.eigh(theta)

现在获得的原始分子频率事实上是力常数除以质量的结果,或者按照 Levine (7ed) p63, eq (4.23) 的表达,为 \(k/m\)。因此,化为以波数表示的频率 freq_cm_1 的公式是

\[ \tilde \nu = \frac{1}{2 \pi c_0} \sqrt{\frac{k}{m}} \]

其中,\(c_0\) 表示真空光速。在实行具体计算前,需要将单位转换为国际单位制。最终会将频率转成 cm-1 单位。

freq_cm_1 = np.sqrt(np.abs(e * E_h * 1000 * N_A / a_0**2)) / (2 * np.pi * c_0 * 100) * ((e > 0) * 2 - 1)
freq_cm_1
array([-3561.40505, -2816.91767,  -168.16445,  -156.49378,  -118.3671 ,    -0.05708,     0.00635,     0.02696,    43.3215 ,   289.57484,   359.82004,
         542.70646,   584.63229,   646.04288,   680.41788,   775.32894,  1115.58713,  1346.89468,  1521.52455,  1593.04881,  1969.81362])

需要留意,复数的频率实际上是虚数频率,或者说是现实中不存在的频率;使用复数表示这些频率仅仅是为了程序方便,以及约定俗称的原因。

由于分子的振动自由度 (对于非线性分子) 是 \(3 n_\mathrm{Atom} - 6\),因此其中有 6 个频率不应当归属于振动频率中。大多数情况下,舍去绝对值最小的六个频率即可;但其值仍然会与 Gaussian 给出的结果有少许的不同。

简正坐标在这里我们暂时不进行更多说明;在叙述去除平动、转动的频率后,我们再讨论简正坐标的导出。

去除平动、转动的频率#

去除平动、转动对频率的贡献,其过程大致是预先将平动、转动的模式求取,随后将力常数张量投影到平动、转动模式的补空间 (\(3 n_\mathrm{Atom} - 6\) 维度空间),得到新的力常数张量。

其中的大部分内容应当在 Wilson et al. [2] 的 Chapter 2 可以找到。

质心坐标#

center_coord \(C_t\) 表示质心坐标,维度 \((3,)\),单位 Bohr。

\[ C_{t} = \frac{\sum_{A} A_{t} w_A}{\sum_A w_A} \]
center_coord = (mol_coord * mol_weight[:, None]).sum(axis=0) / mol_weight.sum()
center_coord
array([ 2.56385, -0.44307, -0.07436])

centered_coord \(A^\mathrm{C}_t\) 是将质心平移至原点后的原子坐标,维度 \((n_\mathrm{Atom}, 3)\),单位 Bohr。

\[ A^\mathrm{C}_t = A_t - C_t \]
centered_coord = mol_coord - center_coord

转动惯量本征向量#

rot_tmp \(I_{ts}\) 是转动惯量相关的矩阵,在初始化时维度为 \((n_\mathrm{Atom}, 3, 3)\),最终结果通过求和得到 \((3, 3)\) 的矩阵,单位 Bohr2 amu。

\[\begin{split} \begin{split} I_{ts} = \begin{cases} \sum_{A} w_A \left( - (A_t^\mathrm{C})^2 + \sum_r (A_r^\mathrm{C})^2 \right) \,, & t = s \\ \sum_{A} w_A \left( - A_t^\mathrm{C} A_s^\mathrm{C} \right) \,, & t \neq s \end{cases} \end{split} \end{split}\]
rot_tmp = np.zeros((natm, 3, 3))
rot_tmp[:, 0, 0] = centered_coord[:, 1]**2 + centered_coord[:, 2]**2
rot_tmp[:, 1, 1] = centered_coord[:, 2]**2 + centered_coord[:, 0]**2
rot_tmp[:, 2, 2] = centered_coord[:, 0]**2 + centered_coord[:, 1]**2
rot_tmp[:, 0, 1] = rot_tmp[:, 1, 0] = - centered_coord[:, 0] * centered_coord[:, 1]
rot_tmp[:, 1, 2] = rot_tmp[:, 2, 1] = - centered_coord[:, 1] * centered_coord[:, 2]
rot_tmp[:, 2, 0] = rot_tmp[:, 0, 2] = - centered_coord[:, 2] * centered_coord[:, 0]
rot_tmp = (rot_tmp * mol_weight[:, None, None]).sum(axis=0)

rot_eig \(R_{ts}\) 是转动惯量相关的对称矩阵 \(I_{ts}\) 所求得的本征向量,维度 \((3, 3)\),无量纲。

_, rot_eig = np.linalg.eigh(rot_tmp)
rot_eig
array([[ 0.80658, -0.54971,  0.21739],
       [-0.16056, -0.55765, -0.8144 ],
       [ 0.56891,  0.62197, -0.53805]])

平动、转动投影矩阵#

proj_scr \(P_{A_t q}\) 是平动、转动的 \((3 n_\mathrm{Atom}, 6)\) 维度投影矩阵,其目的是将 \(\Theta^{A_t B_s}\) 中不应对分子振动产生贡献的部分投影消去,剩余的 \(3 n_\mathrm{Atom} - 6\) 子空间用于求取实际的分子振动频率。但在初始化 proj_scr \(P_{A_t q}\) 时,先使用 \((n_\mathrm{Atom}, 3, 6)\) 维度的张量。

在计算投影矩阵前,我们先生成 rot_coord \(\mathscr{R}_{Asrw}\) 转动投影相关量,维度 \((n_\mathrm{Atom}, 3, 3, 3)\)

\[ \mathscr{R}_{Asrw} = \sum_{t} A^\mathrm{C}_t R_{ts} R_{rw} \]
rot_coord = np.einsum("At, ts, rw -> Asrw", centered_coord, rot_eig, rot_eig)
rot_coord.shape
(7, 3, 3, 3)

随后我们给出 proj_scr 的计算表达式。proj_scr 的前三列表示平动投影,当 \(q \in (x, y, z) = (0, 1, 2)\) 时,

\[ P_{A_t q} = \sqrt{w_A} \delta_{tq} \]

而当 \(q \in (x, y, z) = (3, 4, 5)\) 时,

\[\begin{split} \begin{split} P_{A_t q} = \sqrt{w_A} \times \begin{cases} \mathscr{R}_{Aytz} - \mathscr{R}_{Azty} \,, & q = x \\ \mathscr{R}_{Aztx} - \mathscr{R}_{Axtz} \,, & q = y \\ \mathscr{R}_{Axty} - \mathscr{R}_{Aytx} \,, & q = z \end{cases} \end{split} \end{split}\]

最终,我们会将 \(P_{A_t q}\) 中关于 \(A_t\) 的维度进行归一化,因此最终获得的 \(P_{A_t q}\) 是无量纲的。

proj_scr = np.zeros((natm, 3, 6))
proj_scr[:, (0, 1, 2), (0, 1, 2)] = 1
proj_scr[:, :, 3] = (rot_coord[:, 1, :, 2] - rot_coord[:, 2, :, 1])
proj_scr[:, :, 4] = (rot_coord[:, 2, :, 0] - rot_coord[:, 0, :, 2])
proj_scr[:, :, 5] = (rot_coord[:, 0, :, 1] - rot_coord[:, 1, :, 0])
proj_scr *= np.sqrt(mol_weight)[:, None, None]
proj_scr.shape = (-1, 6)
proj_scr /= np.linalg.norm(proj_scr, axis=0)
proj_scr
array([[ 0.36722,  0.     ,  0.     ,  0.00369,  0.05794,  0.11255],
       [ 0.     ,  0.36722,  0.     ,  0.04243, -0.17071,  0.09139],
       [ 0.     ,  0.     ,  0.36722,  0.00675, -0.10185, -0.09286],
       [ 0.42396,  0.     ,  0.     ,  0.01229, -0.02015, -0.0705 ],
       [ 0.     ,  0.42396,  0.     , -0.49194, -0.29189,  0.22557],
       [ 0.     ,  0.     ,  0.42396, -0.15626, -0.27952, -0.36992],
       [ 0.36722,  0.     ,  0.     ,  0.03114,  0.00301, -0.06566],
       [ 0.     ,  0.36722,  0.     ,  0.11694,  0.18118, -0.11688],
       [ 0.     ,  0.     ,  0.36722, -0.01115,  0.16511,  0.15039],
       [ 0.42396,  0.     ,  0.     ,  0.16731, -0.02119, -0.43088],
       [ 0.     ,  0.42396,  0.     , -0.30661,  0.34319, -0.15655],
       [ 0.     ,  0.     ,  0.42396, -0.32374,  0.28897,  0.06287],
       [ 0.42396,  0.     ,  0.     , -0.02565,  0.18716,  0.45037],
       [ 0.     ,  0.42396,  0.     ,  0.3913 , -0.45793,  0.21108],
       [ 0.     ,  0.     ,  0.42396,  0.1468 , -0.24516, -0.13753],
       [ 0.42396,  0.     ,  0.     , -0.19609, -0.19816,  0.03903],
       [ 0.     ,  0.42396,  0.     ,  0.31205,  0.3942 , -0.26137],
       [ 0.     ,  0.     ,  0.42396,  0.36608,  0.17829,  0.41139],
       [ 0.10642,  0.     ,  0.     ,  0.04773, -0.00179, -0.11404],
       [ 0.     ,  0.10642,  0.     , -0.17067,  0.01343,  0.01336],
       [ 0.     ,  0.     ,  0.10642, -0.11584,  0.01045, -0.06629]])

最后我们声明,在经过上述投影后的力常数矩阵几乎表现为零:

\[ \mathbf{P}^\dagger \mathbf{\Theta} \mathbf{P} \simeq \mathbf{0} \]
proj_scr.T @ theta @ proj_scr
array([[-0.     ,  0.     ,  0.     , -0.     , -0.     ,  0.     ],
       [ 0.     ,  0.     ,  0.     , -0.     , -0.     , -0.     ],
       [ 0.     ,  0.     ,  0.     ,  0.     ,  0.     ,  0.     ],
       [-0.     , -0.     ,  0.     , -0.00034,  0.00053, -0.00012],
       [-0.     , -0.     ,  0.     ,  0.00053,  0.00001,  0.00011],
       [ 0.     , -0.     ,  0.     , -0.00012,  0.00011, -0.00021]])

对上述矩阵进行对角化所给出的平动、转动频率如下:

e_tr, _ = np.linalg.eigh(proj_scr.T @ theta @ proj_scr)
np.sqrt(np.abs(e_tr * E_h * 1000 * N_A / a_0**2)) / (2 * np.pi * c_0 * 100) * ((e_tr > 0) * 2 - 1)
array([-142.94819,  -65.35067,   -0.05708,    0.00635,    0.02696,  102.63548])

平动、转动投影矩阵的补空间#

既然我们已经得到了平动、转动的投影,那么根据矩阵的原理,相应地我们也能获得其补空间的投影。我们令 proj_inv \(Q_{A_t q}\)\(P_{A_t q}\) 的补空间投影。获得补空间的大致方式是预先定义一个仅有一个分量为 \(1\)\((3 n_\mathrm{Atom}, )\) 维度向量,随后通过 Schmit 正交的方式给出已有投影空间的补空间向量。组合这些 Schmit 正交的向量便获得了 \(Q_{A_t q}\)

\(Q_{A_t q}\) 的维度本应当是 \((3 n_\mathrm{Atom}, 3 n_\mathrm{Atom} - 6)\) 维。但为了程序编写方便,我们先规定 proj_inv\((3 n_\mathrm{Atom}, 3 n_\mathrm{Atom})\) 维度,并且其中的前 6 列填入 \(P_{A_t q}\);在进行 Schmit 正交化后,再将前 6 列剔除。

proj_inv = np.zeros((natm * 3, natm * 3))
proj_inv[:, :6] = proj_scr
cur = 6
for i in range(0, natm * 3):
    vec_i = np.einsum("Ai, i -> A", proj_inv[:, :cur], proj_inv[i, :cur])
    vec_i[i] -= 1
    if np.linalg.norm(vec_i) > 1e-8:
        proj_inv[:, cur] = vec_i / np.linalg.norm(vec_i)
        cur += 1
    if cur >= natm * 3:
        break
proj_inv = proj_inv[:, 6:]

我们最后获得的 \(Q_{A_t q}\) 是列正交切归一的矩阵,且形式大致是下三角矩阵。但需要留意,对于当前的分子,最后一列只有 6 个非零值,与倒数第二列非零值的数量相差 2 个。

proj_inv[:, :8]
array([[-0.92147, -0.     ,  0.     , -0.     , -0.     ,  0.     , -0.     , -0.     ],
       [ 0.0006 , -0.90876,  0.     ,  0.     ,  0.     , -0.     ,  0.     , -0.     ],
       [-0.01772,  0.0101 , -0.91962,  0.     ,  0.     ,  0.     ,  0.     , -0.     ],
       [ 0.15913, -0.00263,  0.00635, -0.88846,  0.     ,  0.     , -0.     , -0.     ],
       [ 0.00723,  0.22587,  0.00828, -0.0174 , -0.62508, -0.     ,  0.     ,  0.     ],
       [-0.06338,  0.00797,  0.23777,  0.02386,  0.12464, -0.71004, -0.     ,  0.     ],
       [ 0.13864, -0.00562,  0.00379,  0.20568, -0.05571,  0.01213, -0.89165, -0.     ],
       [-0.00242,  0.10806, -0.00617,  0.00599,  0.06901, -0.02449,  0.00896, -0.88757],
       [ 0.02871, -0.01639,  0.11235, -0.00984, -0.01789,  0.10978, -0.00552,  0.00503],
       [ 0.11566, -0.03146,  0.04451,  0.26042, -0.29396,  0.15738,  0.31106,  0.0477 ],
       [ 0.00123,  0.07679, -0.02363,  0.00022,  0.33955,  0.06639, -0.01868,  0.25957],
       [ 0.02455, -0.06306,  0.1274 , -0.01053,  0.12201,  0.23871, -0.01701,  0.00208],
       [ 0.23563,  0.00909, -0.07083,  0.20364,  0.09472, -0.32385,  0.2141 , -0.00369],
       [-0.00144,  0.29684,  0.03556, -0.00183,  0.37737,  0.06574, -0.02848,  0.16881],
       [-0.03163,  0.03906,  0.21245,  0.01424, -0.03451,  0.45782,  0.02184, -0.02423],
       [ 0.16048,  0.0321 ,  0.01383,  0.22974,  0.26819,  0.14629,  0.22725, -0.04739],
       [-0.00589,  0.08555, -0.01393,  0.01471, -0.20618, -0.12656,  0.04452,  0.32995],
       [ 0.06292,  0.02501,  0.10976, -0.01965, -0.21114, -0.11824,  0.00024,  0.01928],
       [ 0.02856, -0.00888,  0.01142,  0.06576, -0.08244,  0.03852,  0.07928,  0.01347],
       [ 0.00179,  0.03386, -0.00375, -0.00353,  0.21735,  0.06231, -0.0204 ,  0.04162],
       [-0.0079 , -0.01404,  0.04718,  0.00238,  0.05775,  0.14602, -0.00114, -0.00595]])
proj_inv[:, 8:]
array([[-0.     , -0.     ,  0.     ,  0.     , -0.     ,  0.     , -0.     ],
       [ 0.     , -0.     ,  0.     ,  0.     ,  0.     , -0.     , -0.     ],
       [ 0.     ,  0.     , -0.     ,  0.     , -0.     , -0.     , -0.     ],
       [ 0.     ,  0.     , -0.     , -0.     ,  0.     , -0.     ,  0.     ],
       [-0.     ,  0.     ,  0.     ,  0.     ,  0.     ,  0.     ,  0.     ],
       [-0.     , -0.     , -0.     , -0.     ,  0.     ,  0.     , -0.     ],
       [ 0.     , -0.     ,  0.     ,  0.     , -0.     ,  0.     , -0.     ],
       [-0.     , -0.     , -0.     , -0.     , -0.     , -0.     , -0.     ],
       [-0.88821,  0.     , -0.     , -0.     , -0.     ,  0.     ,  0.     ],
       [-0.04822, -0.55628,  0.     ,  0.     , -0.     ,  0.     , -0.     ],
       [ 0.03972, -0.14218, -0.60686, -0.     , -0.     ,  0.     , -0.     ],
       [ 0.28906, -0.17446,  0.45345, -0.46401, -0.     ,  0.     ,  0.     ],
       [ 0.06431,  0.06973,  0.00436,  0.05117, -0.55525,  0.     , -0.     ],
       [-0.05364, -0.22108,  0.16095, -0.21835, -0.04109, -0.14297, -0.     ],
       [ 0.18656,  0.31662, -0.22295,  0.16951, -0.42471,  0.25709,  0.     ],
       [-0.01297,  0.40547,  0.02383,  0.0214 ,  0.5487 ,  0.10936, -0.18819],
       [ 0.01152,  0.40633,  0.35634,  0.05943,  0.02677,  0.08518, -0.13536],
       [ 0.27891, -0.14836, -0.26106,  0.18737,  0.44457, -0.05496,  0.07441],
       [-0.01243,  0.32303, -0.11228, -0.28913,  0.02613, -0.43568,  0.74973],
       [ 0.00958, -0.17158,  0.35684,  0.63313,  0.05705,  0.23023,  0.53923],
       [ 0.05897,  0.02469,  0.12176,  0.42675, -0.07912, -0.80524, -0.29643]])

去除平动、转动部分的频率#

我们将对矩阵 \(\mathbf{Q}^\dagger \mathbf{\Theta} \mathbf{Q}\) 进行对角化;且获得的第 \(q\) 个简正坐标的频率相关量 e \(K_q = k_q / m_q\) 与原始简正坐标 q \(\mathbf{q}^\mathrm{orig}\) 表示如下:

\[ \mathbf{Q}^\dagger \mathbf{\Theta} \mathbf{Q} \mathbf{q}^\mathrm{orig} = \mathbf{q}^\mathrm{orig} \mathrm{diag} (\boldsymbol{K}) \]
e, q = np.linalg.eigh(proj_inv.T @ theta @ proj_inv)

由此,我们就可以立即获得去除平动、转动部分的,以 cm-1 为单位的,总数为 \(3 n_\mathrm{Atom} - 6\) 的分子频率 freq_cm_1

freq_cm_1 = np.sqrt(np.abs(e * E_h * 1000 * N_A / a_0**2)) / (2 * np.pi * c_0 * 100) * ((e > 0) * 2 - 1)
freq_cm_1
array([-3561.40117, -2816.7847 ,  -111.10727,   285.51964,   354.79005,   541.46285,   583.67422,   641.59573,   678.70706,   774.75315,  1114.8334 ,
        1341.17965,  1521.15243,  1592.00939,  1969.72731])

归一化的简正坐标#

方才通过对角化,我们获得的原始简正坐标 q 的维度是 \(3 n_\mathrm{Atom} - 6\)。我们需要通过 q \(\mathbf{q}^\mathrm{orig}\) 重塑回正常的简正坐标的维度 \(q_{A_t q}\) \((3 n_\mathrm{Atom}, 3 n_\mathrm{Atom} - 6)\)

我们首先给出未经过归一化的简正坐标,命名为 q_unnormed \(q_{A_t q}^\mathrm{unnorm}\),其单位是 amu-1/2。该量将会用于后续的红外强度计算上。其计算过程大致是

\[ \mathbf{q}^\mathrm{unnorm} = \mathbf{Q} \mathbf{q}^\mathrm{orig} / \sqrt{\mathbf{w}} \]
q_unnormed = np.einsum("AtQ, A -> AtQ", (proj_inv @ q).reshape(natm, 3, (proj_inv @ q).shape[-1]), 1 / np.sqrt(mol_weight))
q_unnormed = q_unnormed.reshape(-1, q_unnormed.shape[-1])

而将每一个简正坐标的振动强度归一化的矩阵称为 q_normed \(q_{A_t q}\);它是我们的目标的简正坐标。

q_normed = q_unnormed / np.linalg.norm(q_unnormed, axis=0)

我们可以以下述代码核对前三个简正坐标。这些坐标应当与 Gaussian 所输出的坐标几乎相同,或刚好相差正负号。

q_normed.reshape(natm, 3, 3 * natm - 6)[:, :, :3].transpose((2, 0, 1))
array([[[ 0.54001, -0.07088, -0.04608],
        [-0.14273,  0.02387,  0.00516],
        [-0.284  ,  0.09918, -0.24533],
        [ 0.03237, -0.03565,  0.07878],
        [-0.12144, -0.02845,  0.08535],
        [ 0.08194,  0.03046,  0.05426],
        [-0.66966, -0.18195, -0.07812]],

       [[ 0.0408 , -0.01485,  0.03176],
        [-0.02602,  0.02747, -0.02887],
        [-0.03581,  0.01862, -0.02587],
        [-0.02413, -0.01192,  0.01877],
        [-0.01187,  0.00732, -0.02008],
        [ 0.0026 , -0.0054 ,  0.00522],
        [ 0.88356, -0.32216,  0.32609]],

       [[-0.02081,  0.165  ,  0.00726],
        [ 0.02702,  0.41713,  0.26319],
        [-0.01085,  0.09639, -0.03556],
        [ 0.09566, -0.32921, -0.34068],
        [ 0.03741, -0.46378, -0.19918],
        [-0.13693,  0.19313,  0.30902],
        [ 0.00955, -0.21243, -0.17633]]])

修订记录#

  • 2021-06-21:重新写了 Schmidt 正交化代码。我不太理解当时的代码到底为什么是对的 (>.<)


频率分析 (3):热力学能矫正#

创建时间:2021-06-18

在这份文档中,我们将简单地讨论从 Gaussian 生成的 formated checkpoint 文件 (fchk 或 fch 后缀名),产生热力学能量矫正。

我们计算的分子与 频率分析 (1) 相同,为没有优化到能量最低结构的 C2O4H+ 离子。

该文档的重要的参考资料是 Gaussian 的白皮书 Thermochemistry in Gaussian [1]。我们所使用的公式也 (很无耻地) 会与该文档几乎完全相同;并且作为程序实现笔记,也不会对具体的公式推导作讨论。

警告

不处于能量最低结构的分子一般来说不适合用作频率分析。此时不仅分子光谱、同时热力学矫正从理论上也是不允许的。

这份文档尽管使用了有虚频的分子,但若要进行有价值的热力学矫正,仍然需要先对分子的结构进行优化。

分子对应的输入卡 C2O4H.gjf、输出文件 C2O4H.out 与 fchk 文件 C2O4H.fchk 在链接中可供下载。这份文档的目标将是重复输出文件中的热力学矫正量。其一部分输出是:

with open("C2O4H.out", "r") as f:
    while "Zero-point correction" not in f.readline(): continue
    for _ in range(23): print(f.readline()[:-1])
 Thermal correction to Energy=                    0.030349
 Thermal correction to Enthalpy=                  0.031293
 Thermal correction to Gibbs Free Energy=        -0.002096
 Sum of electronic and zero-point Energies=           -370.093195
 Sum of electronic and thermal Energies=              -370.088816
 Sum of electronic and thermal Enthalpies=            -370.087872
 Sum of electronic and thermal Free Energies=         -370.121261
 
                     E (Thermal)             CV                S
                      KCal/Mol        Cal/Mol-Kelvin    Cal/Mol-Kelvin
 Total                   19.044             14.495             70.273
 Electronic               0.000              0.000              0.000
 Translational            0.889              2.981             39.370
 Rotational               0.889              2.981             26.171
 Vibrational             17.267              8.534              4.732
 Vibration     1          0.683              1.701              1.500
 Vibration     2          0.731              1.565              1.145
 Vibration     3          0.897              1.158              0.562
 Vibration     4          0.941              1.067              0.479
                       Q            Log10(Q)             Ln(Q)
 Total Bot       0.920866D+01          0.964196          2.220144
 Total V=0       0.811762D+13         12.909429         29.725059
 Vib (Bot)       0.238627D-11        -11.622280        -26.761289

我们的文档的内容原则上可以重现所有的上述数据。

备注

热力学能量矫正本质上是统计热力学的初步应用。我们这里遇到的分子是最为常见的没有对称性、单重态的分子。但一些特殊的情况,譬如具有对称性、多重态等情况,需要对下述代码作改动。这些改动我们不在文档中作补充,因此读者还是需要参考 Gaussian 白皮书 [1]

准备工作#

频率分析部分是由 freqanal.py 完成的;它还需要调用读取 Gaussian formchk 文件的小程序 formchk_interface.py。这些程序可以下载。频率分析的具体做法已经在 频率分析 (1) 有所陈述;对于比较一般的非线性分子,它应当可以输出与 Gaussian 近乎一致的频率结果。

from freqanal import FreqAnal
import numpy as np

np.set_printoptions(5, linewidth=150, suppress=False)

为了能进行单位换算,我们还需要定义一些常数。这里对它们在这份文档中的变量名与符号作说明,并给出大致的数量。

  • E_h \(E_\mathrm{H}\):Hartree 能量 \(4.360 \times 10^{-18} \ \mathrm{J}\)

  • a_0 \(a_0\):Bohr 半径 \(5.292 \times 10^{-11} \ \mathrm{m}\)

  • N_A \(N_\mathrm{A}\):Avogadro 常数 \(6.022 \times 10^{23} \ \mathrm{mol}^{-1}\)

  • c_0 \(c\):真空光速 \(2.998 \times 10^{8} \ \mathrm{m} \ \mathrm{s}^{-1}\)

  • k_B \(k_\mathrm{B}\):Boltzmann 常数 \(1.381 \times 10^{-23} \ \mathrm{J} \ \mathrm{K}^{-1}\)

  • R \(R\):Mole 气体常数 \(8.314 \ \mathrm{J} \ \mathrm{mol}^{-1} \ \mathrm{K}^{-1}\)

  • h \(h\):Planck 常数 \(6.626 \times 10^{-34} \ \times{J} \ \mathrm{s}\)

  • P_0 \(P_0\):标准大气压 \(101325. \mathrm{kg} \ \mathrm{m}^{-1} \mathrm{s}^{-2}\)

  • amu \(m_\mathrm{u}\):原子质量单位 \(1.661 \times 10^{-27} \ \mathrm{kg}\)

# https://docs.scipy.org/doc/scipy/reference/constants.html
from scipy import constants
from scipy.constants import physical_constants

E_h = physical_constants["Hartree energy"][0]
a_0 = physical_constants["Bohr radius"][0]
N_A = physical_constants["Avogadro constant"][0]
c_0 = physical_constants["speed of light in vacuum"][0]
k_B = physical_constants["Boltzmann constant"][0]
R = physical_constants["molar gas constant"][0]
h = physical_constants["Planck constant"][0]
P_0 = physical_constants["standard atmosphere"][0]
amu = physical_constants["atomic mass constant"][0]
  • Calorie 与 Joule 的换算比例 4.184

  • pi \(\pi\):圆周率

calorie = 4.184
pi = np.pi

我们在整个文档中,使用的温度是 \(T = 298.15 \ \mathrm{K}\)

T = 298.15

对 C2O4H+ 离子的频率分析对象储存在变量 fa 中。

fa = FreqAnal("C2O4H.fchk")

整个热力学分析过程需要分为平动 (translation)、电子态 (electronic)、转动 (rotation)、振动 (vibrational) 四部分。需要计算的基础热力学矫正量是熵 \(S\) (entropy)、内能 \(E\) (thermo energy)、热容 \(C\) (heat capacity)。这里的热容是指恒容热容。在计算熵时,我们还需要给出配分函数 \(q\) (partition function)。

平动 (Translation)#

  • 配分函数

    \[ q_\mathrm{t} = \left( \frac{2 \pi m k_\mathrm{B} T}{h^2} \right)^{3/2} \frac{k_\mathrm{B} T}{P_0} \]

    m \(m\) 指分子质量,Gaussian 的输出是原子质量单位。它需要先转为 SI 单位制再进行计算。

m = fa.mol_weight.sum() * amu
q_t = (2 * pi * m * k_B * T / h**2)**(3/2) * (k_B * T / P_0)
q_t
32994942.55727539
  • 熵 (\(\mathrm{cal} \ \mathrm{mol}^{-1} \ \mathrm{K}^{-1}\))

    \[ S_\mathrm{t} = R (\ln q_\mathrm{t} + 1 + 3/2) \]
S_t = R * (np.log(q_t) + 1 + 3/2) / calorie
S_t
39.37022220447884
  • 内能 (\(\mathrm{kcal} \ \mathrm{mol}^{-1}\))

    \[ E_\mathrm{t} = \frac{3}{2} R T \]
E_t = 3/2 * R * T / 1000 / calorie
E_t
0.8887274245542662
  • 热容 (\(\mathrm{cal} \ \mathrm{mol}^{-1} \ \mathrm{K}^{-1}\))

    \[ C_\mathrm{t} = \frac{3}{2} R \]
C_t = 3/2 * R / calorie
C_t
2.9808063879063096

电子态 (Electronic)#

警告

电子态的配分函数 \(q_\mathrm{e}\) 受分子多重度的影响;这也会同时影响到熵矫正 \(S_\mathrm{e}\)。因此对于多重度不为 1 的分子,下述代码将需要作修改。具体地来说,需要将 ω_0 \(\omega_0\) 改为多重度的数值。

  • 配分函数

    \[ q_\mathrm{e} = \omega_0 \]

    ω_0 \(\omega_0\) 指分子多重度。

ω_0 = 1
q_e = ω_0
q_e
1
  • 熵 (\(\mathrm{cal} \ \mathrm{mol}^{-1} \ \mathrm{K}^{-1}\))

    \[ S_\mathrm{e} = R \ln q_\mathrm{e} \]
S_e = R * np.log(q_e) / calorie
S_e
0.0
  • 内能 \(E_\mathrm{e}\) 与热容 \(C_\mathrm{e}\) 取零值。

E_e = C_e = 0

转动 (Rotation)#

警告

分子转动的配分函数、熵、内能与热容计算都受分子本身构型而影响。

  • 我们这里计算的是不具有对称性的分子;

  • 对于线性分子,所有物理量的计算都将发生变化,这里的代码将不能使用

  • 对于具有对称性的分子需要修改代码;具体地来说,需要将 σ_r \(\sigma_\mathrm{r}\) 设置为分子的对称数;对称数应当指分子的一些原子置换,但通过一系列旋转或反映操作可以重现原来分子的总数量,对于水为 2、氨气为 3、甲烷或苯为 6。

q_r = 1.16957e5
S_r = R * (np.log(q_r) + 3/2) / calorie
S_r
26.17060894487201
  • 转动惯量 Ixyz \(I_x, I_y, I_z\) (\(\mathrm{kg} \ \mathrm{m}^2\))。需要注意,这里并非真的是绕 \(x, y, z\) 轴转动的惯量,即

    \[ I_x \neq \sum_{A} m_\mathrm{A} r_{Ax}^2 \]

    它需要通过对 \(3 \times 3\) 的转动惯量矩阵 \(I_{xx}, I_{xy}, \cdots, I_{zz}\) 作对角化得到。对角化同时会得到三个转动主轴;由于这三个转动主轴相互垂直,确实地可以构建坐标系,因此才称为 \(I_x, I_y, I_z\),但这里的 \(x, y, z\) 相对于输入卡的坐标系一般有旋转。

Ixyz = fa.rot_eig * amu * a_0**2
Ixyz
array([1.33218e-45, 2.34564e-45, 3.43473e-45])
  • 转动特性温度 Θxyz_r \(\Theta_{\mathrm{r}, x}, \Theta_{\mathrm{r}, y}, \Theta_{\mathrm{r}, z}\) (\(\mathrm{K}\))

    \[ \Theta_{r, x} = \frac{h^2}{8 \pi^2 I_x k_\mathrm{B}} \]
Θxyz_r = h**2 / (8 * pi**2 * Ixyz * k_B)
Θxyz_r
array([0.30233, 0.1717 , 0.11726])
  • 配分函数

    \[ q_\mathrm{r} = \frac{\pi^{1/2}}{\sigma_r} \left( \frac{T^3}{\Theta_{\mathrm{r}, x} \Theta_{\mathrm{r}, y} \Theta_{\mathrm{r}, z}} \right)^{1/2} \]

    其中 \(\sigma_\mathrm{r}\) 为分子的对称数。

σ_r = 1
q_r = (pi**(1/2) / σ_r) * (T**3 / Θxyz_r.prod())**(1/2)
q_r
116957.22677656508
  • 熵 (\(\mathrm{cal} \ \mathrm{mol}^{-1} \ \mathrm{K}^{-1}\))

    \[ S_\mathrm{r} = R (\ln q_\mathrm{r} + 3/2) \]
S_r = R * (np.log(q_r) + 3/2) / calorie
S_r
26.170612798005376
  • 内能 (\(\mathrm{kcal} \ \mathrm{mol}^{-1}\))

    \[ E_\mathrm{r} = \frac{3}{2} R T \]
E_r = 3/2 * R * 298.15 / 1000 / calorie
E_r
0.8887274245542662
  • 热容 (\(\mathrm{cal} \ \mathrm{mol}^{-1} \ \mathrm{K}^{-1}\))

    \[ C_\mathrm{r} = \frac{3}{2} R \]
C_r = 3/2 * R / calorie
C_r
2.9808063879063096

振动 (Vibrational)#

备注

热力学问题的一般都要求假设分子处于稳定构型 (因此才能有热力学稳定的状态,各种热力学能量作为温度的状态函数才能成立)。因此,具有振动虚频的分子一般认为是不能进行振动分析的。

我们这里的计算单纯地是 Gaussian 的结果作比对。Gaussian 在计算热力学能时,对虚频的处理是直接忽视。

  • 振动特征温度 (\(\mathrm{K}\))

    \[ \Theta_{\mathrm{v}, K} = h \nu_K = \varpi_K h c \]

    其中,\(K\) 是指震动模式,\(\varpi_K\) 是以长度为量纲的振动频率。

ΘK_v = fa.freq[fa.freq > 0] / 1e-2 * h * c_0 / k_B
  • 配分函数

    \[ q_\mathrm{v} = \prod_K \frac{e^{- \Theta_{\mathrm{v}, K} / 2 T}}{1 - e^{- \Theta_{\mathrm{v}, K} / T}} \]

    这里采用的是与 Gaussian 的最终热力学矫正一致的输出 (即 BOT 结果)。

qK_v = np.exp(-ΘK_v / (2 * T)) / (1 - np.exp(-ΘK_v / T))
q_v = qK_v.prod()
q_v
2.386188575409033e-12
  • 熵 (\(\mathrm{cal} \ \mathrm{mol}^{-1} \ \mathrm{K}^{-1}\))

    \[ S_\mathrm{v} = R \sum_K \left( \frac{\Theta_{\mathrm{v}, K} / T}{e^{\Theta_{\mathrm{v}, K} / T} - 1} - \ln (1 - e^{- \Theta_{\mathrm{v}, K} / T}) \right) \]
SK_v = R * (ΘK_v / T / (np.exp(ΘK_v / T) - 1) - np.log(1 - np.exp(-ΘK_v / T))) / calorie
S_v = SK_v.sum()
S_v
4.732478685230292
  • 内能 (\(\mathrm{kcal} \ \mathrm{mol}^{-1}\))

    \[ E_\mathrm{v} = R \sum_K \Theta_{\mathrm{v}, K} \left( \frac{1}{2} + \frac{1}{e^{\Theta_{\mathrm{v}, K} / T} - 1} \right) \]
EK_v = R * (ΘK_v * (1/2 + 1 / (np.exp(ΘK_v / T) - 1))) / 1000 / calorie
E_v = EK_v.sum()
E_v
17.266670082670938
  • 热容 (\(\mathrm{cal} \ \mathrm{mol}^{-1} \ \mathrm{K}^{-1}\))

    \[ C_\mathrm{v} = R \sum_K e^{- \Theta_{\mathrm{v}, K} / T} \left( \frac{\Theta_{\mathrm{v}, K} / T}{e^{- \Theta_{\mathrm{v}, K} / T} - 1} \right)^2 \]

    这里 Gaussian 白皮书的记号有略微的错误。

CK_v = R * (np.exp(-ΘK_v / T) * (ΘK_v / T / (np.exp(-ΘK_v / T) - 1))**2) / calorie
C_v = CK_v.sum()
C_v
8.533482711830887

总热力学矫正量#

最终的热力学矫正量直接通过将四部分 (平动、电子态、转动、振动) 相加即可。对于配分函数,其结果通过相乘得到。

E_thermal = E_t + E_e + E_r + E_v
E_thermal
19.04412493177947
C_thermal = C_t + C_e + C_r + C_v
C_thermal
14.495095487643507
S_thermal = S_t + S_e + S_r + S_v
S_thermal
70.2733136877145
q_total = q_t * q_e * q_r * q_v
q_total
9.208294504188077

最终热力学矫正#

化学中关心的通常是零点能 (zero point energy, ZPE)、焓 (Enthalpy)、Gibbs 自由能 (Gibbs free energy)。这些量不难通过上面的结果给出。

  • 零点能 (zero point energy, ZPE) (\(E_\mathrm{H}\), Hartree)

    \[ E_\mathrm{ZPE} = \sum_{K} \frac{1}{2} \Theta_{\mathrm{v}, K} k_\mathrm{B} \]
corr_zpe = ΘK_v.sum() / 2 * k_B / E_h
corr_zpe
0.025969755181835332
  • 内能矫正 (\(E_\mathrm{H}\), Hartree) 实质上就是刚才结果的单位变换而已。

corr_thermal = E_thermal / (E_h * N_A / 1000 / calorie)
corr_thermal
0.03034874486989149
  • 焓 (Enthalpy) 矫正 (\(E_\mathrm{H}\), Hartree)

    \[ H_\mathrm{corr} = E_\mathrm{corr} + k_\mathrm{B} T \]
corr_enthalpy = corr_thermal + k_B * T / E_h
corr_enthalpy
0.03129292973753578
  • Gibbs 自由能 (Gibbs free energy) 矫正 (\(E_\mathrm{H}\), Hartree)

    \[ G_\mathrm{corr} = H_\mathrm{corr} - T S_\mathrm{tot} \]
corr_gibbs = corr_enthalpy - T * S_thermal / (E_h * N_A / calorie)
corr_gibbs
-0.002096189219235073

频率分析 (4):非谐性频率矫正 VPT2#

在本文档中,我们将会讨论非谐性频率矫正模型 VPT2。

在量化计算中,通常分子振动的频率通过 Hessian 矩阵给出。但我们也知道,Hessian 矩阵是能量对原子核坐标的两次导数;以这种方式求得的频率,相当于求取抛物线势函数的频率。但真实的势函数并非抛物线 (譬如 Morse、L-J 势函数等),因此真实的频率会与 Hessian 矩阵所得到的值有少许区别。这也会影响到热力学矫正中,频率配分函数所产生的贡献值。

为了获得更真实的分子振动信息,那么就需要求解 Hamiltonian 受分子振动微扰后的本征波函数,并进而进行分析。近似的做法可以是微扰理论的矫正。这篇文档是其中一种近似方法,称为 Vibrational Perturbation Theory to the Second-Order (VPT2)。

我们主要会依据 V. Barone [1] 所提供的算法与公式进行实现。我们不对公式与其正确性作推演与检查。我们主要与 Gaussian 16 rev. B01 的结果作参照,但不会完全相同。

备注

在 4 Core CPU 上完整执行本文档需要大约 5-10 分钟。

警告

我们在本文档中使用处于非平衡构型的非对称的氨分子。

  • 频率分析没有物理意义:频率分析原则上一定要处于平衡构型;特殊的情况会是过渡态。除此之外的情况,频率分析都没有物理意义。需要先优化到稳定的构型再进行分析。

  • 不考察对称性:对于对称的分子,有可能会产生简并的频率。在一些情况下,简并会产生数值上的奇点。非简并的分子不太容易产生奇点,分析相对容易。

  • 非线性分子:很多时候,线性与非线性分子需要分别使用不同的方法分析。我们只考虑非线性分子,即 \(3N-6\) 型分子。

危险

若本文档有幸 (或不幸) 能帮助到读者,读者也需要知道这份文档的程序实现未必正确!

本文档所用于计算的体系比较特别 (有虚频的氨、能量最低结构的乙醛),无法保证可以应用到其它的分子

同时,Gaussian 的不同版本有不同的 bug 和使用技巧 (当然完全可能是我的错误,但这应由读者去判断与负责)。我们会在具体的位置,给出各个版本存在潜在问题的情况;并且可能的话,会给出如何重复出 Gaussian 的结果。对于输入卡 anharm.gjf 我们所考察的 Gaussian 版本有 16 rev. B01 anharm-G16B01.log, 09 rev. D01 anharm-G09D01.log, 09 rev. C01 anharm-G09C01.log

读者特别需要注意在作性质计算时,大多数时候我们采用 Eckart Orientation

由于当前的 VPT2 理论本身就不适合用于小分子以外的计算 (容易由于频率共振而给出非常离谱的非谐矫正值),因此即使看起来这里给出的做法似乎适合大分子,并且程序也看起来不会报错,但必须要慎用当前文档所实现的 VPT2。VPT2 有不止一种应用方式;Gaussian 也实现了一些变种,可以一定程度上解决共振效应,这是本文档我完全无法处理的。

VPT2 分析会稍微复杂一些。我们在这里尽管会与 Gaussian 16 的结果作对照,但所有量化计算都会在 PySCF 进行。为此我们需要对 Hessian 矩阵作基础的 频率分析,并且需要进行 数值导数;这两个功能分别实现在 freqanal.pyderiv_numerical.py 中。

%matplotlib notebook

from freqanal import FreqAnal
from deriv_numerical import NumericDiff, NucCoordDerivGenerator

import itertools
import numpy as np
from matplotlib import pyplot as plt
from opt_einsum import contract as einsum
from pyscf import gto, scf, hessian, lib, data

np.set_printoptions(6, linewidth=150, suppress=True)

我们也需要进行单位换算。这在先前的若干文档中已经有所使用。

from scipy.constants import physical_constants
# https://docs.scipy.org/doc/scipy/reference/constants.html
E_h = physical_constants["Hartree energy"][0]
a_0 = physical_constants["Bohr radius"][0]
N_A = physical_constants["Avogadro constant"][0]
c_0 = physical_constants["speed of light in vacuum"][0]
e_c = physical_constants["elementary charge"][0]
e_0 = physical_constants["electric constant"][0]
F = physical_constants["Faraday constant"][0]
k_B = physical_constants["Boltzmann constant"][0]
R = physical_constants["molar gas constant"][0]
h = physical_constants["Planck constant"][0]
hbar = physical_constants["reduced Planck constant"][0]
amu = physical_constants["atomic mass constant"][0]

除了文末的红外光谱绘制,我们使用的分子是不具有对称性的氨分子,基组 STO-3G。

mol = gto.Mole(atom="""
N  0.000000    0.000000    0.000000
H  0.000000    0.000000    0.940000
H  1.006874    0.000000   -0.260395
H -1.037114   -0.277894   -0.640054
""", basis="STO-3G", verbose=0).build()

数值三阶与四阶核坐标导数#

二阶导数回顾与频率数值#

记号定义

角标定义

  • \(A, B\):原子核标号;

  • \(\alpha, \beta, \gamma\):三维坐标的取向 (即可以取 \(x, y, z\),也可能取转动惯量主轴,本文档多数时间选择后者);

  • \(i, j, k\):简振模式的编号。

物理量定义

  • \(A_\alpha\):原子核 \(A\) 的坐标三维分量 \(\alpha\)

  • \(Q_i\):一单位的简振模式 \(i\)

  • \(Q_{A_\alpha, i}\):简振模式转换矩阵,即 \(i\) 在原子坐标分量 \(A_\alpha\) 的分量值。

需要注意到,这些记号的定义与量化程序中的定义,也与之前文档的定义是冲突或不同的。读者需要注意记号的差别。

我们知道,Hessian 矩阵是频率分析的必要矩阵,它即是二阶核坐标导数,及

\[ \Phi_{A_\alpha B_\beta} = \frac{\partial^2 E}{\partial A_t \partial B_s} \]

在原子坐标分量表示下,这应当是一个 \((A_\alpha, B_\beta): (3 n_\mathrm{atm}, 3 n_\mathrm{atm})\) 的矩阵。尽管依据程序不同,也可能是张量——在 PySCF 中,Hessian 的默认输出是 \((A, B, \alpha, \beta): (n_\mathrm{atm}, n_\mathrm{atm}, 3, 3)\) 维度的张量,但张量的大小与 \((A_\alpha, B_\beta)\) 实际上一致,经过转置两者就能相同。

natm = mol.natm
nhess = natm * 3

mf = scf.RHF(mol).run()
mf_hess = mf.Hessian().run()

我们也需要定义频率分析的实例 fa,其变量名是 FreqAnal 的首字母,与♂fa 乐器♂无关。下面的 code cell 输出的是以 \(\mathrm{cm^{-1}}\) 为简振振动频率 \(\omega_i\)

如果要与 Gaussian 的结果有更好的印证,原子质量需要取与 Gaussian 相同的值,而不太适合用整数值。因此,这里没有用 PySCF 自带的 mol.atom_mass_list,而是使用一个函数 get_atom_mass_list 来解决问题。

def get_atom_mass_list(mol):
    d = {1: 1.00782504, 6: 12., 7: 14.0030740, 8: 15.9949146}
    return np.array([d[m] for m in mol.atom_charges()])
fa = FreqAnal()
fa.mol_weights = get_atom_mass_list(mol)
fa.mol_coords = mol.atom_coords()
fa.natm = mol.natm
fa.mol_hess = mf_hess.de.swapaxes(1, 2)
nvib = fa.freq.size
fa.freq
array([-969.746082, 1680.3876  , 1931.786797, 2059.643873, 3874.822068, 5095.777567])

记号定义

我们之后会经常作单位换算与量纲分析,因此这里尽量将目前与将来需要的物理量与量纲列出:

  • \(Q_i\) 简振模式,单位 \(\mathrm{Bohr \ amu^{1/2}}\)

  • \(Q_{A_\alpha i}\) 简振模量到分子坐标表象的转换矩阵,单位 \(\mathrm{amu^{-1/2}}\),程序调用 fa.q

  • \(Q_{A_\alpha i}^\mathrm{norm}\) 归一化的简振模量转换矩阵,无量纲,程序调用 fa.qnorm

  • \(\omega_i\) 简振振动频率,单位 \(\mathrm{cm^{-1}}\),程序调用 fa.freq

  • \(\nu_i\) 非谐振动频率,单位 \(\mathrm{cm^{-1}}\)

  • \(\lambda_i\) 二阶力常数,单位 \(\mathrm{\mathit{E}_h \ Bohr^{-2} \ amu^{-1}}\)

  • \(\Phi_{A_\alpha B_\beta}\) 坐标表示的 Hessian 矩阵,单位 \(\mathrm{\mathit{E}_h \ Bohr^{-2}}\)

  • \(\Phi_{ij}\) 简振表示的 Hessian 矩阵,单位 \(\mathrm{\mathit{E}_h \ Bohr^{-2} \ amu^{-1}}\)

  • \(\Phi_{ijk}\) 简振表示的三阶导数,单位 \(\mathrm{\mathit{E}_h \ Bohr^{-3} \ amu^{-3/2}}\)

  • \(\Phi_{ijkl}\) 简振表示的四阶导数,单位 \(\mathrm{\mathit{E}_h \ Bohr^{-4} \ amu^{-2}}\)

  • \(I_\alpha\) 转动惯量,单位 \(\mathrm{amu \ Bohr^{2}}\)

  • \(\varpi_\alpha\) 转动常数,单位 \(\mathrm{cm^{-1}}\)

  • \(\zeta_{ij}^\alpha\) Coriolis 耦合常数,无量纲;

  • \(\theta_i (T)\) 温度矫正系数,无量纲;

  • \(\boldsymbol{\mu}\) 偶极矩,单位 \(\mathrm{Debye}\)

为了后文的便利,我们也会经常使用在简振模式表示下的 Hessian 矩阵:

\[ \Phi_{ij} = \frac{\partial^2 E}{\partial Q_i \partial Q_j} \]

从定义上,简振模式是分子振动的 Hessian 矩阵的本征模式,因此 \(\Phi_{ij}\) 实际上是对角矩阵 \(\lambda_i \delta_{ij}\)。其中,\(\lambda_i\) 是简振模式 \(i\) 的振动强度。坐标表示与简振表示的换算关系是

\[ \Phi_{ij} = \sum_{A_\alpha B_\beta} Q_{A_\alpha i} \Phi_{A_\alpha B_\beta} Q_{B_\beta j} \]

我们用 deriv_2 表示 \(\Phi_{ij}\)

deriv_2 = einsum("Pi, PQ, Qj -> ij", fa.q, fa.mol_hess.reshape(nhess, nhess), fa.q)
deriv_2
array([[-0.035588, -0.      ,  0.      ,  0.      , -0.      , -0.      ],
       [-0.      ,  0.106859,  0.      , -0.      ,  0.      ,  0.      ],
       [ 0.      ,  0.      ,  0.141224, -0.      ,  0.      ,  0.      ],
       [ 0.      , -0.      , -0.      ,  0.160537, -0.      , -0.      ],
       [ 0.      ,  0.      , -0.      , -0.      ,  0.568192, -0.      ],
       [-0.      , -0.      , -0.      , -0.      , -0.      ,  0.982681]])

我们注意到这是一个对称矩阵。实际上,简振模式从定义上就是在 Hessian 矩阵去除了平动、转动贡献之后的本征振动模式;因此,在简振模式表示下的 Hessian 矩阵自然应当是对角化的。其对角元我们之后也经常用到,因此以 lambd \(\lambda_i = \Phi_{ij} \delta_{ij}\) 表示,物理量上称为力常数。

lambd = deriv_2.diagonal()
lambd
array([-0.035588,  0.106859,  0.141224,  0.160537,  0.568192,  0.982681])

力常数 \(\lambda_i\) 它与频率 \(\omega_i\) 之间的关系是

\[ \omega_i = \frac{\sqrt{\lambda_i}}{2 \pi c_0} = \frac{1}{2 \pi c_0} \sqrt{\frac{k_i}{m}}, \quad \lambda = (2 \pi c_0 \omega_i)^2 \]

它与我们经常看到的另一个力常数 \(k_i\) 不同。从量纲上,\(\lambda_i\)\(\mathrm{[T]^{-2}}\)。如果考虑到一些比较麻烦的单位换算,我们可以通过频率 \(\omega_i\) 给出力常数 \(\lambda_i\)

单位换算的原则如下。我们发现,对于等式左边的待求量完全是原子单位,但右边则完全是经过变动的 SI 单位制 (波数以 \(\mathrm{cm^{-1}}\) 而非以 \(\mathrm{m^{-1}}\) 表示)。我们以 SI 单位制作为中间单位,

  • 对等式左边,\(\lambda_i\) 的单位是 \(\mathrm{\mathit{E}_h \ Bohr^{-2} \ amu^{-1}}\),我们要对应地除以这部分单位的 SI 转换;

  • 对等式右边,\(\omega_i\) 的单位是 \(\mathrm{cm^{-1}}\),我们要乘以这部分的 SI 转换 (\(100\))。

(
    (2 * np.pi * c_0 * fa.freq)**2        # equation
    / (E_h * a_0**-2 * amu**-1) * 100**2  # unit conversion
)
array([0.035588, 0.106859, 0.141224, 0.160537, 0.568192, 0.982681])

我们最后讨论一下简振模量矩阵 \(Q_{A_\alpha i}\) 与其归一化形式 \(Q_{A_\alpha i}^\mathrm{norm}\) 之间的关系。在 Gaussian 的输出中,我们一般只会看到 \(Q_{A_\alpha i}^\mathrm{norm}\),它具有下述限制:

\[ \sum_{A_\alpha} (Q_{A_\alpha i}^\mathrm{norm})^2 = 1 , \ \forall i \]

正因为这种归一化条件,\(Q_{A_\alpha i}^\mathrm{norm}\) 实际上是没有量纲的。但我们以后一直要遇到与处理的简振模量,是所谓分子质量折合过的模量,其量纲是 \(\mathrm{[M]^{-1/2}}\)。它也可以从归一化的简振模量导出,其中 \(m_\mathrm{A}\)\(A\) 原子的质量:

\[ Q_{A_\alpha i} = Q_{A_\alpha i}^\mathrm{norm} \left( \sum_{A_\alpha} \big( Q_{A_\alpha i}^\mathrm{norm} \sqrt{m_\mathrm{A}} \big)^2 \right)^{-1/2} \]
np.allclose(fa.qnorm / np.linalg.norm(fa.qnorm * np.sqrt(np.repeat(get_atom_mass_list(mol), 3))[:, None], axis=0), fa.q)
True

数值三阶导数#

我们以后一直会使用简振坐标表示下的能量导数;并且我们注意到简振模式的数量是 \(3N-6\),比原子坐标分量的 \(3N\) 数量要少一些。因此,在求取数值导数时,我们会希望使用简振模式作为实际被求导量。

我们需要的三阶导数量是

\[ \Phi_{ijk} = \frac{\partial^3 E}{\partial Q_i \partial Q_j \partial Q_k} \]

但在求取数值导数时,我们可以选择一个量作为数值偏移量,譬如 \(Q_k\);而剩下的量则是解析地计算。但我们也注意到,程序上默认给出的是原子坐标表示的 \(\Phi_{A_\alpha B_\beta}\),还需要我们作转换得到 \(\Phi_{ij}\)。因此,数值导数的求取方法是

\[ \Phi_{ijk} = \sum_{A_\alpha B_\beta} Q_{A_\alpha i} Q_{B_\beta j} \Phi_{A_\alpha B_\beta k} = \sum_{A_\alpha B_\beta} Q_{A_\alpha i} Q_{B_\beta j} \frac{\Phi_{A_\alpha B_\beta} (\delta Q_k) - \Phi_{A_\alpha B_\beta} (-\delta Q_k)}{2 \delta Q_k} \]

下面的程序就是以 \(0.01 \ \mathrm{\mathring{A}} \ \mathrm{amu}^{1/2}\) 为偏移大小,求取对简振坐标导数的程序:

class ModeDerivGenerator(NucCoordDerivGenerator):

    def __init__(self, mol, mf_func, q, interval=3e-3):
        self.q = q
        super(ModeDerivGenerator, self).__init__(mol, mf_func, stencil=3, interval=interval)
    
    def init_objects(self):
        nmode = self.q.shape[1]
        self.objects = np.empty((nmode, 2), dtype=object)
    
    def perform_mf(self):
        natm = self.mol.natm
        nmode = self.q.shape[1]
        for nq, norm in enumerate(self.q.T):
            norm = norm.reshape(natm, 3)
            movelist = [(A, t, norm[A, t]) for A in range(natm) for t in range(3)]
            self.objects[nq, 1] = self.mf_func(self.move_mol(movelist))
            movelist = [(A, t, - norm[A, t]) for A in range(natm) for t in range(3)]
            self.objects[nq, 0] = self.mf_func(self.move_mol(movelist))

我们将 Hessian 对简振坐标的导数实例定为 num_hess

num_hess = ModeDerivGenerator(mol, lambda mol: scf.RHF(mol).run().Hessian().run(), fa.q, interval=0.01)

使用 NumericDiff 直接求导的结果是 \(\Phi_{A_\alpha B_\beta k}\)。我们需要再对其进行转换再得到 \(\Phi_{ijk}\) deriv_3

tmp_3 = NumericDiff(num_hess, lambda mf: mf.de.swapaxes(1, 2).reshape(nhess, nhess)).derivative
deriv_3 = einsum("Ai, Bj, kAB -> ijk", fa.q, fa.q, tmp_3)

严格地来说,\(\Phi_{ijk}\) 应当是具有六重对称性的张量。因此,我们还可以对其进行对称化,使得结果定性上更正确:

deriv_3 = 1/3 * (deriv_3 + deriv_3.transpose(1, 2, 0) + deriv_3.transpose(2, 0, 1))

数值四阶导数#

我们通过三点差分的方法,给出了三阶导数。尽管我们无法求出全部的四阶导数,但利用刚才求取三阶导数的数据,我们可以求取 \(\Phi_{ijkk}\) 形式的四阶导数:

\[ \Phi_{ijkk} = \frac{\partial^2}{\partial Q_k^2} \frac{\partial^2 E}{\partial Q_i \partial Q_j} = \frac{\Phi_{ij} (\delta Q_k) - 2 \Phi_{ij} (0) + \Phi_{ij} (-\delta Q_k)}{(\delta Q_k)^2} \]

注意到利用现有的数据,我们只能求取 \(\Phi_{ijkk}\),而不能求取更一般的 \(\Phi_{ijkl}\)。该张量也具有对称性;我们可以对 \(i, j\) 角标进行对称化。我们将会用 deriv_4 表示 \(\Phi_{ijkk}\),维度是 \((i, j, k)\)

hess_gathered = np.array([[mf.de.swapaxes(1, 2).reshape(nhess, nhess) for mf in l] for l in num_hess.objects])
tmp_4 = einsum("ksAB, Ai, Bj -> skij", hess_gathered, fa.q, fa.q)
deriv_4 = (tmp_4[0] + tmp_4[1] - 2 * deriv_2) / (num_hess.interval**2)  # finite diff
deriv_4 = (deriv_4 + deriv_4.swapaxes(-1, -2)) / 2                      # symmetrize
deriv_4 = einsum("kij -> ijk", deriv_4)                                 # subscript transform

非谐频率矫正#

在求取频率矫正前,我们需要先获得矩阵 \(\xi_{ij}\) xmat。该矩阵与频率相同单位,即 \(\mathrm{cm}^{-1}\)。这里的公式主要参考 Barone eq. (36-38),但在与一些系数有关的问题上不太相同。该矩阵对非谐矫正后的频率至关重要。

四阶导数对 \(\xi_{ij}\) 的贡献#

若除开单位换算,四阶导数对矩阵 \(\xi_{ij}\) 的贡献是

\[\begin{split} \begin{align} \xi_{ii} &\leftarrow \frac{\hbar}{2 \pi c_0} \frac{1}{16 \lambda_i} \Phi_{iiii} \\ \xi_{ij} &\leftarrow \frac{\hbar}{2 \pi c_0} \frac{1}{4 \sqrt{|\lambda_i \lambda_j|}} \Phi_{iijj} \quad (i \neq j) \end{align} \end{split}\]

之所以表达式中需要引入绝对值,是因为我们同时处理实频与虚频。虚频下,\(\lambda_i\) 小于零,无法开根号。依据 Miller, et al. [2] 的表达式,实际上对于有且仅有一个虚频的 \(Q_i\),其对应的 \(\xi_{ij}\) 也应当是虚数。当前的分子也恰好是有且仅有一个虚频,并且实际上虚数的 \(\xi_{ij}\) 可以与虚数的 \(\omega_i\) 相加得到非谐频率 \(\nu_i\);因此就这种过渡态情形,在程序实现上,可以对虚频与实频一视同仁。

xmat_4 = np.zeros((nvib, nvib))
for i in range(nvib):
    xmat_4[i, i] = deriv_4[i,i,i] / (16 * lambd[i])
    for j in range(nvib):
        if i == j: continue
        xmat_4[i, j] = deriv_4[i,i,j] / (4 * np.sqrt(np.abs(lambd[i] * lambd[j])))
xmat_4 *= (hbar / (2 * np.pi * c_0)) * (a_0**-2 * amu**-1) / (100)
xmat_4
array([[ -59.929105,   17.359227,    2.022484,  -26.747814,  -89.778445, -133.911891],
       [  17.360842,   11.4939  ,   18.955104,   -6.407765,   -6.83072 , -112.008037],
       [   2.024055,   18.952477,    4.4002  ,   42.205352,  -50.906768,  -38.849444],
       [ -26.737152,   -6.40661 ,   42.197109,   45.874797,  -16.574222,   -5.321896],
       [ -89.725547,   -6.828563,  -50.876096,  -16.565645,   62.64625 ,    1.480829],
       [-133.825551, -111.932215,  -38.826908,   -5.319965,    1.480938,   70.703952]])

三阶导数对 \(\xi_{ij}\) 的贡献#

三阶导数对 \(\xi_{ij}\) 的贡献核算相对复杂。我们首先处理对角线的贡献:

\[ \xi_{ii} \leftarrow - \frac{\hbar}{2 \pi c_0} \frac{1}{16 \lambda_i} \left( \frac{5}{3 \lambda_i} \Phi_{iii}^2 + \frac{8 \lambda_i - 3 \lambda_j}{\lambda_j (4 \lambda_i - \lambda_j)} \Phi_{iij}^2 \right) \]
xmat_3 = np.zeros((nvib, nvib))
for i in range(nvib):
    xmat_3[i, i] -= deriv_3[i,i,i]**2 * 5 / (3 * lambd[i])
    for j in range(nvib):
        if i == j: continue
        xmat_3[i, i] -= deriv_3[i,i,j]**2 * (8 * lambd[i] - 3 * lambd[j]) / (lambd[j] * (4 * lambd[i] - lambd[j]))
    xmat_3[i, i] /= 16 * lambd[i]

随后处理非对角线的贡献 (\(i \neq j\)):

\[\begin{split} \begin{align} \xi_{ij} &\leftarrow - \frac{\hbar}{2 \pi c_0} \frac{1}{4 \sqrt{|\lambda_i \lambda_j|}} \left( \frac{2}{4 \lambda_i - \lambda_j} \Phi_{iij}^2 + \frac{2}{4 \lambda_j - \lambda_i} \Phi_{ijj}^2 + \frac{\Phi_{iii} \Phi_{ijj}}{\lambda_i} + \frac{\Phi_{jjj} \Phi_{iij}}{\lambda_j} \right) \\ &\quad + \frac{\hbar}{2 \pi c_0} \frac{1}{4 \sqrt{|\lambda_i \lambda_j|}} \sum_{k \notin \{i, j\}} \left[ \frac{2 (\lambda_i + \lambda_j - \lambda_k)}{\Delta_{ijk}} \Phi_{ijk}^2 - \frac{\Phi_{iik} \Phi_{jjk}}{\lambda_k} \right] \end{align} \end{split}\]
for i in range(nvib):
    for j in range(nvib):
        if i == j: continue
        xmat_3[i, j] -= deriv_3[i,i,j]**2 * 2 / (4 * lambd[i] - lambd[j])
        xmat_3[i, j] -= deriv_3[i,j,j]**2 * 2 / (4 * lambd[j] - lambd[i])
        xmat_3[i, j] -= deriv_3[i,i,i] * deriv_3[i,j,j] / lambd[i]
        xmat_3[i, j] -= deriv_3[j,j,j] * deriv_3[i,i,j] / lambd[j]
        for k in range(nvib):
            if len(set([i, j, k])) != 3: continue
            delta_ijk = lambd[i]**2 + lambd[j]**2 + lambd[k]**2 - 2 * (lambd[i]*lambd[j] + lambd[j]*lambd[k] + lambd[k]*lambd[i])
            xmat_3[i, j] += deriv_3[i,j,k]**2 * 2 * (lambd[i] + lambd[j] - lambd[k]) / delta_ijk
            xmat_3[i, j] -= deriv_3[i,i,k] * deriv_3[j,j,k] / lambd[k]
        xmat_3[i, j] /= 4 * np.sqrt(np.abs(lambd[i] * lambd[j]))
xmat_3 *= (hbar / (2 * np.pi * c_0)) * (a_0**-2 * amu**-1) / (100)
xmat_3
array([[   6.436059,  -17.659895,  -12.843587,  -17.730696,   49.859864,   86.717056],
       [ -17.659895,  -18.294258,  -35.959998,  -46.395273,    2.625465,   94.139235],
       [ -12.843587,  -35.959998,  -65.430162, -108.220206,  228.295265,   26.274758],
       [ -17.730696,  -46.395273, -108.220206, -115.728507,  -19.13443 ,   -3.494852],
       [  49.859864,    2.625465,  228.295265,  -19.13443 , -111.055075,  -10.265471],
       [  86.717056,   94.139235,   26.274758,   -3.494852,  -10.265471, -112.711441]])

Coriolis 矫正对 \(\xi_{ij}\) 的贡献#

Coriolis 矫正的贡献方式是 (允许 \(i = j\))

\[ \xi_{ij} \leftarrow \sum_\alpha \frac{\lambda_i + \lambda_j}{\sqrt{|\lambda_i \lambda_j|}} \varpi_\alpha (\zeta_{ij}^\alpha)^2 \]

这里引入了一些特殊的记号。被求和的角标 \(\alpha\) 尽管是 \(x, y, z\) 三个取向之一,但在求取 Coriolis 矫正贡献时,它必须是转动惯量主轴。

我们在通过 Hessian 矩阵得到分子振动频率时,需要去除转动的三个自由度贡献。那时我们已经计算过与转动有关的各种中间变量。\(\varpi_\alpha\) rot_wavenum 是以 \(\mathrm{cm^{-1}}\) 为单位的转动常数:

\[ \varpi_\alpha = \frac{h}{8 \pi^2 c_0 I_\alpha} \]
rot_wavenum = h / (8 * np.pi**2 * c_0 * fa.rot_eig) * (a_0**-2 * amu**-1) / (100)
rot_wavenum
array([13.875725,  7.153573,  4.775983])

\(\zeta_{ij}^\alpha\) zeta Coriolis 耦合常数通过下式进行计算 (Barone, eq (11),维度 \((\alpha, i, j)\),无量纲):

\[\begin{split} \begin{align} \zeta_{ij}^\alpha &= \sum_k (L_{A_\beta i} L_{A_\gamma j} - L_{A_\gamma i} L_{A_\beta j}) \\ L_{A_\alpha i} &= \sum_\beta Q_{A_\beta i} R_{\beta \alpha} \sqrt{m_A} \end{align} \end{split}\]

其中,\(R_{\beta \alpha}\) 转动的本征向量,无量纲。其本征值是转动惯量,这在 频率分析 (1) 中有所提及。同时,求取 \(\zeta_{ij}^\alpha\) 时,其表达式的分量 \(\alpha, \beta, \gamma\) 必须按照右手坐标系排序。

q_ = einsum("Aβi, βα, A -> Aαi", fa.q.reshape(natm, 3, nvib), fa.rot_vec, np.sqrt(get_atom_mass_list(mol)))
zeta_x = einsum("Ai, Aj -> ij", q_[:, 1, :], q_[:, 2, :]) - einsum("Ai, Aj -> ij", q_[:, 2, :], q_[:, 1, :])
zeta_y = einsum("Ai, Aj -> ij", q_[:, 2, :], q_[:, 0, :]) - einsum("Ai, Aj -> ij", q_[:, 0, :], q_[:, 2, :])
zeta_z = einsum("Ai, Aj -> ij", q_[:, 0, :], q_[:, 1, :]) - einsum("Ai, Aj -> ij", q_[:, 1, :], q_[:, 0, :])
zeta = np.array([zeta_x, zeta_y, zeta_z])

同时,转动本征向量还可以用来将分子旋转到转动主轴坐标系,且该坐标系的 \(I_x < I_y < I_z\)。该坐标系在 Gaussian 中称为 Eckart Orientation。我们可以输出该坐标系下以 Angstrom 为单位的分子构型。

fa.centered_coord @ fa.rot_vec * a_0 * 1e10
array([[ 0.003095, -0.002241, -0.016268],
       [ 0.384988,  0.851931,  0.073996],
       [ 0.805212, -0.657419,  0.078348],
       [-1.233207, -0.163374,  0.07369 ]])

至此,我们就可以将 Coriolis 矫正的贡献计算出来。

\[ \xi_{ij} \leftarrow \sum_\alpha \frac{\lambda_i + \lambda_j}{\sqrt{|\lambda_i \lambda_j|}} \varpi_\alpha (\zeta_{ij}^\alpha)^2 \]
xmat_coriol = np.zeros((nvib, nvib))
for i in range(nvib):
    for j in range(nvib):
        for ixyz in range(3):
            xmat_coriol[i, j] += zeta[ixyz, i, j]**2 * rot_wavenum[ixyz]
        xmat_coriol[i, j] *= (lambd[i] + lambd[j]) / np.sqrt(np.abs(lambd[i] * lambd[j]))
xmat_coriol
array([[-0.      ,  6.485503,  2.854787,  7.462297, 14.215879, 25.469408],
       [ 6.485503,  0.      ,  0.862218,  2.938909,  0.906227,  8.749904],
       [ 2.854787,  0.862218,  0.      ,  0.883756,  5.835533,  3.491808],
       [ 7.462297,  2.938909,  0.883756,  0.      ,  1.970819,  0.474133],
       [14.215879,  0.906227,  5.835533,  1.970819,  0.      ,  0.009302],
       [25.469408,  8.749904,  3.491808,  0.474133,  0.009302,  0.      ]])

危险

对于 Gaussian 09 rev. D01 及 C01,其输出的 Coriolis 贡献是错误的。其原因在于没有将分子坐标预先转置到 Eckart Orientation 下,而仍然在输入坐标下进行计算。将这种坐标与转动惯量主轴的转动频率作乘积没有意义。也因此,该版本的 Gaussian 实际上无法正确地输出频率的矫正;除非输入坐标与 Eckart Orientation 一致。

如果要重复该版本的结果,对于当前的分子,可以使用后面一个被折叠的 code cell 代码。

我们之前号称正确的实现方式,与 Gaussian 16 rev. B01 的结果应当一致。

Hide code cell content
def xmat_coriol_G09():
    # -- What makes different --
    q_ = einsum("Aαi, A -> Aαi", fa.q.reshape(natm, 3, nvib), np.sqrt(get_atom_mass_list(mol)))
    rot_in_G09 = rot_wavenum[0], rot_wavenum[2], rot_wavenum[1]
    # -- The rest are merely the same --
    zeta = np.zeros((3, nvib, nvib))
    for α, β, γ in [(0, 1, 2), (1, 2, 0), (2, 0, 1)]:
        zeta[α] = einsum("Ai, Aj -> ij", q_[:, β, :], q_[:, γ, :]) - einsum("Ai, Aj -> ij", q_[:, γ, :], q_[:, β, :])
    xmat_coriol = einsum("αij, α, ij -> ij", zeta**2, rot_in_G09, (lambd[:, None] + lambd[None, :]) / np.sqrt(np.abs(lambd[:, None] * lambd[None, :])))
    return xmat_coriol
xmat_coriol_G09()
array([[ 0.      ,  4.90998 ,  3.505416,  9.864751, 10.909095, 26.892735],
       [ 4.90998 ,  0.      ,  0.933722,  3.050355,  0.933074,  9.206211],
       [ 3.505416,  0.933722,  0.      ,  0.902214,  5.887021,  3.409958],
       [ 9.864751,  3.050355,  0.902214,  0.      ,  1.987215,  0.43257 ],
       [10.909095,  0.933074,  5.887021,  1.987215,  0.      ,  0.009969],
       [26.892735,  9.206211,  3.409958,  0.43257 ,  0.009969,  0.      ]])
Hide code cell content
! head -n 9725 anharm-G09C01.log | tail -n 10
 Coriolis contributions to Xmat 
                1             2             3             4             5 
      1  0.000000D+00
      2  0.268927D+02  0.000000D+00
      3  0.109091D+02  0.996906D-02  0.000000D+00
      4  0.986475D+01  0.432572D+00  0.198722D+01  0.000000D+00
      5  0.350541D+01  0.340996D+01  0.588702D+01  0.902213D+00  0.000000D+00
      6  0.490997D+01  0.920621D+01  0.933076D+00  0.305035D+01  0.933724D+00
                6 
      6  0.000000D+00

\(\xi_{ij}\) 矩阵与非谐频率的导出#

我们首先将四阶导数、三阶导数、Coriolis 的贡献相加,得到总 \(\xi_{ij}\) 矩阵。

xmat = xmat_4 + xmat_3 + xmat_coriol
xmat
array([[-53.493046,   6.184835,  -7.966316, -37.016213, -25.702702, -21.725427],
       [  6.18645 ,  -6.800358, -16.142676, -49.864129,  -3.299028,  -9.118898],
       [ -7.964745, -16.145302, -61.029962, -65.131098, 183.22403 ,  -9.082879],
       [-37.005551, -49.862974, -65.139341, -69.85371 , -33.737833,  -8.342616],
       [-25.649804,  -3.296871, 183.254702, -33.729256, -48.408825,  -8.77534 ],
       [-21.639087,  -9.043076,  -9.060343,  -8.340685,  -8.775231, -42.007489]])

这可以与 Gaussian 的输出作比对。需要注意,我们的结果与 Gaussian 并不完全一致,且不仅是由于简振模式的排序不同所产生。这是由于 Fermi 共振所致,我们后面会提及。我们的程序无法处理 Fermi 共振,但 Gaussian 会有一些办法。

! head -n 14013 anharm-G16B01.log | tail -n 11
 Total Anharmonic X Matrix (in cm^-1)
 ------------------------------------
                1             2             3             4             5
      1 -0.534804D+02
      2 -0.216582D+02 -0.420071D+02
      3 -0.256619D+02 -0.877403D+01 -0.484077D+02
      4 -0.370099D+02 -0.834034D+01 -0.337307D+02 -0.698507D+02
      5 -0.797061D+01 -0.906609D+01 -0.296660D+02 -0.651376D+02 -0.780405D+01
      6  0.617458D+01 -0.905380D+01 -0.329760D+01 -0.498632D+02 -0.161479D+02
                6
      6 -0.680891D+01

依据 Barone eq. (43),非谐频率很容易地通过下式给出 (单位 \(\mathrm{cm^{-1}}\)):

\[ \nu_i = \omega_i + 2 \xi_{ii} + \frac{1}{2} \sum_{j \neq i} \xi_{ij} = \omega_i + \frac{3}{2} \xi_{ii} + \frac{1}{2} \sum_j \xi_{ij} \]
fa.freq + 1.5 * xmat.diagonal() + 0.5 * xmat.sum(axis=-1)
array([-1119.845085,  1630.667744,  1852.176877,  1822.892296,  3833.906134,  4983.333379])

当我们与 Gaussian 的非谐频率进行比对时,会发现我们 \(\mathrm{3834 \ cm^{-1}}\) 波数的结果与 Gaussian 的 \(\mathrm{3748 \ cm^{-1}}\) 相差很大。

! head -n 14193 anharm-G16B01.log | tail -n 9
 Fundamental Bands
 -----------------
     Mode(n)      Status      E(harm)  E(anharm)     Aa(x)      Ba(y)      Ca(z)
        1(1)      active      -969.747 -1119.771  14.263374   7.063101   4.568200
        2(1)      active      5095.778  4983.317  14.043335   6.985596   4.575906
 H      3(1)      active      3874.822  3747.756  14.108724   6.929780   4.566570
        4(1)      active      2059.644  1822.902  14.182793   6.771150   4.581152
        5(1)      active      1931.787  1852.185  14.252150   7.053448   4.510857
        6(1)      active      1680.388  1630.676  14.460822   7.057768   4.466441

这是源于 Gaussian 有对近简并倍频振动模式 (Fermi 共振) 的处理。在我们的问题中,\(\omega_4 - 2 \omega_2 = 3834 - 2 \times 1852 = 11 (\mathrm{cm^{-1}})\) 被认为是倍频近简并的:

fa.freq[4] - 2 * fa.freq[2]
11.248473679877407
! head -n 13948 anharm-G16B01.log | tail -n 5
 Fermi resonances
 ----------------

     I      J  +   K   Freq. Diff.  Red. Cubic Const.  PT2-Variat.Diff.
      3      5      5       11.248         138.473           1009.207

它对下述三阶导数贡献项 \(\xi_{42} = \xi_{24}\) 会有非常大的贡献 (注意到 \(\lambda_i \propto \omega_i^2\),因此下式中分母有 \(\omega_4 - 2 \omega_2\) 的因式):

\[ \xi_{24} \leftarrow - \frac{\hbar}{2 \pi c_0} \frac{1}{4 \sqrt{|\lambda_2 \lambda_4|}} \frac{2}{4 \lambda_2 - \lambda_4} \Phi_{224}^2 \]

这类表达式可以拆分为共振项与非共振项:

\[ \frac{1}{4 \lambda_i - \lambda_j} = \frac{1}{(2 \pi c_0)^2} \left( \underset{\text{non-resonant}}{\frac{1}{4 \omega_i (2 \omega_i + \omega_j)}} + \underset{\text{resonant}}{\frac{1}{4 \omega_i (2 \omega_i - \omega_j)}} \right) \]

在 Gaussian 中,当被判断为 Fermi 共振时,就会直接排除共振项,而只保留非共振项,以避免频率矫正的奇点。同时会在非谐频率输出的部分用记号 H 表示这种 Fermi 共振效应。

简振坐标平均偏移#

为了计算分子的各种性质 (譬如偶极、红外等光谱性质) 的非谐矫正矫正,我们需要给出简振坐标的平均偏移量 \(\langle Q_i \rangle\) 与二阶平均偏移量 \(\langle Q_i Q_j \rangle\)

我们在这一节中,为了与 Gaussian 的结果作比对,暂时不考虑转动矫正效应。

温度矫正效应#

我们现在要考虑温度对分子振动的影响。一般来说,温度越高,分子的振动会越剧烈,其各种谱学性质与 \(0 \ K\) 下的结果会偏差越大。这种温度效应是通过近似的配分函数所给出 (类似于 softmax 的效果)。虚频被认为不参与配分,因此其值置为零。该配分函数是 (Barone, eq. (59))

\[\begin{split} \theta_i (T) = \left\{ \begin{alignat}{3} & \cot \left( \frac{h \omega_i c_0}{2 k_\mathrm{B} T} \right) \quad &&\omega_i \in \mathbb{R} \\ & 0 && \omega_i \in \mathbb{I} \end{alignat} \right. \end{split}\]

在后续的文档中,我们始终会使用 \(T_{2500} = 2500 \ \mathrm{K}\)

def get_coef_temp(T, calc_imag=False):
    coef_temp = np.tanh(h * fa.freq * 100 * c_0 / (2 * k_B * T))**-1
    if not calc_imag: coef_temp[coef_temp < 0] = 0
    else: coef_temp = np.abs(coef_temp)
    return coef_temp

显然,在 \(0 \ \mathrm{K}\) 时,实数频率的 \(\theta_i (0) = 1\)。在 \(2500 \ \mathrm{K}\) 时,温度对简振坐标平均偏移的矫正比例 coef_temp

T = 2500
coef_temp = get_coef_temp(T)
coef_temp
array([0.      , 2.226801, 1.980529, 1.88035 , 1.240967, 1.1125  ])

一阶简振坐标平均偏移的振动贡献#

我们假定虚频对谱学性质的计算不产生贡献。对于实数的简振模式 \(i\),一阶的简振坐标平均偏移 \(\langle Q_i \rangle^\mathrm{vib}\) 为 (Barone, eq. (56)):

\[ \langle Q_i \rangle^\mathrm{vib} (T) = - \frac{\hbar}{4 \lambda_i} \sum_j \frac{\Phi_{ijj}}{\sqrt{|\lambda_j|}} \theta_j (T) \]

我们将常用的转换系数置于变量 Q_scale,并令 \(\langle Q_i \rangle^\mathrm{vib} (0)\)Q_1\(\langle Q_i \rangle^\mathrm{vib} (T_{2500})\)QT_1

Q_scale = hbar / np.sqrt(E_h * a_0**2 * amu)
Q_1, QT_1 = np.zeros(nvib), np.zeros(nvib)
for i in range(nvib):
    if fa.freq[i] <= 0: continue
    for j in range(nvib):
        val = - deriv_3[i,j,j] / (4 * lambd[i] * np.sqrt(np.abs(lambd[j]))) * Q_scale
        Q_1[i] += val
        QT_1[i] += val * coef_temp[j]
print("<Q> (0 K)   ", Q_1)
print("<Q> (2500 K)", QT_1)
<Q> (0 K)    [ 0.        0.014627 -0.03185  -0.059824  0.01557   0.00532 ]
<Q> (2500 K) [ 0.        0.026619 -0.055617 -0.11182   0.022404  0.005395]

Gaussian 的结果显式如下。之所以会有正负号的差异,是因为我们的简振坐标方向会与 Gaussian 有些微区别。

! head -n 13347 anharm-G16B01.log | tail -n 8
 Average Normal Coordinates (in amu^1/2.bohr)
 --------------------------------------------
   Mode    <Q> (0)       <Q> (******)
      2     0.005318        0.005392
      3     0.015568        0.022402
      4    -0.059824       -0.111821
      5     0.031847        0.055613
      6    -0.014626       -0.026617

危险

对于这里的实现,关于如何处理虚频,Gaussian 09 rev. D01 与 Gaussian 16 rev. B01 应是一致的,但与 Gaussian 09 rev. C01 不一致。两边似乎都有不合理之处。

我们之前采用的是与 Gaussian 16 rev. B01 相似的做法。

  • 对于 Gaussian 09 rev. C01,它在计算温度矫正系数时,并没有排除虚频部分。同时,该版本还只能计算室温的温度效应。使用后一个被折叠的 code cell 即可给出该版本的结果。

  • 对于 Gaussian 16 rev. B01,尽管在 \(2500 \ \mathrm{K}\) 时排除了虚频的贡献,但在 \(0 \ \mathrm{K}\) 下仍然考虑了虚频;因此尽管说 \(T_{2500}\) 是很高的温度,但 \(\langle Q_i \rangle^\mathrm{vib} (0)\)\(\langle Q_i \rangle^\mathrm{vib} (T_{2500})\),甚至极端一些 \(\langle Q_i \rangle^\mathrm{vib} (1 \ \mathrm{K})\) 的差距还是会非常大。这种做法似乎也不合理。

Hide code cell content
def print_Q_1_G09C01():
    coef_temp_G09C01 = np.abs(get_coef_temp(298, calc_imag=True))
    Q_1, QT_1 = np.zeros(nvib), np.zeros(nvib)
    for i in range(nvib):
        if fa.freq[i] <= 0: continue
        for j in range(nvib):
            val = - deriv_3[i,j,j] / (4 * lambd[i] * np.sqrt(np.abs(lambd[j]))) * Q_scale
            Q_1[i] += val
            QT_1[i] += val * coef_temp_G09C01[j]
    print("<Q> (0 K)   ", Q_1)
    print("<Q> (298 K) ", QT_1)
print_Q_1_G09C01()
<Q> (0 K)    [ 0.        0.014627 -0.03185  -0.059824  0.01557   0.00532 ]
<Q> (298 K)  [ 0.        0.014657 -0.031826 -0.059851  0.015479  0.005218]

二阶简振坐标平均偏移的振动贡献#

对于实数的简振模式 \(i\),一阶的简振坐标平均偏移 \(\langle Q_i Q_j \rangle\) 只有在 \(i = j\) 时非零。因此,我们只需要考虑 \(\langle Q_i^2 \rangle (T)\) (Barone, eq. (56)):

\[ \langle Q_i^2 \rangle (T) = \frac{\hbar}{2 \sqrt{\lambda_i}} \]

我们令 \(\langle Q_i^2 \rangle (0)\)Q_2\(\langle Q_i^2 \rangle (T_{2500})\)QT_2

Q_2, QT_2 = np.zeros(nvib), np.zeros(nvib)
for i in range(nvib):
    if fa.freq[i] <= 0: continue
    val = 1 / (2 * np.sqrt(lambd[i])) * Q_scale
    Q_2[i] += val
    QT_2[i] += val * coef_temp[i]
print(Q_2)
print(QT_2)
[0.       0.035825 0.031163 0.029228 0.015536 0.011814]
[0.       0.079775 0.061719 0.054959 0.01928  0.013143]

Gaussian 的结果显式如下。

! head -n 13338 anharm-G16B01.log | tail -n 8
 Mean Square Amplitudes of Normal Coordinates (in amu.bohr^2)
 -----------------------------------------------------------
 Mode    <Q^2> (0)    <Q^2> (******)    <Q^2> (******) class.
    2     0.011814       0.013143          0.318060
    3     0.015536       0.019280          0.550081
    4     0.029228       0.054959          1.946911
    5     0.031163       0.061719          2.213156
    6     0.035825       0.079775          2.924904

危险

Barone 文章的 eq. (57) 公式很有可能差了一个正负号。因此上面的实现方式与 Barone 原文并不完全一致。在 Harding et al. [3] 的 eq. (3) 中,就没有出现负号;因此多半与 Gaussian 的实现是一致的。在作这部分 Double Check 后,决定在这篇文档的程序实现中去除负号。

非谐谱学性质:偶极矩#

现在我们考虑在非谐近似下,相对来说最简单的光谱性质,即偶极矩强度。

我们在获得了简振坐标平均偏移量 \(\langle Q_i \rangle\)\(\langle Q_i^2 \rangle\),且得到平衡构型下目标性质 \(P\) 对简振坐标的导数后,就可以求取特定温度非谐近似下的目标性质的值:

\[ \langle P \rangle^\mathrm{anharm} (T) \simeq P + \sum_i \frac{\partial P}{\partial Q_i} \cdot \langle Q_i \rangle (T) + \frac{1}{2} \sum_{i} \frac{\partial^2 P}{\partial Q_i^2} \cdot \langle Q_i^2 \rangle (T) \]

尽管求取的方式并不复杂,但在获取程序输出时,仍然必须要格外当心。

偶极矩的高阶导数#

我们知道,为获得红外光谱数据,我们需要计算偶极在原子坐标下的导数 \(\partial \boldsymbol{\mu} / \partial A_\alpha\)。在 Gaussian 中,只要进行频率分析 (求取能量的二阶导数),就可以获得偶极矩的原子坐标导数。

在 PySCF 中,尽管没有特定的函数去完成偶极矩的原子坐标导数的功能;但对于开壳层的自洽场计算方法,如果我们有了 Hessian 计算的实例,那么其求取实际上只需要 15 行代码左右。下述 get_dipderiv 通过带入闭壳层 Hessian 计算实例,给出偶极在原子坐标下的导数 \(\partial \boldsymbol{\mu} / \partial A_\alpha\) (维度:\((A, \alpha, \gamma)\),其中 \(A_\alpha\) 是原子坐标,\(\gamma\) 是偶极方向)。

def get_dipderiv(mf_hess):
    mf, mol = mf_hess.base, mf_hess.mol
    natm, nao, nocc = mol.natm, mol.nao, mol.nelec[0]
    H_2_ao = np.zeros((3, natm, 3, nao, nao))
    int1e_irp = mol.intor("int1e_irp").reshape((3, 3, nao, nao))
    for A in range(natm):
        _, _, p0, p1 = mol.aoslice_by_atom()[A]
        H_2_ao[:, A, :, :, p0:p1] = int1e_irp[:, :, :, p0:p1]
    H_2_ao += H_2_ao.swapaxes(-1, -2)
    dipderiv_skeleton = einsum("rAtuv, uv -> rAt", H_2_ao, mf.make_rdm1())
    dipderiv_nuc = einsum("rt, A -> rAt", np.eye(3), mol.atom_charges())
    h1ao = mf_hess.make_h1(mf.mo_coeff, mf.mo_occ)
    mo1 = np.array(mf_hess.solve_mo1(mf.mo_energy, mf.mo_coeff, mf.mo_occ, h1ao)[0])
    dipderiv_U = 4 * einsum("Atui, ruv, vi -> rAt", mo1, - mol.intor("int1e_r"), mf.mo_coeff[:, :nocc])
    return (dipderiv_skeleton + dipderiv_nuc + dipderiv_U).reshape(3, 3 * natm).T

在可以解析地计算偶极的核坐标导数之后,仿照先前求取能量对简振模量的高阶导数,我们很容易地给出偶极矩对简振坐标的数值二阶导数、以及一部分数值三阶导数。程序计算所使用的单位是偶极使用 \(\mathrm{Debye}\),简振坐标使用原子单位 \(\mathrm{Bohr \ amu^{1/2}}\),所有偶极取向均变换到 Eckart Orientation。需要注意到我们无法求取完整的三阶导数,因此三阶导数量的维度与二阶相同。

  • dip_0\(\mu_\gamma\)

  • dip_1\(\partial \mu_\gamma / \partial Q_i\),维度 \((i, \gamma)\)

  • dip_2\(\partial^2 \mu_\gamma / \partial Q_i \partial Q_j\),维度 \((i, j, \gamma)\)

  • dip_3\(\partial^3 \mu_\gamma / \partial Q_i^2 \partial Q_j\),维度 \((i, j, \gamma)\)

这部分的结果可以与 Gaussian 16 rev. B01 作核验。

dip_0 = mf.dip_moment() @ fa.rot_vec
dip_1 = einsum("Aα, αγ, Ai -> iγ", get_dipderiv(mf_hess), fa.rot_vec, fa.q) * data.nist.AU2DEBYE
dip_tmp_2 = NumericDiff(num_hess, lambda mf_hess: get_dipderiv(mf_hess)).derivative * data.nist.AU2DEBYE
dip_2 = einsum("iAα, αγ, Aj -> ijγ", dip_tmp_2, fa.rot_vec, fa.q)
dipderiv_gathered = np.array([[get_dipderiv(mf_hess) for mf_hess in l] for l in num_hess.objects]) * data.nist.AU2DEBYE
dip_tmp_3 = einsum("iσAα, αγ, Aj -> σijγ", dipderiv_gathered, fa.rot_vec, fa.q)
dip_3 = (dip_tmp_3[0] + dip_tmp_3[1] - 2 * dip_1) / (num_hess.interval**2)
Dipole moment(X, Y, Z, Debye): -0.01359, -0.52278,  0.07319

警告

Gaussian 09 rev. D01 版本中,偶极矩的取向对应输入分子所使用的坐标系;但 Gaussian 16 rev. B01 版本中,偶极的取向对应 Eckart Orientation。本文档使用后者;读者需要小心地处理坐标系取向。

二阶非谐效应的偶极贡献#

矫正项 \(\partial^2 \mu_\gamma / \partial Q_i^2 \cdot \langle Q_i^2 \rangle (T)\) 以维度为 \((i, \alpha)\) 的矩阵表示如下:

  • dip_anharm_2\(T = 0\) 的情形

  • dipT_anharm_2\(T = T_{2500}\) 的情形

dip_anharm_2 = 0.5 * dip_2.diagonal(0, 0, 1).T * Q_2[:, None]
dip_anharm_2
array([[ 0.      ,  0.      , -0.      ],
       [ 0.002213, -0.001139, -0.000712],
       [-0.004645,  0.001438,  0.000898],
       [ 0.008767,  0.001811, -0.000082],
       [-0.003475,  0.002666,  0.000128],
       [-0.002509, -0.005047, -0.000059]])
dipT_anharm_2 = 0.5 * dip_2.diagonal(0, 0, 1).T * QT_2[:, None]
dipT_anharm_2
array([[ 0.      ,  0.      , -0.      ],
       [ 0.004927, -0.002536, -0.001586],
       [-0.0092  ,  0.002849,  0.001778],
       [ 0.016485,  0.003405, -0.000155],
       [-0.004312,  0.003309,  0.000159],
       [-0.002791, -0.005614, -0.000066]])

一阶振动非谐效应的偶极贡献#

矫正项 \(\partial \mu_\gamma / \partial Q_i \cdot \langle Q_i \rangle^\mathrm{vib} (T)\) 以维度为 \((i, \alpha)\) 的矩阵表示如下:

  • dip_anharm_1\(T = 0\) 的情形

  • dipT_anharm_1\(T = T_{2500}\) 的情形

dip_anharm_1 = dip_1 * Q_1[:, None]
dip_anharm_1
array([[-0.      , -0.      , -0.      ],
       [-0.000133,  0.004004, -0.000545],
       [-0.004411,  0.005262,  0.000769],
       [-0.013259, -0.008027, -0.000521],
       [ 0.002164, -0.000338,  0.000207],
       [ 0.000337,  0.000047,  0.000094]])
dipT_anharm_1 = dip_1 * QT_1[:, None]
dipT_anharm_1
array([[-0.      , -0.      , -0.      ],
       [-0.000242,  0.007286, -0.000992],
       [-0.007703,  0.009188,  0.001343],
       [-0.024782, -0.015004, -0.000973],
       [ 0.003114, -0.000487,  0.000299],
       [ 0.000342,  0.000048,  0.000095]])

危险

Gaussian 16 rev. B01 与 Gaussian 09 rev. D01 在一阶振动计算上都存在问题,且问题均为计算性质时所用的 \(\langle Q_i \rangle^\mathrm{vib} (T)\) 与先前计算的结果不同。

我们回顾到,对于实数频率,

\[ \langle Q_i \rangle^\mathrm{vib} (T) = - \frac{\hbar}{4 \lambda_i} \sum_j \frac{\Phi_{ijj}}{\sqrt{|\lambda_j|}} \theta_j (T) \]

但在 Gaussian 中的实现,很有可能是

\[ \langle Q_i \rangle^\mathrm{vib} (T) = - \frac{\hbar}{4 \lambda_i} \sum_j \frac{\Phi_{ijj}}{\sqrt{|\lambda_j|}} \theta_j (298.15 \ \mathrm{K}) \theta_j (T) \]

我认为这显然有些问题哈。

下面被折叠的 code cell 可以复现 Gaussian 输出的结果。

Hide code cell content
def dip_anharm_1_Gaussian():
    Q_1, QT_1, QTT_1 = np.zeros(nvib), np.zeros(nvib), np.zeros(nvib)
    for i in range(nvib):
        if fa.freq[i] <= 0: continue
        for j in range(nvib):
            val = - deriv_3[i,j,j] / (4 * lambd[i] * np.sqrt(np.abs(lambd[j]))) * Q_scale
            Q_1[i] += val
            QT_1[i] += val * get_coef_temp(298.15)[j]
            QTT_1[i] += val * get_coef_temp(298.15)[j] * get_coef_temp(T)[j]
    print("Gaussian 16 rev. B01")
    print("Temperature: 0 K")
    print(dip_1 * QT_1[:, None])
    print("Temperature: 2500 K")
    print(dip_1 * QTT_1[:, None])
    print("=====")
    print("Gaussian 09 rev. D01")
    print("Temperature: 0 K")
    print(dip_1 * QT_1[:, None] @ fa.rot_vec.T)
    print("Temperature: 2500 K")
    print(dip_1 * QTT_1[:, None] @ fa.rot_vec.T)
dip_anharm_1_Gaussian()
Gaussian 16 rev. B01
Temperature: 0 K
[[-0.       -0.       -0.      ]
 [-0.000119  0.003575 -0.000487]
 [-0.004617  0.005507  0.000805]
 [-0.013046 -0.007899 -0.000512]
 [ 0.002838 -0.000443  0.000272]
 [ 0.000674  0.000094  0.000188]]
Temperature: 2500 K
[[-0.       -0.       -0.      ]
 [-0.000242  0.007287 -0.000993]
 [-0.007704  0.009189  0.001343]
 [-0.024786 -0.015006 -0.000973]
 [ 0.003113 -0.000487  0.000299]
 [ 0.000341  0.000048  0.000095]]
=====
Gaussian 09 rev. D01
Temperature: 0 K
[[ 0.        0.        0.      ]
 [-0.001651  0.000601  0.003154]
 [-0.006357 -0.001267  0.003206]
 [-0.008541 -0.001726 -0.012527]
 [ 0.002775  0.000134  0.000776]
 [ 0.000591 -0.000082  0.000378]]
Temperature: 2500 K
[[ 0.        0.        0.      ]
 [-0.003365  0.001224  0.006428]
 [-0.010607 -0.002114  0.00535 ]
 [-0.016227 -0.003279 -0.0238  ]
 [ 0.003045  0.000147  0.000851]
 [ 0.000299 -0.000042  0.000191]]
Hide code cell content
! head -n 13449 anharm-G16B01.log | tail -n 20
   ## Vibrational contributions to averages at    0K (Unit: Debye) ##
 ---------------------------------------------------------------------------
          Mode |   P1(diag)     P1(vib)    P1(rot)        P2     |  P1(tot)+P2
 ---------------------------------------------------------------------------
 X           2 |   0.111D-02   0.674D-03   0.000D+00  -0.251D-02 |  -0.183D-02
 X           3 |   0.316D-02   0.284D-02   0.000D+00  -0.348D-02 |  -0.637D-03
 X           4 |  -0.898D-02  -0.130D-01   0.000D+00   0.877D-02 |  -0.428D-02
 X           5 |   0.664D-05  -0.462D-02   0.000D+00  -0.465D-02 |  -0.926D-02
 X           6 |   0.226D-04  -0.119D-03   0.000D+00   0.221D-02 |   0.209D-02
 Y           2 |   0.156D-03   0.944D-04   0.000D+00  -0.505D-02 |  -0.495D-02
 Y           3 |  -0.494D-03  -0.443D-03   0.000D+00   0.267D-02 |   0.222D-02
 Y           4 |  -0.544D-02  -0.790D-02   0.000D+00   0.181D-02 |  -0.609D-02
 Y           5 |  -0.793D-05   0.551D-02   0.000D+00   0.144D-02 |   0.694D-02
 Y           6 |  -0.681D-03   0.357D-02   0.000D+00  -0.114D-02 |   0.244D-02
 Z           2 |   0.310D-03   0.188D-03   0.000D+00  -0.591D-04 |   0.129D-03
 Z           3 |   0.303D-03   0.272D-03   0.000D+00   0.128D-03 |   0.400D-03
 Z           4 |  -0.352D-03  -0.512D-03   0.000D+00  -0.824D-04 |  -0.595D-03
 Z           5 |  -0.116D-05   0.805D-03   0.000D+00   0.898D-03 |   0.170D-02
 Z           6 |   0.927D-04  -0.487D-03   0.000D+00  -0.712D-03 |  -0.120D-02
 ---------------------------------------------------------------------------
Hide code cell content
! head -n 13479 anharm-G16B01.log | tail -n 20
   ## Vibrational contributions to averages at 2500K (Unit: Debye) ##
 ---------------------------------------------------------------------------
          Mode |   P1(diag)     P1(vib)    P1(rot)        P2     |  P1(tot)+P2
 ---------------------------------------------------------------------------
 X           2 |   0.111D-02   0.342D-03  -0.696D-05  -0.279D-02 |  -0.246D-02
 X           3 |   0.316D-02   0.311D-02   0.252D-04  -0.431D-02 |  -0.117D-02
 X           4 |  -0.898D-02  -0.248D-01  -0.115D-03   0.165D-01 |  -0.841D-02
 X           5 |   0.664D-05  -0.770D-02   0.656D-03  -0.920D-02 |  -0.162D-01
 X           6 |   0.226D-04  -0.242D-03   0.104D-04   0.493D-02 |   0.470D-02
 Y           2 |   0.156D-03   0.478D-04  -0.974D-06  -0.561D-02 |  -0.557D-02
 Y           3 |  -0.494D-03  -0.487D-03  -0.394D-05   0.331D-02 |   0.282D-02
 Y           4 |  -0.544D-02  -0.150D-01  -0.698D-04   0.340D-02 |  -0.117D-01
 Y           5 |  -0.793D-05   0.919D-02  -0.782D-03   0.285D-02 |   0.113D-01
 Y           6 |  -0.681D-03   0.729D-02  -0.167D-03  -0.254D-02 |   0.458D-02
 Z           2 |   0.310D-03   0.950D-04  -0.194D-05  -0.658D-04 |   0.273D-04
 Z           3 |   0.303D-03   0.299D-03   0.242D-05   0.159D-03 |   0.460D-03
 Z           4 |  -0.352D-03  -0.973D-03  -0.452D-05  -0.155D-03 |  -0.113D-02
 Z           5 |  -0.116D-05   0.134D-02  -0.114D-03   0.178D-02 |   0.301D-02
 Z           6 |   0.927D-04  -0.992D-03   0.228D-04  -0.159D-02 |  -0.256D-02
 ---------------------------------------------------------------------------

一阶转动非谐效应的偶极贡献#

我们现在考虑一阶转动的非谐效应。实际上,依据 Harding et al. [3] eq. (4),一阶简谐模量的平均偏移不止有振动的贡献,也还有转动的贡献:

\[ \langle Q_i \rangle (T) = \langle Q_i \rangle^\mathrm{vib} (T) + \langle Q_i \rangle^\mathrm{rot} (T) = - \frac{\hbar}{4 \lambda_i} \sum_j \frac{\Phi_{ijj}}{\sqrt{|\lambda_j|}} \theta_j (T) + \frac{k_\mathrm{B} T}{2 \lambda_i} \sum_\alpha \frac{\partial I_{\alpha \alpha}}{\partial Q_i} \frac{1}{I_{\alpha \alpha}} \]

为此,我们还需要求取转动惯量 \(I_{\alpha \beta}\) 在简振坐标下的导数 \(\partial I_{\alpha \beta}\) deriv_inertia (维度:\((i, \alpha, \beta)\))。需要注意的是,尽管我们已经将分子转动到 Eckart Orientation 以使得转动惯量矩阵是对角矩阵 \(I_{\alpha \beta} = \delta_{\alpha \beta} I_{\alpha \alpha}\),但这不意味着其简振坐标下的导数还是对角的。

def get_fa(mf_hess):
    mol = mf_hess.mol
    fa = FreqAnal()
    fa.mol_weights = get_atom_mass_list(mol)
    fa.mol_coords = mol.atom_coords()
    fa.natm = mol.natm
    fa.mol_hess = mf_hess.de.swapaxes(1, 2)
    return fa
deriv_inertia = NumericDiff(num_hess, lambda mf_hess: get_fa(mf_hess).mom_inertia).derivative
deriv_inertia = einsum("Aγδ, γα, δβ -> Aαβ", deriv_inertia, fa.rot_vec, fa.rot_vec)

依据这个导数,在合理的单位转换下,我们能给出 \(\langle Q_i \rangle^\mathrm{vib} (T_{2500})\) QTrot_1。同时,我们也能看到,显然地 \(\langle Q_i \rangle^\mathrm{vib} (0)\) Qrot_1 为零,因此在 \(0 \ \mathrm{K}\) 下,分子旋转对非谐效应不产生贡献。

QTrot_1 = (1/2 * T * k_B / lambd * (deriv_inertia.diagonal(0, -1, -2) / (fa.rot_eig)).sum(axis=-1)) * E_h**-1
QTrot_1[lambd < 0] = 0
Qrot_1 = np.zeros_like(QTrot_1)
QTrot_1
array([ 0.      ,  0.005706, -0.006855, -0.025615,  0.006727,  0.003773])

矫正项 \(\partial \mu_\gamma / \partial Q_i \cdot \langle Q_i \rangle^\mathrm{rot} (T)\) 以维度为 \((i, \alpha)\) 的矩阵表示如下:

  • dip_anharm_rot\(T = 0\) 的情形 (实际上 \(0 \ \mathrm{K}\) 不产生贡献)

  • dipT_anharm_rot\(T = T_{2500}\) 的情形

dipT_anharm_rot = QTrot_1[:, None] * dip_1
dip_anharm_rot = np.zeros_like(dipT_anharm_rot)
dipT_anharm_rot
array([[-0.      , -0.      , -0.      ],
       [-0.000052,  0.001562, -0.000213],
       [-0.000949,  0.001132,  0.000166],
       [-0.005677, -0.003437, -0.000223],
       [ 0.000935, -0.000146,  0.00009 ],
       [ 0.000239,  0.000033,  0.000066]])

旋转的贡献一般来说总是比振动小不少。在 Gaussian 09 版本中,实际上就没有考虑转动的贡献;尽管不太精确,但作这种忽略也应认为是合理的。

危险

Gaussian 16 rev. B01 的转动贡献计算很可能是错误的。该错误不容易重现。

统合所有贡献#

回顾到非谐性质在简振坐标下的展开式

\[ \langle P \rangle^\mathrm{anharm} (T) \simeq P + \sum_i \frac{\partial P}{\partial Q_i} \cdot \langle Q_i \rangle (T) + \frac{1}{2} \sum_{i} \frac{\partial^2 P}{\partial Q_i^2} \cdot \langle Q_i^2 \rangle (T) \]

在所有贡献量都已经求得的情况下,我们很容易地给出 \(0 \ \mathrm{K}\)\(T_{2500} = 2500 \ \mathrm{K}\) 的非谐矫正下的偶极矩。

未矫正的偶极矩:

print("Dipole/Debye (Uncorrected, Eckart Orientation): ", dip_0)
print("Dipole/Debye (Uncorrected, Input  Orientation): ", dip_0 @ fa.rot_vec.T)
Dipole/Debye (Uncorrected, Eckart Orientation):  [-0.059826  0.052123  0.522057]
Dipole/Debye (Uncorrected, Input  Orientation):  [-0.013591 -0.522779  0.07319 ]

\(0 \ \mathrm{K}\) 的非谐矫正偶极矩:

dip_anharm = dip_0 + (dip_anharm_1 + dip_anharm_rot + dip_anharm_2).sum(axis=0)
print("Dipole/Debye (0 K, Eckart Orientation): ", dip_anharm)
print("Dipole/Debye (0 K, Input  Orientation): ", dip_anharm @ fa.rot_vec.T)
Dipole/Debye (0 K, Eckart Orientation):  [-0.074777  0.052801  0.522232]
Dipole/Debye (0 K, Input  Orientation):  [-0.027333 -0.525138  0.067748]

\(T_{2500} = 2500 \ \mathrm{K}\) 的非谐矫正偶极矩:

dipT_anharm = dip_0 + (dipT_anharm_1 + dipT_anharm_rot + dipT_anharm_2).sum(axis=0)
print("Dipole/Debye (0 K, Eckart Orientation): ", dipT_anharm)
print("Dipole/Debye (0 K, Input  Orientation): ", dipT_anharm @ fa.rot_vec.T)
Dipole/Debye (0 K, Eckart Orientation):  [-0.089493  0.053711  0.521843]
Dipole/Debye (0 K, Input  Orientation):  [-0.041028 -0.526894  0.062559]

非谐谱学性质:红外光谱#

如果上面用于计算偶极矩的计算方法也可以用于计算偶极矩的坐标导数,那么红外光谱也可以通过类似的方法实现。

危险

这只是依葫芦画瓢,用计算非谐偶极的方法套用到红外光谱中。很显然这未必是正确的,而且事实上,不仅是非谐振动频率 (因为 Fermi 或 Darling-Dennsion 共振),下述代码的 IR 峰强度结果也与 Gaussian 的输出非常不同

因此,各位看官就当看个热闹哈 (`・ω・´)

想要了解更多信息,请参考 Bloino 作为第一作者的文章 ([4], [5])。红外光谱应当需要作更严格的 RSPT 二阶展开,并且考虑一部分非基频振动间的相互跃迁偶极矩导数。这部分工作应当已经实现在 Gaussian 16 rev. B01 中,因此我们能获得非谐红外信息。

在上面对 Gaussian 程序的实现结果作讨论之后,我其实觉得务实看待 Gaussian 输出的态度应当是保留地接受它。它所给出的分子振动频率还是相对准确的,特别是相比与本文档而言,对频率共振的处理多少让结果变好一些 (尽管对于 Fermi 共振,如果不应用 DCPT2 或其它简并微扰理论而完全去除共振项,任意性其实太强;这在 Gaussian 中或许实现了,但我还不清楚)。但在偶极矩 (或可能地,磁矩等) 的非谐处理上,大概还有需改进之处。

我们这里选择使用相对来说较为真实的乙醛分子 (CH3CHO) 分子,作为绘制红外光谱的分子。计算方法仍然是 STO-3G。由于我们需要重新写一遍近乎完整的代码来绘制红外光谱,因此这里将大部分代码进行了折叠。其输入卡 CH3CHO.gjf,输出文件 CH3CHO.log。绘制红外光谱时,使用 Lorentzian 展宽,半峰宽 \(30 \ \mathrm{cm^{-1}}\)

分子定义与原子核高阶导数#

mol = gto.Mole(atom="""
C   -0.852693    0.499814   -0.000294
C    0.685096    0.670458    0.000342
O    1.340323   -0.602319   -0.000432
O   -1.349649   -0.611839    0.000139
H   -1.450829    1.425645   -0.001000
H    0.974215    1.253523    0.883979
H    0.975003    1.254674   -0.882236
H    0.581799   -1.242205    0.001322
""", basis="STO-3G", verbose=0).build()
Hide code cell content
natm = mol.natm
nhess = natm * 3
mf = scf.RHF(mol).run()
mf_hess = mf.Hessian().run()
Hide code cell content
fa = FreqAnal()
fa.mol_weights = get_atom_mass_list(mol)
fa.mol_coords = mol.atom_coords()
fa.natm = mol.natm
fa.mol_hess = mf_hess.de.swapaxes(1, 2)
nvib = fa.freq.size
Hide code cell content
deriv_2 = einsum("Pi, PQ, Qj -> ij", fa.q, fa.mol_hess.reshape(nhess, nhess), fa.q)
lambd = deriv_2.diagonal()
Hide code cell content
num_hess = ModeDerivGenerator(mol, lambda mol: scf.RHF(mol).run().Hessian().run(), fa.q, interval=0.01)
tmp_3 = NumericDiff(num_hess, lambda mf: mf.de.swapaxes(1, 2).reshape(nhess, nhess)).derivative
deriv_3 = einsum("Ai, Bj, kAB -> ijk", fa.q, fa.q, tmp_3)
deriv_3 = 1/3 * (deriv_3 + deriv_3.transpose(1, 2, 0) + deriv_3.transpose(2, 0, 1))
Hide code cell content
hess_gathered = np.array([[mf.de.swapaxes(1, 2).reshape(nhess, nhess) for mf in l] for l in num_hess.objects])
tmp_4 = einsum("ksAB, Ai, Bj -> skij", hess_gathered, fa.q, fa.q)
deriv_4 = (tmp_4[0] + tmp_4[1] - 2 * deriv_2) / (num_hess.interval**2)  # finite diff
deriv_4 = (deriv_4 + deriv_4.swapaxes(-1, -2)) / 2                      # symmetrize
deriv_4 = einsum("kij -> ijk", deriv_4)                                 # subscript transform

\(\xi_{ij}\) 矩阵生成与非谐频率计算#

Hide code cell content
xmat_4 = np.zeros((nvib, nvib))
for i in range(nvib):
    xmat_4[i, i] = deriv_4[i,i,i] / (16 * lambd[i])
    for j in range(nvib):
        if i == j: continue
        xmat_4[i, j] = deriv_4[i,i,j] / (4 * np.sqrt(np.abs(lambd[i] * lambd[j])))
xmat_4 *= (hbar / (2 * np.pi * c_0)) * (a_0**-2 * amu**-1) / (100)
Hide code cell content
xmat_3 = np.zeros((nvib, nvib))
for i in range(nvib):
    xmat_3[i, i] -= deriv_3[i,i,i]**2 * 5 / (3 * lambd[i])
    for j in range(nvib):
        if i == j: continue
        xmat_3[i, i] -= deriv_3[i,i,j]**2 * (8 * lambd[i] - 3 * lambd[j]) / (lambd[j] * (4 * lambd[i] - lambd[j]))
    xmat_3[i, i] /= 16 * lambd[i]
for i in range(nvib):
    for j in range(nvib):
        if i == j: continue
        xmat_3[i, j] -= deriv_3[i,i,j]**2 * 2 / (4 * lambd[i] - lambd[j])
        xmat_3[i, j] -= deriv_3[i,j,j]**2 * 2 / (4 * lambd[j] - lambd[i])
        xmat_3[i, j] -= deriv_3[i,i,i] * deriv_3[i,j,j] / lambd[i]
        xmat_3[i, j] -= deriv_3[j,j,j] * deriv_3[i,i,j] / lambd[j]
        for k in range(nvib):
            if len(set([i, j, k])) != 3: continue
            delta_ijk = lambd[i]**2 + lambd[j]**2 + lambd[k]**2 - 2 * (lambd[i]*lambd[j] + lambd[j]*lambd[k] + lambd[k]*lambd[i])
            xmat_3[i, j] += deriv_3[i,j,k]**2 * 2 * (lambd[i] + lambd[j] - lambd[k]) / delta_ijk
            xmat_3[i, j] -= deriv_3[i,i,k] * deriv_3[j,j,k] / lambd[k]
        xmat_3[i, j] /= 4 * np.sqrt(np.abs(lambd[i] * lambd[j]))
xmat_3 *= (hbar / (2 * np.pi * c_0)) * (a_0**-2 * amu**-1) / (100)
Hide code cell content
rot_wavenum = h / (8 * np.pi**2 * c_0 * fa.rot_eig) * (a_0**-2 * amu**-1) / (100)
q_ = einsum("Aβi, βα, A -> Aαi", fa.q.reshape(natm, 3, nvib), fa.rot_vec, np.sqrt(get_atom_mass_list(mol)))
zeta_x = einsum("Ai, Aj -> ij", q_[:, 1, :], q_[:, 2, :]) - einsum("Ai, Aj -> ij", q_[:, 2, :], q_[:, 1, :])
zeta_y = einsum("Ai, Aj -> ij", q_[:, 2, :], q_[:, 0, :]) - einsum("Ai, Aj -> ij", q_[:, 0, :], q_[:, 2, :])
zeta_z = einsum("Ai, Aj -> ij", q_[:, 0, :], q_[:, 1, :]) - einsum("Ai, Aj -> ij", q_[:, 1, :], q_[:, 0, :])
zeta = np.array([zeta_x, zeta_y, zeta_z])

xmat_coriol = np.zeros((nvib, nvib))
for i in range(nvib):
    for j in range(nvib):
        for ixyz in range(3):
            xmat_coriol[i, j] += zeta[ixyz, i, j]**2 * rot_wavenum[ixyz]
        xmat_coriol[i, j] *= (lambd[i] + lambd[j]) / np.sqrt(np.abs(lambd[i] * lambd[j]))
Hide code cell content
xmat = xmat_4 + xmat_3 + xmat_coriol
freq_anharm = fa.freq + 1.5 * xmat.diagonal() + 0.5 * xmat.sum(axis=-1)

温度效应矫正系数计算 (2500 K)#

Hide code cell content
T = 2500
coef_temp = get_coef_temp(T)
Q_scale = hbar / np.sqrt(E_h * a_0**2 * amu)
Hide code cell content
Q_1, QT_1 = np.zeros(nvib), np.zeros(nvib)
for i in range(nvib):
    if fa.freq[i] <= 0: continue
    for j in range(nvib):
        val = - deriv_3[i,j,j] / (4 * lambd[i] * np.sqrt(np.abs(lambd[j]))) * Q_scale
        Q_1[i] += val
        QT_1[i] += val * coef_temp[j]
Hide code cell content
Q_2, QT_2 = np.zeros(nvib), np.zeros(nvib)
for i in range(nvib):
    if fa.freq[i] <= 0: continue
    val = 1 / (2 * np.sqrt(lambd[i])) * Q_scale
    Q_2[i] += val
    QT_2[i] += val * coef_temp[i]
Hide code cell content
deriv_inertia = NumericDiff(num_hess, lambda mf_hess: get_fa(mf_hess).mom_inertia).derivative
deriv_inertia = einsum("Aγδ, γα, δβ -> Aαβ", deriv_inertia, fa.rot_vec, fa.rot_vec)
QTrot_1 = (1/2 * T * k_B / lambd * (deriv_inertia.diagonal(0, -1, -2) / (fa.rot_eig)).sum(axis=-1)) * E_h**-1
QTrot_1[lambd < 0] = 0
Qrot_1 = np.zeros_like(QTrot_1)

偶极矩导数与红外强度计算#

Hide code cell content
dip_0 = mf.dip_moment() @ fa.rot_vec
dip_1 = einsum("Aα, αγ, Ai -> iγ", get_dipderiv(mf_hess), fa.rot_vec, fa.q) * data.nist.AU2DEBYE
dip_tmp_2 = NumericDiff(num_hess, lambda mf_hess: get_dipderiv(mf_hess)).derivative * data.nist.AU2DEBYE
dip_2 = einsum("iAα, αγ, Aj -> ijγ", dip_tmp_2, fa.rot_vec, fa.q)
dipderiv_gathered = np.array([[get_dipderiv(mf_hess) for mf_hess in l] for l in num_hess.objects]) * data.nist.AU2DEBYE
dip_tmp_3 = einsum("iσAα, αγ, Aj -> σijγ", dipderiv_gathered, fa.rot_vec, fa.q)
dip_3 = (dip_tmp_3[0] + dip_tmp_3[1] - 2 * dip_1) / (num_hess.interval**2)
Dipole moment(X, Y, Z, Debye): -0.85484,  1.70251,  0.00242
Hide code cell content
IR_scale = 1/3 * np.pi * F**2 * data.nist.AU2DEBYE**-2 * 1e-7
IR_inten = (dip_1**2).sum(axis=-1) * IR_scale
Hide code cell content
dip_1_anharm = einsum("ijγ, j -> jγ", dip_2, Q_1 + Qrot_1) + 0.5 * einsum("ijγ, j -> jγ", dip_3, Q_2)
IR_anharm = ((dip_1 + dip_1_anharm)**2).sum(axis=-1) * IR_scale
Hide code cell content
dip_1T_anharm = einsum("ijγ, j -> jγ", dip_2, QT_1 + QTrot_1) + 0.5 * einsum("ijγ, j -> jγ", dip_3, QT_2)
IRT_anharm = ((dip_1 + dip_1T_anharm)**2).sum(axis=-1) * IR_scale

红外光谱绘制#

Hide code cell content
def lorentzian_freq(omega, omega_n, gamma):
    return 100 * 0.5 / np.pi * gamma / ((omega - omega_n)**2 + 0.25 * gamma**2)
Hide code cell content
def ir_plot(omega, gamma, freq, ir):
    val = 0
    assert(len(freq) == len(ir))
    for i in range(len(freq)):
        val += lorentzian_freq(omega, freq[i], gamma) * ir[i]
    return val
Hide code cell content
freq_anharm_gau = [4098.800, 3570.604, 3446.746, 3471.841, 2088.048, 1787.600, 1725.346, 1593.897, 1530.767, 1407.142, 1297.110, 1206.649, 1019.526, 878.962, 795.699, 185.138, 258.388, 69.977]
IR_anharm_gau = [2.37219675, 1.54485433, 9.30810815, 7.71760501, 22.32308331, 0.91999625, 21.74763306, 0.33568248, 39.89748639, 0.34998748, 8.84110221, 1.90263087, 8.44102685, 0.12421802, 12.58976516, 36.74388344, 9.13647155, 24.14833335]
Hide code cell source
fig, ax = plt.subplots(figsize=(7, 4))
ax.grid()

x = np.arange(0, 4500, 1)
ax.set_xlim(0, 4500)
ax.set_ylim(-10, 180)
ax.plot(x, ir_plot(x, 30, fa.freq, IR_inten), label="Harmonic")
ax.plot(x, ir_plot(x, 30, freq_anharm, IR_anharm), label="Anharmonic (0 K)")
# ax.plot(x, ir_plot(x, 30, freq_anharm, IRT_anharm), label="Anharmonic (2500 K)")
ax.plot(x, ir_plot(x, 30, freq_anharm_gau, IR_anharm_gau), label="Anharmonic (Gaussian)")
ax.set_ylabel("Molar Absorption Coefficient (L mol$^{-1}$ cm$^{-1}$)")
ax.set_xlabel("Vibration Wavenumber (cm$^{-1}$)")
ax.set_title("Acetaldehyde ($\mathsf{CH_3CHO}$) Infared Spectrum (HF/STO-3G)")
ax.legend(loc="upper right")

ax.set_ylabel("IR Intensity (km mol$^{-1}$)")
ax.legend(loc="upper right")
<matplotlib.legend.Legend at 0x7f5974689ee0>

文档补充信息#

这份文档的一部分内容与颜文杰有较多讨论,与申同昊有一部分讨论。我也算是对非谐矫正有些好奇,于是尝试搞了一下。


简单理解 RHF 含频极化率及其与 TD-HF 间的关系#

创建日期:2020-01-02

最后修改:2020-06-10

含频极化率在非线性光学中有所应用。这里指的频率是入射激发光频率,而非分子振动频率。含频极化率英文是 Frequency-Dependent Polarizability,也有时使用动态 Dynamic 替代含频 Frequency-Dependent;相对地,没有入射激发光给出的极化率称为静态 Static 极化率。

但这也只是道听途说。对于我来讲更直接的意义会是,含频极化率对坐标的一阶导数可以用于计算含频 Raman 光谱。

写这篇文档一开始的原因是,曾经在尝试计算简单的 SERS 光谱时,发现 Valley, Schatz et al. [1] 的含频 Raman 光谱计算的文章提到使用 TD-DFT (time-dependent density functional theory);其它文献也几乎无一例外。这多少对我来说有点意外。Raman 光谱的计算通过求取极化率对简正坐标的导数 (不管是解析的还是数值的) 得到,而极化率则可以通过 CP-HF (coupled-perturbated Hartree-Fock) 方程给出。此前我确实地成功得到了 Gaussian 所给出的 RKS (GGA level) 极化率,并且并没有使用 TD-DFT 计算,而是 CP-KS (coupled-perturbated Kohn-Sham) 方程计算得到的极化率;我曾经一度以为这是 ADF 软件与 Gaussian 软件两者的区别。后来在各路同学的提醒下,才渐渐明白极化率与含时分析 (TD) 之间的关系。

这篇文档将会忽略大部分与公式推导有关的问题;这是由于 TD-DFT 或 TD-HF 的公式推导并不简单;在短时间之内我重复不出让我自己信服的推导。这篇文档不讨论与复数有关的话题,变量与公式全部采用实数与实函数。

我们会使用非对称的双氧水分子作为演示分子,基组使用 6-31G。计算程序使用 PySCF 提供电子积分,并与 Gaussian 的含频极化率、PySCF 的激发频率计算结果作对应。

%matplotlib notebook

import numpy as np
import scipy
from pyscf import gto, scf, tdscf
from functools import partial
import matplotlib.pyplot as plt
from matplotlib import patches
from formchk_interface import FormchkInterface

np.einsum = partial(np.einsum, optimize=["greedy", 1024 ** 3 * 2 / 8])
np.set_printoptions(5, linewidth=150, suppress=True)

全文使用以下记号:

  • \(p, q, r, s, m\) 表示全部轨道

  • \(i, j\) 表示占据分子轨道

  • \(a, b\) 表示非占分子轨道

  • \(\mu, \nu, \kappa, \lambda\) 表示原子轨道

  • \(t, s\) 在不引起歧义的情况下表示空间取向 \(x, y, z\)

  • \(P, Q, R, S\) 在这篇文档表示类似于 \(ai\) 的组合下标

  • \(n\) 表示 TD-HF 激发态

全文使用简化与不严格的 Einstein Summation Convention。

下面补充一个原子单位能量 \(E_\mathrm{h}\) 到波数 \(\mathrm{cm}^{-1}\) 的换算 Eh_cm

\[ 1 \, E_\mathrm{h} = 219474.6 \, \mathrm{cm}^{-1} \]
from scipy.constants import physical_constants
Eh_cm = physical_constants["hartree-inverse meter relationship"][0] / 100
Eh_cm
219474.6313702

分子体系与标准结果#

阅读提示

我们会花很长的时间进行分子体系与标准结果的定义。如果对代码与文本阅读能力有信心,这段可以跳过。

PySCF 体系定义#

在进入下面的讨论前,我们先定义如下变量:

  • mol PySCF 分子实例

mol = gto.Mole()
mol.atom = """
O  0.0  0.0  0.0
O  0.0  0.0  1.5
H  1.0  0.0  0.0
H  0.0  0.7  1.0
"""
mol.basis = "6-31G"
mol.verbose = 0
mol.build()
<pyscf.gto.mole.Mole at 0x7fc514e22588>
  • nao 轨道数量 \(n_\mathrm{AO}\), nocc 占据轨道数 \(n_\mathrm{occ}\), nvir 非占轨道数 \(n_\mathrm{vir}\)

  • so 占据轨道分割,sv 非占轨道分割,sa 全轨道分割

  • eri0_ao 原子轨道基组双电子积分 ERI (electron repulsion integral)

    \[ (\mu \nu | \kappa \lambda) = \int \phi_\mu (\boldsymbol{r}) \phi_\nu (\boldsymbol{r}) \frac{1}{|\boldsymbol{r} - \boldsymbol{r}'|} \phi_\kappa (\boldsymbol{r}') \phi_\lambda (\boldsymbol{r}') \, \mathrm{d} \boldsymbol{r} \, \mathrm{d} \boldsymbol{r}' \]
  • d_ao 偶极积分,其中下述的 \(t\) 或后文会出现的 \(s\) 表示偶极积分的方向 \(x, y\)\(z\)

    \[ d_{\mu \nu}^t = - \langle \mu | t | \nu \rangle = \int \phi_\mu (\boldsymbol{r}) t \phi_\nu (\boldsymbol{r}) \, \mathrm{d} \boldsymbol{r} \]
nao = nmo = mol.nao
nocc = mol.nelec[0]
nvir = nmo - nocc
so, sv, sa = slice(0, nocc), slice(nocc, nmo), slice(0, nmo)
eri0_ao = mol.intor("int2e")
d_ao = - mol.intor("int1e_r")
  • scf_eng PySCF 的 RHF 计算实例

scf_eng = scf.RHF(mol).run()
  • C \(C_{\mu p}\) 分子轨道系数

  • e \(e_p\) RHF 轨道能量

  • D \(D_{\mu \nu} = 2 C_{\mu i} C_{\nu i}\) RHF 电子态密度

C, e = scf_eng.mo_coeff, scf_eng.mo_energy
D = 2 * C[:, so] @ C[:, so].T
  • eri0_mo 分子轨道双电子 ERI \((pq|rs) = C_{\mu p} C_{\nu q} (\mu \nu | \kappa \lambda) C_{\kappa r} C_{\lambda s}\)

  • d_mo 分子轨道偶极积分 \(d^t_{pq} = C_{\mu p} d^t_{\mu \nu} C_{\nu q}\)

  • d_ia 占据-非占的分子轨道偶极积分 \(d^t_{ia}\)

  • d_P 以双下标 \(P = ia\) 为记号的占据-非占分子轨道偶极积分 \(d^t_P\)

eri0_mo = np.einsum("up, vq, uvkl, kr, ls -> pqrs", C, C, eri0_ao, C, C)
d_mo = np.einsum("up, tuv, vq -> tpq", C, d_ao, C)
d_ia = d_mo[:, so, sv]
d_P = d_ia.reshape(3, nocc*nvir)
  • scf_td PySCF 的 TD-RHF 计算实例

scf_td = tdscf.TDHF(scf_eng)
scf_td.nstates = nvir * nocc
scf_td.run()
<pyscf.tdscf.rhf.TDHF at 0x7fc4ddee7048>

就我目前所知,PySCF 可以计算极化率;但与含频极化率有关的计算,我在这里采用下文所述的、与 Gaussian 可以大致匹配结果的程序。

Gaussian 计算含频极化率#

我们需要一个可以核验结果的结果与工具。Gaussian 事实上提供了含频的极化率的选项。这一小段我们简单了解如何使用 Gaussian 来计算含频极化率。

我们首先给给出一个示范的例子。这个例子只是演示,并不能代表真实的物理。

Gaussian 的输入卡 assets/H2O2_freq_polar_example.gjf 如下:

with open("assets/H2O2_freq_polar_example.gjf", "r") as f:
    print(f.read()[:-1])
%chk=H2O2_freq_polar_example.chk
# RHF/6-31G NoSymm Freq(Raman) CPHF=RdFreq

H2O2 Frequency-Dependent Polarizability (Raman)

0 1
O  0.0  0.0  0.0
O  0.0  0.0  1.5
H  1.0  0.0  0.0
H  0.0  0.7  1.0

1nm

事实上这段程序不只计算了含频极化率,还计算了含频 Raman;但这份文档只讨论极化率问题。这里的“含频”指的是两个频率,其一为静态 (static) 极化率,其二是入射光线为 \(\omega = 1 \, \mathrm{nm}\) 频率下的极化率。后者是一个相当极端的例子,因为通常可能没有人会想到用 X-Ray 照射一个普通的液体分子。

Gaussian 程序默认会输出 .out 或 .log 文件作为文本信息输出,其输出文件在 assets/H2O2_freq_polar_example.out 中。我们还要求 Gaussian 输出 .chk 文件,该文件只包含单纯的计算数据信息,其通过 Gaussian Utility formchk 导出为 ASCII 格式的文件在 assets/H2O2_freq_polar_example.fch

对于 .out 文件,我们可以用下述命令仅查看含频极化率:

with open("assets/H2O2_freq_polar_example.out", "r") as f:
    while f.readable():
        line = f.readline()
        if "Alpha(-w,w) frequency" in line:
            print(line[:-1])
            for _ in range(4):
                print(f.readline()[:-1])
        if "Beta(-w,w,0) frequency" in line:
            break
 Property number 1 -- Alpha(-w,w) frequency  1    0.000000:
                 1             2             3 
      1   0.658142D+01 -0.841017D-01 -0.145378D+01
      2  -0.841017D-01  0.426836D+01  0.399688D+00
      3  -0.145378D+01  0.399688D+00  0.178903D+02
 Property number 1 -- Alpha(-w,w) frequency  2   45.563353:
                 1             2             3 
      1  -0.339006D-02  0.323942D-04 -0.262886D-04
      2   0.323942D-04 -0.353767D-02  0.379883D-03
      3  -0.262886D-04  0.379883D-03 -0.437398D-02

对于每个频率,程序会给出一个 \(3 \times 3\) 大小的矩阵;这就是极化率张量 \(\alpha_{ts} (-\omega, \omega)\),其中 \(t, s\) 可以是 \(x, y, z\)。以后的文档中,我们会简记 \(\alpha_{ts} (-\omega, \omega)\)\(\alpha_{ts} (\omega)\)

极化率单位是原子单位;关于极化率原子单位与 SI 单位制的转换,参考下述 NIST 网页

上述出现的两个频率值 0.000000, 45.563353 并不是以 \(\mathrm{nm}\) 为单位,而是以 \(E_\mathrm{h}\) Hartree 为单位。关于上述单位换算的过程,我们采用下述代码来说明:

1 / Eh_cm * 1e7
45.56335252766616

上面出现的 Eh_cm 已经在文档开头有所解释。这样我们就完成了 .out 文件的含频极化率的读取。

但在后面的文档中,我们将会利用 .chk 文件 (或者几乎等价的 .fch 文件) 的信息,来给出 Gaussian 计算的标准结果。在这份文档中,我们会利用到的键值有两段:Frequencies for FD properties 储存了以 \(E_\mathrm{h}\) Hartree 为单位的频率 \(\omega\)Alpha(-w,w) 储存了以原子单位储存的极化率张量 \(\alpha_{ts} (\omega)\)

with open("assets/H2O2_freq_polar_example.fch", "r") as f:
    print_flag = False
    while f.readable():
        line = f.readline()
        if "Frequencies for FD properties" in line:
            print_flag = True
        if "Beta(-w,w,0)" in line:
            break
        if print_flag is True:
            print(line[:-1])
Frequencies for FD properties              R   N=           2
  0.00000000E+00  4.55633525E+01
Alpha(-w,w)                                R   N=          18
  6.58141820E+00 -8.41017140E-02 -1.45378248E+00 -8.41017140E-02  4.26835620E+00
  3.99687823E-01 -1.45378248E+00  3.99687823E-01  1.78903287E+01 -3.39006427E-03
  3.23941556E-05 -2.62886421E-05  3.23941556E-05 -3.53766531E-03  3.79883414E-04
 -2.62886421E-05  3.79883414E-04 -4.37398325E-03

我们可以看到,至少从程序输出的角度来讲,包含频率的极化率很可能与不含极化率的频率值相去甚远。当然,至于在 \(1 \, \mathrm{nm}\) 如此高的光能量下,这个含频极化率是否在物理上正确,不是这篇文档讨论的问题。

通过我们导入的 FormchkInterface (取自 pyxdh 项目),我们也可以对上述数值导入到 numpy 向量中。譬如我们需要提取所有频率,那么下面的代码就可以给出:

fchk_helper = FormchkInterface("assets/H2O2_freq_polar_example.fch")
fchk_helper.key_to_value("Frequencies for FD properties")
array([ 0.     , 45.56335])

下面是 Gaussian 所给出的静态极化率 ref_alpha_static \(\alpha_{ts} (-\omega, \omega)\)。下一段我们会先回顾如何通过 CP-HF 方程,得到静态极化率。下述的矩阵将可以是我们计算结果的参考值。

ref_alpha_static = fchk_helper.key_to_value("Alpha(-w,w)")[:9].reshape(3, 3)
ref_alpha_static
array([[ 6.58142, -0.0841 , -1.45378],
       [-0.0841 ,  4.26836,  0.39969],
       [-1.45378,  0.39969, 17.89033]])

再之后的段落,我们会需要计算含频极化率。Gaussian 一次性至多计算 100 个频率下的光学性质,否则程序会报错;因此我们的输入文件将会是多个文件,这里不列举其超链接。我们用 freq_all_list 表示含频极化率对应的频率,而 alpha_all_list 表示这些频率下的极化率。

freq_full_list = []
alpha_full_list = []
for idx in (1, 2, 3):
    fchk_helper = FormchkInterface("assets/H2O2_freq_polar_{:1d}.fch".format(idx))
    freq_full_list.append(fchk_helper.key_to_value("Frequencies for FD properties")[1:])
    alpha_full_list.append(fchk_helper.key_to_value("Alpha(-w,w)").reshape(-1, 3, 3)[1:])
freq_full_list = np.concatenate(freq_full_list)
alpha_full_list = np.concatenate(alpha_full_list)

将所获得的含频极化率 (仅绘制其中一个分量 \(\alpha_{zz} (\omega)\)) 绘图可以得到:

fig, ax = plt.subplots()
ax.plot(freq_full_list, alpha_full_list[:, 2, 2])
rect = patches.Rectangle((0.184, -24), 0.01, 78, linewidth=1, edgecolor='C1', facecolor='C1', alpha=.25)
ax.add_patch(rect)
ax.set_ylim(-25, 75)
ax.set_xlabel(r"$\omega$ / $E_\mathrm{h}$")
ax.set_ylabel(r"$\alpha_{zz} (\omega)$ / a.u.")
ax.set_title("Frequency-Dependent Polarizability of $\mathrm{H_2O_2}$ (RHF/6-31G)")
fig.show()

含频极化率的图像在处于分子的激发态区域会呈剧烈振荡。在后续文档中,我们会先更多地看上图中橙色区域表示的前两个激发态。由于上图对橙色部分的描述不很精细,我们下面做一份更精细的极化率图绘制。

fchk_helper = FormchkInterface("assets/H2O2_freq_polar_small_range.fch")
freq_small_list = fchk_helper.key_to_value("Frequencies for FD properties")[1:]
alpha_small_list = fchk_helper.key_to_value("Alpha(-w,w)").reshape(-1, 3, 3)[1:]
fig, ax = plt.subplots()
ax.plot(freq_small_list, alpha_small_list[:, 2, 2])
ax.set_xlabel(r"$\omega$ / $E_\mathrm{h}$")
ax.set_ylabel(r"$\alpha_{zz} (\omega)$ / a.u.")
ax.set_title("Frequency-Dependent Polarizability of $\mathrm{H_2O_2}$ (RHF/6-31G)\nFor First Two Excited States")
fig.show()

从这两张图的纵坐标的缩放关系来看,事实上,对于每一个振荡峰,其振荡是趋于无穷大的;并且其对应的频率恰好是分子 TD-HF 计算得到的激发能。这将会在后面的文档中叙述并验证。

TD-HF 方程过程回顾#

TD-HF 方程与激发能#

一般会认为,TD 方法是用于求解与电子激发过程有关的方法。最常用的应用即是求解激发能与跃迁偶极矩。我们在这一段先回顾这两者的计算过程。

在进行后续的描述前,我们会定义下述与 TD-HF 方程有关的张量或矩阵 A \(\mathbb{A}_{ia, jb}\)B \(\mathbb{B}_{ia, jb}\)

\[\begin{split} \begin{align} \mathbb{A}_{ia, jb} &= (\varepsilon_a - \varepsilon_i) \delta_{ij} \delta_{ab} + 2 (ia|jb) - (ij|ab) \\ \mathbb{B}_{ia, jb} &= 2 (ia|jb) - (ib|ja) \end{align} \end{split}\]

其中两个辅助变量为:

  • delta_ij \(\delta_{ij}\) 为占据轨道数维度的单位矩阵

  • delta_ab \(\delta_{ab}\) 为非占轨道数维度的单位矩阵

delta_ij, delta_ab = np.eye(nocc), np.eye(nvir)
A_iajb = (
    np.einsum("ia, ij, ab -> iajb", - e[so, None] + e[sv], delta_ij, delta_ab)
    + 2 * eri0_mo[so, sv, so, sv]
    - eri0_mo[so, so, sv, sv].swapaxes(1, 2))
B_iajb = (
    + 2 * eri0_mo[so, sv, so, sv]
    - eri0_mo[so, sv, so, sv].swapaxes(1, 3))

为了后文的代码方便,我们把双下标的矩阵记为 A \(A_{PQ}\)B \(B_{PQ}\)

A = A_iajb.reshape(nocc*nvir, nocc*nvir)
B = B_iajb.reshape(nocc*nvir, nocc*nvir)

根据 TD-DFT 中的 Casida 方程 (TD-DFT 可以看作是 TD-HF 情形的扩展),我们可以写出 TD-HF 的频率及其对应的本征向量为 \(\omega_n, X_{ia}^n, Y_{ia}^n\),或者双下标记号的 \(X_{P}^n, Y_{P}^n\)。其中,\(X_{ia}^n\) 有时称为第 \(n\) 个激发态的激发矩阵,\(Y_{ia}^n\) 则称退激发矩阵。这几者之间满足下述 TD-HF 矩阵方程。

\[\begin{split} \begin{pmatrix} \mathbb{A} & \mathbb{B} \\ - \mathbb{B} & - \mathbb{A} \end{pmatrix} \begin{pmatrix} \mathbf{X}^n \\ \mathbf{Y}^n \end{pmatrix} = \omega_n \begin{pmatrix} \mathbf{X}^n \\ \mathbf{Y}^n \end{pmatrix} \end{split}\]

我们在程序上,记等式左边的大矩阵为 AB

AB = np.block([
    [  A,   B],
    [- B, - A]
])
AB.shape
(234, 234)

我们首先解出上述矩阵的本征值 eigs 与本征向量 xys

eigs, xys = np.linalg.eig(AB)

但我们会发现,我们本来预期的在 6-31G 基组下可解的激发态数量只有 \(n_\mathrm{occ} n_\mathrm{vir} = 117\),但本征值数量却是 234 个。我们需要舍去所有负值的本征值。事实上,负值的本征值与正值的本征值之间有一一对应的关系。

(eigs < 0).sum()
117

我们舍去负本征值及其对应的本征向量,并对本征值作排序,得到正的本征值 eigs_sorted 及其相对应的本征向量 xys_sorted

eigs_sorted = eigs[eigs.argsort()[int(eigs.size / 2):]]
xys_sorted = xys[:, eigs.argsort()[int(eigs.size / 2):]]

我们应当可以验证,上述本征值与本征向量确实满足 TD-HF 矩阵方程:

np.allclose(AB @ xys_sorted, eigs_sorted * xys_sorted)
True

最后,我们用 td_eig \(\omega_n\)td_x_unnormed 未归一化的 \(X^n_P\)td_y_unnormed 未归一化的 \(Y^n_P\) 来重新整理上述的结果 eigs_sortedxys_sorted。需要注意,变量 td_x_unnormed 的两个维度中,第一个维度为激发态 \(n\),第二个维度为双下标 \(P = ia\);尽管两个维度的大小都是 \(n_\mathrm{occ} n_\mathrm{vir} = 117\),但意义完全不同。

td_eig = eigs_sorted
td_x_unnormed = xys_sorted.T[:, :nvir*nocc]
td_y_unnormed = xys_sorted.T[:, nvir*nocc:]

我们简单看一下最低激发能的几个激发态的能级大小,单位是原子单位或 Hartree \(E_\mathrm{h}\)

eigs_sorted[:10]
array([0.18674, 0.19114, 0.35357, 0.39384, 0.41744, 0.42516, 0.45701, 0.4702 , 0.50732, 0.55833])

我们能看到最低的激发态中,有 0.187 与 0.191;这恰好与上面 Gaussian 绘制出来的含频极化率图中的两个振荡峰位置恰好吻合。这并非是偶然,并且我们会在后文进行更详细的描述。

TD-HF 跃迁偶极矩#

从基态波函数 \(| 0 \rangle\) 到激发态波函数 \(| n \rangle\) 的跃迁偶极矩可以写作 \(\langle 0 | \hat d{}^t | n \rangle\) 或等价的 \(- \langle 0 | t | n \rangle\);留意到 \(t \in \{ x, y, z \}\)。但实施上,我们尚不能写出激发态波函数 \(| n \rangle\) 的具体形式。这个激发态波函数需要通过激发矩阵 \(X_{ia}^n\) 与退激发矩阵 \(Y_{ia}^n\) 来描述。

刚才的计算中,我们得到的本征向量是未经归一化的;它乘以任何非零常数,仍然会是 TD-HF 矩阵方程的本征向量。但我们可以使用激发与退激发,赋予这个本征向量以物理含义。其归一化条件是,态 \(| n \rangle\) 的电子数守恒,即与 \(| 0 \rangle\) 的电子数相同。在 RHF 问题下,这要求

\[ (X_{ia}^n)^2 - (Y_{ia}^n)^2 = 2 \]

我们令归一化过程中的中间量为 td_renorm \(N_n = \frac{1}{2} \left( (X_{ia}^n)^2 - (Y_{ia}^n)^2 \right)\)

td_renorm = ((td_x_unnormed**2).sum(axis=1) - (td_y_unnormed**2).sum(axis=1)) / 2

那么重新归一化后的 X \(X_P^n\)Y \(Y_P^n\)

X = td_x_unnormed / np.sqrt(td_renorm)[:, None]
Y = td_y_unnormed / np.sqrt(td_renorm)[:, None]

为了处理一些问题的便利,我们声明变量 X_ia \(X_{ia}^n\)Y_ia \(Y_{ia}^n\);它们的维度均是 \((n, i, a)\)

X_ia = X.reshape(nocc*nvir, nocc, nvir)
Y_ia = Y.reshape(nocc*nvir, nocc, nvir)
X_ia.shape
(117, 9, 13)

以此为基础,我们可以写出 TD-HF 的跃迁偶极矩 td_transdip

\[ \langle 0 | \hat d{}^t | n \rangle = d_{ia}^t (X_{ia}^n + Y_{ia}^n) \]

我们会打印出最低能级的 5 个激发态的跃迁偶极矩:

td_transdip = np.einsum("tia, nia -> nt", d_ia, X_ia + Y_ia)
td_transdip[:5]
array([[ 0.00096, -0.01194,  0.10696],
       [-0.01912, -0.02168,  0.07287],
       [ 0.01189,  0.06348, -0.09044],
       [-0.28032,  0.11958,  1.16236],
       [-0.11797,  0.0042 , -0.50674]])

这与 PySCF 所给出的跃迁偶极矩几乎是相同的,但符号上会有差异。我们认为这已经完整并成功地重复了跃迁偶极矩了。

scf_td.transition_dipole()[:5]
array([[ 0.00096, -0.01194,  0.10696],
       [ 0.01912,  0.02168, -0.07287],
       [ 0.01189,  0.06348, -0.09044],
       [ 0.28032, -0.11958, -1.16236],
       [-0.11797,  0.0042 , -0.50674]])

需要注意,这可能与 Gaussian 计算得到的跃迁偶极矩的值接近但并不完全相等。这可能与 Gaussian 默认的 TD-HF 精度偏低有关。

静态极化率#

偶极微扰下的 CP-HF 方程#

这篇文档的一个目的是将 CP-HF 方程与 TD-HF 方程之间的关系作一个联系。因此,我们需要首先了解 CP-HF 方程在静态极化率中的工作过程。

注意

尽管我们确实可以用后续文档中的代码或公式计算得到一些结果,但这并不意味着成型的量化软件也使用这些算法。譬如对于 RHF 下静态极化率计算,通常更高效的做法是使用类似于 CP-HF 方程的 Z-Vector 方程。

由此,我们会写偶极微扰下的 CP-HF 方程为

\[ A'_{ia, jb} U^t_{jb} = d^t_{ia} \]

下面简单但不严谨地回顾 CP-HF 方程的推导思路。我们在分子体系上,外加一个微扰偶极场,其大小是分子轨道基组下的 \(d_{pq}^t\) 偶极矩阵,微扰哈密顿量为 \(t\) (即单位方向为 \(t\) 的电场微扰)。根据 RHF 的变分条件,任何外加微扰的哈密顿量 \(t\) 都应该满足

\[ \frac{\partial F_{pq}}{\partial t} = 0 \]

通过该式,几乎可以直接得到 CP-HF 方程。方程的左边定义上是偶极积分,右边的 A_p \(A'_{ia, jb}\)

\[ A'_{ia, jb} = (\varepsilon_a - \varepsilon_i) \delta_{ij} \delta_{ab} + 4 (ia|jb) - (ij|ab) - (ib|ja) \]

U_ia \(U_{jb}^t\) 称为 U 矩阵,表示的是与电子态密度在外加偶极微扰影响下的变化有关的量;一种导出式如下:

\[ \frac{\partial D_{pq}}{\partial t} = D_{pm} U^t_{mq} + D_{mq} U^t_{mp} \]

因此,CP-HF 的一种直观的解释思路是,它求取的是分子受到外加偶极的微扰 \(d^t_{ia}\) 下,电子态密度形变的大小,而这个大小是由 \(U_{jb}^t\) U 矩阵刻画的。很容易想到的性质是,若外加偶极微扰趋于零,那么外加的形变微扰也趋于零矩阵。

下面我们来求取 CP-HF 方程,给出 A_p \(A'_{PQ} = A'_{ia, jb}\)U_ia \(U_{jb}^t\)。需要注意在这份文档中,角标顺序是 \(ia, jb\) 而非 \(ai, bj\);这可能与其它课本或文档的顺序不太相同,在一些矩阵的正负号上也可能存在差异。

A_p = (
    + np.einsum("ia, ij, ab -> iajb", - e[so, None] + e[sv], delta_ij, delta_ab)
    + 4 * eri0_mo[so, sv, so, sv]
    - eri0_mo[so, so, sv, sv].swapaxes(1, 2)
    - eri0_mo[so, sv, so, sv].swapaxes(1, 3)
).reshape(nvir*nocc, nvir*nocc)
U_ia = np.einsum("PQ, sQ -> sP", np.linalg.inv(A_p), d_P)
U_ia.shape = (3, nocc, nvir)

随后,根据求导法则与矩阵的对称性、反对称性的应用,应当可以得到静态极化率表达式为

\[ \alpha_{ts} (0) = \frac{\partial^2 E_\mathrm{RHF}}{\partial t \partial s} = \frac{\partial D_{ij} d^t_{ij} \delta_{ij}}{\partial s} = 4 d^t_{ia} U^s_{ia} \]
4 * np.einsum("tia, sia -> ts", d_ia, U_ia)
array([[ 6.58142, -0.0841 , -1.45378],
       [-0.0841 ,  4.26835,  0.39969],
       [-1.45378,  0.39969, 17.89033]])

上述的结果与 Gaussian 计算所得到的静态极化率 ref_alpha_static 完全一致。

np.allclose(
    4 * np.einsum("tia, sia -> ts", d_ia, U_ia),
    ref_alpha_static)
True

矩阵求逆直接获得静态极化率#

根据我们所写的 CP-HF 方程

\[ A'_{ia, jb} U^t_{jb} = d^t_{ia} \]

我们应当很容易地想到,如果我们有足够的计算能力,可以对四脚标矩阵 \(A'_{ia, jb}\) 求逆,那么我们不一定需要明确写出 U 矩阵,也一样可以求得静态极化率:

\[ \alpha_{ts} (0) = 4 d^t_{ia} (A'{}^{-1})_{ia, jb} d^s_{ia} \]

当然,上面的计算过程实际上是用双下标 (\(P = ia\), \(Q = jb\)) 表达式实现的:

\[ \alpha_{ts} (0) = 4 d^t_{P} (A'{}^{-1})_{PQ} d^s_{Q} \]
np.allclose(4 * np.einsum("tP, PQ, sQ -> ts", d_P, np.linalg.inv(A_p), d_P), ref_alpha_static)
True

跃迁偶极矩获得静态极化率#

CP-HF 方程求解静态极化率的思路是非常直观的;但静态极化率还可以通过 TD-HF 的方式求得。表面上,这两种推导思路和前提几乎完全不同;但我们却可以得到数值上 完全 (而非近似) 相等的静态极化率。这里我们会作说明。

我们首先不加说明地直接给出 TD-HF 方式给出的静态极化率公式:

\[ \alpha_{ts} (0) = 2 \frac{\langle 0 | \hat d{}^t | n \rangle \langle n | \hat d{}^s | 0 \rangle}{\omega_n} \]

它很容易化为程序表达式:

2 * np.einsum("nt, n, ns -> ts", td_transdip, 1 / td_eig, td_transdip)
array([[ 6.58142, -0.0841 , -1.45378],
       [-0.0841 ,  4.26835,  0.39969],
       [-1.45378,  0.39969, 17.89033]])

我们与上面用 CP-HF 方式计算得到的静态极化率作对比,不难发现两者的值是完全相等的。可以用下述程序与 Gaussian 的计算结果作对比:

np.allclose(2 * np.einsum("nt, n, ns -> ts", td_transdip, 1 / td_eig, td_transdip), ref_alpha_static)
True

这说明,对于静态极化率问题,TD-HF 与 CP-HF 方法之间有着确实的联系。我们下面就使用 TD-HF 方程来导出 CP-HF 方程的结果,或者说从简单的线性代数角度证明:

\[ \alpha_{ts} (0) = 2 \frac{\langle 0 | \hat d{}^t | n \rangle \langle n | \hat d{}^s | 0 \rangle}{\omega_n} = 4 d^t_{P} (A'{}^{-1})_{PQ} d^s_{Q} \]

静态极化率下 TD-HF 方程与 CP-HF 方程的等价推导#

这里我们会给出静态情况下,TD-HF 与 CP-HF 方程的推演过程。对于动态 (含频) 过程的推演,我们会放在文档的后面描述。

首先,我们会说明 TD-HF 方程的 A \(\mathbb{A}_{PQ}\)B \(\mathbb{B}_{PQ}\) 之和,恰好是 CP-HF 方程的 A_p \(A'_{PQ}\)

\[ A'_{PQ} = \mathbb{A}_{PQ} + \mathbb{B}_{PQ} \]
np.allclose(A + B, A_p)
True

利用 \(\langle 0 | \hat d{}^t | n \rangle = d_{ia}^t (X_{ia}^n + Y_{ia}^n)\),我们重新用 \(X_P^n, Y_P^n\) 的形式写一下 TD-HF 方程所给出的极化率公式:

\[ \alpha_{ts} (0) = 2 \frac{d_P^t d_Q^s (X_P^n + Y_P^n) (X_Q^n + Y_Q^n)}{\omega_n} \]
np.allclose(2 * np.einsum("tP, sQ, nP, nQ, n -> ts", d_P, d_P, X + Y, X + Y, 1 / td_eig), ref_alpha_static)
True

回顾到 TD-HF 方程

\[\begin{split} \begin{pmatrix} \mathbb{A} & \mathbb{B} \\ - \mathbb{B} & - \mathbb{A} \end{pmatrix} \begin{pmatrix} \mathbf{X}^n \\ \mathbf{Y}^n \end{pmatrix} = \omega_n \begin{pmatrix} \mathbf{X}^n \\ \mathbf{Y}^n \end{pmatrix} \end{split}\]

我们可以推知,\((\mathbb{A} + \mathbb{B}) (\mathbf{X}^n + \mathbf{Y}^n) = \omega_n (\mathbf{X}^n - \mathbf{Y}^n)\),或者写为角标求和的形式,

\[ (\mathbb{A} + \mathbb{B})_{RQ} (\mathbf{X}^n + \mathbf{Y}^n)_Q = \omega_n (\mathbf{X}^n - \mathbf{Y}^n)_R \]

那么我们可以将上式,以及矩阵逆关系 \((\mathbb{A} + \mathbb{B})^{-1} (\mathbb{A} + \mathbb{B}) = \mathbb{1}\)

\[ (\mathbb{A} + \mathbb{B})^{-1}_{SR} (\mathbb{A} + \mathbb{B})_{RQ} = \delta_{SQ} \]

代入到上面提到的 TD-HF 的极化率公式中,得到

\[\begin{split} \begin{align} \alpha_{ts} (0) &= 2 \frac{d_P^t d_Q^s (X_P^n + Y_P^n) (\mathbb{A} + \mathbb{B})^{-1}_{QR} (\mathbb{A} + \mathbb{B})_{RQ} (X_Q^n + Y_Q^n)}{\omega_n} \\ &= 2 d_P^t d_Q^s (X_P^n + Y_P^n) (\mathbb{A} + \mathbb{B})^{-1}_{QR} (X_R^n - Y_R^n) \end{align} \end{split}\]

随后我们需要利用 \(\mathbf{X}^n, \mathbf{Y}^n\) 的正交化条件。正交化条件我们曾经在给出 \(\mathbf{X}^n, \mathbf{Y}^n\) 时确实利用到过,但其更有用的推论是 \((\mathbf{X} + \mathbf{Y})^\dagger (\mathbf{X} - \mathbf{Y}) = 2 \cdot \mathbb{1}\)

\[ (\mathbf{X}^n + \mathbf{Y}^n)_P (\mathbf{X}^n - \mathbf{Y}^n)_R = 2 \delta_{PR} \]

我们可以用下面的程序来说明这一问题:

np.einsum("nP, nQ -> PQ", X + Y, X - Y)
array([[ 2., -0.,  0., ...,  0.,  0., -0.],
       [ 0.,  2.,  0., ...,  0.,  0.,  0.],
       [ 0.,  0.,  2., ...,  0., -0.,  0.],
       ...,
       [ 0.,  0., -0., ...,  2., -0., -0.],
       [ 0.,  0.,  0., ..., -0.,  2., -0.],
       [-0.,  0., -0., ..., -0., -0.,  2.]])

那么我们就可以将上面的极化率公式化为

\[\begin{split} \begin{align} \alpha_{ts} (0) &= 2 d_P^t d_Q^s (\mathbb{A} + \mathbb{B})^{-1}_{QR} \cdot 2 \delta_{PR} \\ &= 4 d_Q^s (\mathbb{A} + \mathbb{B})^{-1}_{QP} d_P^t \\ &= 4 d_Q^s (A'{}^{-1})_{QP} d^t_P \end{align} \end{split}\]

我们知道,上式的等式左边是对 \(Q, P\) 双下标进行求和。作为被求和的两个下标是可以被交换的,因此我们可以将上式写为

\[ \alpha_{ts} (0) = 4 d_P^s (A'{}^{-1})_{PQ} d^t_Q \]
np.allclose(4 * np.einsum("sP, PQ, tQ -> ts", d_P, np.linalg.inv(A_p), d_P), ref_alpha_static)
True

上述推导并没有结束。我们回顾到刚才 CP-HF 所给出的极化率并不是上述的表达式,而是交换了 \(t, s\) 两者的极化率:

\[ \alpha_{ts} (0) = 4 d_P^t (A'{}^{-1})_{PQ} d^s_Q \]
np.allclose(4 * np.einsum("tP, PQ, sQ -> ts", d_P, np.linalg.inv(A_p), d_P), ref_alpha_static)
True

从极化率作为能量的二阶梯度的角度来说,这是因为被求导量可交换,因此极化率具有 Hermite 性质:

\[ \alpha_{ts} (\omega) = \alpha_{st} (\omega) \]

到这一步为止,从 TD-HF 方程给出的极化率,成功地推导出了 CP-HF 方程所给出的极化率。

含频极化率#

跃迁偶极矩获得含频极化率#

我们先不对含频极化率作公式上的分析,先只看数值的结果,并与 Gaussian 输出的结果进行比较。

我们仍然不加说明地直接给出 TD-HF 方式给出的含频极化率公式:

\[ \alpha_{ts} (\omega_n) = \frac{\langle 0 | \hat d{}^t | n \rangle \langle n | \hat d{}^s | 0 \rangle}{\omega_n - \omega} + \frac{\langle 0 | \hat d{}^t | n \rangle \langle n | \hat d{}^s | 0 \rangle}{\omega_n + \omega} \]

需要留意的是,\(\omega_n\) 为分子通过 TD-HF 方程解出来的激发能,而 \(\omega\) 是外加的、任意的激发光束频率;两者除了单位一致外几乎完全无关。

上述公式中,前一项称为共振项 (resonance term),后一项称为非共振项。这两项在不同频率下的行为可以很容易地用图片表示出来;当 \(\omega\) 接近激发频率 \(\omega_n\) 时产生断点行为的项是共振项。

它也很容易化为程序表达式。我们用下述函数 freq_to_alpha,输入 omega \(\omega\) 来返回含频频率 \(\alpha_{ts} (\omega_n)\);并将其中的共振项与非共振项拆分为函数 freq_to_resfreq_to_nonres

freq_to_res    = lambda omega: np.einsum("nt, n, ns -> ts", td_transdip, 1 / (td_eig - omega), td_transdip)
freq_to_nonres = lambda omega: np.einsum("nt, n, ns -> ts", td_transdip, 1 / (td_eig + omega), td_transdip)
freq_to_alpha  = lambda omega: freq_to_res(omega) + freq_to_nonres(omega)

若外加激发光束频率为 0,那么将退化到静态极化率的情形中:

freq_to_alpha(0)
array([[ 6.58142, -0.0841 , -1.45378],
       [-0.0841 ,  4.26835,  0.39969],
       [-1.45378,  0.39969, 17.89033]])
np.allclose(freq_to_alpha(0), ref_alpha_static)
True

在静态情况下,共振项与非共振项对总极化率的贡献是相等的:

freq_to_res(0)
array([[ 3.29071, -0.04205, -0.72689],
       [-0.04205,  2.13418,  0.19984],
       [-0.72689,  0.19984,  8.94517]])

我们现在想要尝试与 Gaussian 的数据进行核对并绘制图片。回顾到我们曾经定义过 freq_full_list 为较广的频率范围,其对应的 Gaussian 计算的极化率在 alpha_full_list。我们将用 freq_to_alpha 函数绘制的 \(\alpha_{zz} (\omega)\) 极化率分量放在 numpy 张量列表 alpha_zz_full_calc 中;并将其共振项与非共振项分别放在 alpha_zz_full_resalpha_zz_full_nonres

alpha_zz_full_calc   = np.vectorize(lambda omega: freq_to_alpha (omega)[2, 2])(freq_full_list)
alpha_zz_full_res    = np.vectorize(lambda omega: freq_to_res   (omega)[2, 2])(freq_full_list)
alpha_zz_full_nonres = np.vectorize(lambda omega: freq_to_nonres(omega)[2, 2])(freq_full_list)

下面我们绘制在这个频率区间内,Gaussian 计算得到的结果与我们用上面跃迁偶极矩的公式获得的结果作比较。尽管在断点附近两者表现略有不同,但稳定区间的含频极化率与 Gaussian 的结果基本上是一致的。

fig, ax = plt.subplots()
ax.plot(freq_full_list, alpha_full_list[:, 2, 2], label="Gaussian")
ax.plot(freq_full_list, alpha_zz_full_res, linestyle="-.", c="C2", label="Resonance")
ax.plot(freq_full_list, alpha_zz_full_nonres, linestyle="-.", c="C3", label="Non-Resonance")
ax.plot(freq_full_list, alpha_zz_full_calc, linestyle=":", label="Calculated")
rect = patches.Rectangle((0.184, -24), 0.01, 78, linewidth=1, edgecolor='C4', facecolor='C4', alpha=.25)
ax.add_patch(rect)
ax.set_ylim(-25, 75)
ax.set_xlabel(r"$\omega$ / $E_\mathrm{h}$")
ax.set_ylabel(r"$\alpha_{zz} (\omega)$ / a.u.")
ax.set_title("Frequency-Dependent Polarizability of $\mathrm{H_2O_2}$ (RHF/6-31G)")
ax.legend()
fig.show()

对于前两个激发态能量的窄区间中,我们的结果在非断点附近其实与 Gaussian 的结果也基本一致:

alpha_zz_small_calc   = np.vectorize(lambda omega: freq_to_alpha (omega)[2, 2])(freq_small_list)
alpha_zz_small_res    = np.vectorize(lambda omega: freq_to_res   (omega)[2, 2])(freq_small_list)
alpha_zz_small_nonres = np.vectorize(lambda omega: freq_to_nonres(omega)[2, 2])(freq_small_list)
fig, ax = plt.subplots()
ax.plot(freq_small_list, alpha_small_list[:, 2, 2], label="Gaussian")
ax.plot(freq_small_list, alpha_zz_small_res, linestyle="-.", c="C2", label="Resonance")
ax.plot(freq_small_list, alpha_zz_small_nonres, linestyle="-.", c="C3", label="Non-Resonance")
ax.plot(freq_small_list, alpha_zz_small_calc, linestyle=":", label="Calculated")
ax.set_xlabel(r"$\omega$ / $E_\mathrm{h}$")
ax.set_ylabel(r"$\alpha_{zz} (\omega)$ / a.u.")
ax.set_title("Frequency-Dependent Polarizability of $\mathrm{H_2O_2}$ (RHF/6-31G)\nFor First Two Excited States")
ax.legend()
fig.show()

我们认为我们确实正确计算了含频极化率。在断点附近的行为应当被认为是数值上的微小差别;并且我们认为,在断点 (共振) 处附近产生的极化率的主要贡献部分应为极化率的共振项所产生。

TD-HF 方程含频极化率及其与跃迁偶极矩的关联#

上一大段中,我们仅仅是用了 TD-HF 给出的跃迁偶极矩结果,反推出了 CP-HF 的公式。但我们并没有介绍过最原始的 TD-HF 极化率表达式。下面我们会从最普遍的公式,推导含频极化率的表达式。下面的推导过程中,程序的部分会少一些。

我们从 TD-DFT 的 Casida 方程开始;Casida 方程可以简单地退化到 TD-HF 的情形。我们上面在推演激发频率 \(\omega_n\) 时,也提到了 Casida 方程;

\[\begin{split} \begin{align} \begin{pmatrix} \mathbb{A} & \mathbb{B} \\ - \mathbb{B} & - \mathbb{A} \end{pmatrix} \begin{pmatrix} \mathbf{X}^n \\ \mathbf{Y}^n \end{pmatrix} = \omega_n \begin{pmatrix} \mathbf{X}^n \\ \mathbf{Y}^n \end{pmatrix} \tag{1} \end{align} \end{split}\]

但下面的 Casida 方程具有更为广泛的适用情形。我们引入外加的偶极微扰 d_P \(\mathbf{d}^t\) 与外加激发光束频率 omega \(\omega\) 的微扰,则有

\[\begin{split} \begin{align} \begin{pmatrix} \mathbb{A} & \mathbb{B} \\ \mathbb{B} & \mathbb{A} \end{pmatrix} \begin{pmatrix} \mathbf{X}'{}^t \\ \mathbf{Y}'{}^t \end{pmatrix} = \omega \begin{pmatrix} \mathbf{X}'{}^t \\ - \mathbf{Y}'{}^t \end{pmatrix} + \begin{pmatrix} 2 \mathbf{d}^t \\ 2 \mathbf{d}^t \end{pmatrix} \tag{2} \end{align} \end{split}\]

这里有不少符号上的区别。首先,(1) 式的激发态频率 \(\omega_n\) 与 (2) 式的外加光束的频率 \(\omega\) 并不相同;但 (2) 在一种情形下确实地可以退化到 (1) 式。若现在没有外加偶极微扰 \(\mathbf{d}^t\),那么外加光束必须要恰好处于分子电子的激发频率上,分子的电子云微扰变化才能被允许 (即使时间非常短,这对应的是紫外光谱电子态的平均寿命)。而电子的激发频率不一定只有一个,因此会产生上下标 \(n\) 表示不同的激发频率;第 \(n\) 个激发态电子云微扰的形变大小和取向由 \(\mathbf{X}^n\)\(\mathbf{Y}^n\) 共同决定;它们将会产生第 \(n\) 个激发态的跃迁密度 \(\rho^n (\boldsymbol{r}, \omega)\) (下式对 \(i, a\) 求和):

\[ \rho^n (\boldsymbol{r}, \omega_n) = (X_{ia}^n + Y_{ia}^n) \phi_i (\boldsymbol{r}) \phi_a (\boldsymbol{r}) \]

其次,(2) 式的 \(\mathbf{X}'{}^t\) 若不看偶极激发方向 \(t\),它还比 (1) 式的 \(\mathbf{X}^n\) 少了 \(n\) 并多了一撇;多的一撇是为了区分两者。之所以这里没有 \(n\),我们可以这样考虑:在某一个特定的外加偶极微扰 \(\mathbf{d}^t\) 与频率 \(\omega\) 微扰下,分子的电子云确实会发生改变;但这种改变的方式一般是唯一的 (简并情况我们不作讨论)。这种形变所产生的密度形式也是类似的 (下式对 \(i, a\) 求和):

\[ \rho (\boldsymbol{r}, \mathbf{d}^t, \omega) = (X_{ia}'{}^t + Y_{ia}'{}^t) \phi_i (\boldsymbol{r}) \phi_a (\boldsymbol{r}) \]

含频极化率可以通过上述的形变密度在偶极算符的作用下给出:

\[ \alpha_{ts} (\omega) = \int -s \cdot \rho (\boldsymbol{r}, \mathbf{d}^t, \omega) \, \mathrm{d} \boldsymbol{r} = (X_{ia}'{}^t + Y_{ia}'{}^t) \int -s \cdot \phi_i (\boldsymbol{r}) \phi_a (\boldsymbol{r}) \, \mathrm{d} \boldsymbol{r} = (X_{ia}'{}^t + Y_{ia}'{}^t) d_{ia}^s = (X_P'{}^t + Y_P'{}^t) d_P^s \]

其中关于 \(X_P'{}^t, Y_P'{}^t\) 需要通过 Casida 方程求取。(2) 式经过简单的代数处理后得到 (3) 式:

\[\begin{split} \begin{align} \begin{pmatrix} \mathbb{A} - \omega \mathbb{1} & \mathbb{B} \\ - \mathbb{B} & - \mathbb{A} - \omega \mathbb{1} \end{pmatrix} \begin{pmatrix} \mathbf{X}'{}^t \\ \mathbf{Y}'{}^t \end{pmatrix} = \begin{pmatrix} 2 \mathbf{d}^t \\ - 2 \mathbf{d}^t \end{pmatrix} \tag{3} \end{align} \end{split}\]

那么我们有

\[\begin{split} \begin{align} \alpha_{ts} (\omega) = (X_P'{}^t + Y_P'{}^t) d_P^s = \begin{pmatrix} \mathbf{d}^s & \mathbf{d}^s \end{pmatrix} \begin{pmatrix} \mathbf{X}'{}^t \\ \mathbf{Y}'{}^t \end{pmatrix} = \begin{pmatrix} \mathbf{d}^s & \mathbf{d}^s \end{pmatrix} \begin{pmatrix} \mathbb{A} - \omega \mathbb{1} & \mathbb{B} \\ - \mathbb{B} & - \mathbb{A} - \omega \mathbb{1} \end{pmatrix}^{-1} \begin{pmatrix} 2 \mathbf{d}^t \\ - 2 \mathbf{d}^t \end{pmatrix} \tag{4} \end{align} \end{split}\]

对于不带频率的情形,我们可以用下面的代码验证:

np.einsum("tP, PQ, sQ -> ts",
          np.concatenate([d_P, d_P], axis=1),
          np.linalg.inv(AB),
          np.concatenate([2 * d_P, - 2 * d_P], axis=1))
array([[ 6.58142, -0.0841 , -1.45378],
       [-0.0841 ,  4.26835,  0.39969],
       [-1.45378,  0.39969, 17.89033]])

而对于带频率的情形,我们可以举一个 \(\omega = 0.186 \, E_\mathrm{h}\) 的例子:

omega = 0.186
np.einsum("tP, PQ, sQ -> ts",
          np.concatenate([d_P, d_P], axis=1),
          np.linalg.inv(AB - np.eye(nvir*nocc*2) * omega),
          np.concatenate([2 * d_P, - 2 * d_P], axis=1))
array([[ 7.28458, -0.05683, -2.08145],
       [-0.05683,  4.79845, -1.39731],
       [-2.08145, -1.39731, 37.73368]])

但这样的表达式 (4) 并没有出现跃迁偶极矩。下面我们需要简化表达式。

首先,我们回顾式 (3),得到方程组

\[\begin{split} \begin{align} \mathbb{A} \mathbf{X}'{}^t + \mathbb{B} \mathbf{Y}'{}^t - \omega \mathbf{X}'{}^t &= 2 \mathbf{d}^t \\ \mathbb{B} \mathbf{X}'{}^t + \mathbb{A} \mathbf{Y}'{}^t + \omega \mathbf{Y}'{}^t &= 2 \mathbf{d}^t \end{align} \end{split}\]

两式加减后,可以得到

\[\begin{split} \begin{align} (\mathbb{A} + \mathbb{B}) (\mathbf{X}'{}^t + \mathbf{Y}'{}^t) - \omega (\mathbf{X}'{}^t - \mathbf{Y}'{}^t) &= 4 \mathbf{d}^t \tag{5} \\ (\mathbb{A} - \mathbb{B}) (\mathbf{X}'{}^t - \mathbf{Y}'{}^t) &= \omega (\mathbf{X}'{}^t + \mathbf{Y}'{}^t) \tag{6} \end{align} \end{split}\]

利用 (6) 式替换 (5) 式中出现的 \((\mathbf{X}'{}^t - \mathbf{Y}'{}^t)\),有

\[ (\mathbb{A} - \mathbb{B}) (\mathbb{A} + \mathbb{B}) (\mathbf{X}'{}^t + \mathbf{Y}'{}^t) - \omega^2 (\mathbf{X}'{}^t + \mathbf{Y}'{}^t) = 4 (\mathbb{A} - \mathbb{B}) \mathbf{d}^t \]

或者,等价地,

\[ (\mathbf{X}'{}^t + \mathbf{Y}'{}^t) = 4 \left( (\mathbb{A} - \mathbb{B}) (\mathbb{A} + \mathbb{B}) - \omega^2 \mathbb{1} \right)^{-1} (\mathbb{A} - \mathbb{B}) \mathbf{d}^t \]

两边再乘上 \(\mathbf{d}^s\),就得到了含频极化率:

\[ \begin{align} \alpha_{ts} (\omega) = 4 \mathbf{d}^s{}^\dagger \left( (\mathbb{A} - \mathbb{B}) (\mathbb{A} + \mathbb{B}) - \omega^2 \mathbb{1} \right)^{-1} (\mathbb{A} - \mathbb{B}) \mathbf{d}^t \tag{7} \end{align} \]

\(\omega = 0.186 \, E_\mathrm{h}\) 来表达上式,则有

4 * np.einsum("tP, PR, RQ, sQ -> ts", d_P, np.linalg.inv((A - B) @ (A + B) - omega**2 * np.eye(nvir*nocc)), A - B, d_P)
array([[ 7.28458, -0.05683, -2.08145],
       [-0.05683,  4.79845, -1.39731],
       [-2.08145, -1.39731, 37.73368]])

上述结果与通过式 (4) 给出的结果一致。

下面我们引入一个技巧。我们将会对矩阵 \((\mathbb{A} - \mathbb{B}) (\mathbb{A} + \mathbb{B}) - \omega^2 \mathbb{1}\) 作逆矩阵分解。我们采用的做法是分析矩阵的特征值与特征向量。依据我们对式 (1) 形式的 Casida 方程的讨论,我们应当容易推知

\[ \begin{align} (\mathbb{A} - \mathbb{B}) (\mathbb{A} + \mathbb{B}) (\mathbf{X}^n + \mathbf{Y}^n) = \omega_n^2 (\mathbf{X}^n + \mathbf{Y}^n) \tag{8} \end{align} \]
np.allclose((A - B) @ (A + B) @ (X + Y).T, td_eig**2 * (X + Y).T)
True

我们曾经指出过正交条件为 \((\mathbf{X} + \mathbf{Y})^\dagger (\mathbf{X} - \mathbf{Y}) = 2 \cdot \mathbb{1}\),因此,若将 \(\mathbf{X}, \mathbf{Y}\) 分别看作是横向维度 \(n\) 表示激发态,纵向维度 \(P\) 表示激发和退激发分量的方形矩阵:

\[\begin{split} \mathbf{X} = \begin{pmatrix} \mathbf{X}^1{}^\dagger \\ \mathbf{X}^2{}^\dagger \\ \vdots \\ \mathbf{X}^{n_\mathrm{occ} n_\mathrm{vir}}{}^\dagger \end{pmatrix} \end{split}\]

那么 \((\mathbf{X} + \mathbf{Y})\)\((\mathbf{X} - \mathbf{Y})\) 之间存在互逆关系:

\[ (\mathbf{X} + \mathbf{Y})^{-1} = \frac{1}{2} (\mathbf{X} - \mathbf{Y})^\dagger \]
np.allclose(np.linalg.inv(X + Y), 0.5 * (X - Y).T)
True

因此,一定程度上,我们可以认为 \((\mathbf{X} + \mathbf{Y})\)\((\mathbf{X} - \mathbf{Y})\) 两者是相互对偶的向量组。注意区别这里的 \(\mathbf{X}\) 引入的目的是为了刻画 \(\mathbb{A}, \mathbb{B}\) 的性质,而与 \(\mathbf{X}'{}^t\) 没有直接关联。

这种向量组满足下述特征向量分解公式:

\[ (\mathbb{A} - \mathbb{B}) (\mathbb{A} + \mathbb{B}) = \frac{1}{2} (\mathbf{X} + \mathbf{Y}) \mathbf{\Omega}^2 (\mathbf{X} - \mathbf{Y})^\dagger \]

其中,

\[\begin{split} \mathbf{\Omega} = \begin{pmatrix} \omega_1 & 0 & \cdots & 0 \\ 0 & \omega_2 & \cdots & 0 \\ \vdots & \vdots & \ddots & \vdots \\ 0 & 0 & \cdots & \omega_{n_\mathrm{occ} n_\mathrm{vir}} \end{pmatrix} \end{split}\]
np.allclose(
    0.5 * np.einsum("nP, n, nQ -> PQ", X + Y, td_eig**2, X - Y),
    (A - B) @ (A + B))
True

下面我们来讨论矩阵 \((\mathbb{A} - \mathbb{B}) (\mathbb{A} + \mathbb{B}) - \omega^2 \mathbb{1}\)。我们容易从式 (8) 推得

\[ \left( (\mathbb{A} - \mathbb{B}) (\mathbb{A} + \mathbb{B}) - \omega^2 \mathbb{1} \right) (\mathbf{X}^n + \mathbf{Y}^n) = (\omega_n^2 - \omega^2) (\mathbf{X}^n + \mathbf{Y}^n) \]

即上述方程的本征向量仍然是 \((\mathbf{X}^n + \mathbf{Y}^n)\),但本征值却变为 \(\omega_n^2 - \omega^2\)。因此,

\[ \begin{align} \left( (\mathbb{A} - \mathbb{B}) (\mathbb{A} + \mathbb{B}) - \omega^2 \mathbb{1} \right)^{-1} = \left( \frac{1}{2} (\mathbf{X} + \mathbf{Y}) (\mathbf{\Omega}^2 - \omega^2 \mathbb{1}) (\mathbf{X} - \mathbf{Y})^\dagger \right)^{-1} = \frac{1}{2} (\mathbf{X} + \mathbf{Y}) (\mathbf{\Omega}^2 - \omega^2 \mathbb{1})^{-1} (\mathbf{X} - \mathbf{Y})^\dagger \tag{9} \end{align} \]

上式中 \((\mathbf{\Omega}^2 - \omega^2 \mathbb{1})^{-1}\) 是一个极为容易计算的对角矩阵。\(\omega = 0.186 \, E_\mathrm{h}\) 下,程序表示上述过程则为

np.allclose(
    0.5 * np.einsum("nP, n, nQ -> PQ", X + Y, 1 / (td_eig**2 - omega**2), X - Y),
    np.linalg.inv((A - B) @ (A + B) - omega**2 * np.eye(nvir*nocc)))
True

将式 (9) 代入到式 (7),得到

\[ \alpha_{ts} (\omega) = 2 \mathbf{d}^s{}^\dagger (\mathbf{X} + \mathbf{Y}) (\mathbf{\Omega}^2 - \omega^2 \mathbb{1})^{-1} (\mathbf{X} - \mathbf{Y})^\dagger (\mathbb{A} - \mathbb{B}) \mathbf{d}^t \]

利用极化率的 Hermite 性,并将上述矩阵表达式展开为求和式,得到

\[ \alpha_{ts} (\omega) = 2 d_P^t (\mathbf{X}^n + \mathbf{Y}^n)_P \cdot \frac{1}{\omega_n^2 - \omega^2} \cdot (\mathbf{X}^n - \mathbf{Y}^n)_R (\mathbb{A} - \mathbb{B})_{RQ} d_Q^s \]
2 * np.einsum("tP, nP, n, nR, RQ, sQ", d_P, X + Y, 1 / (td_eig**2 - omega**2), X - Y, A - B, d_P)
array([[ 7.28458, -0.05683, -2.08145],
       [-0.05683,  4.79845, -1.39731],
       [-2.08145, -1.39731, 37.73368]])

若要化简上式,首先需要利用 \((\mathbb{A} - \mathbb{B})_{RQ}\) 事实上恰好是一个 Hermite 矩阵:

np.allclose(A - B, (A - B).T)
True

随后注意到 \((\mathbb{A} - \mathbb{B})_{QR} (\mathbf{X}^n - \mathbf{Y}^n)_R = \omega_n (\mathbf{X}^n + \mathbf{Y}^n)_Q\);于是上式化为

\[ \alpha_{ts} (\omega) = 2 d_P^t (\mathbf{X}^n + \mathbf{Y}^n)_P \cdot \frac{\omega_n}{\omega_n^2 - \omega^2} \cdot (\mathbf{X}^n + \mathbf{Y}^n)_Q d_Q^s \]

我们留意到跃迁偶极矩的定义是 \(\langle 0 | \hat d{}^t | n \rangle = d_P^t (\mathbf{X}^n + \mathbf{Y}^n)_P\),并且利用下面的小技巧:

\[ \frac{\omega_n}{\omega_n^2 - \omega^2} = \frac{1}{2} \left( \frac{1}{\omega_n - \omega} - \frac{1}{\omega_n + \omega} \right) \]

我们就可以推知,

\[ \alpha_{ts} (\omega) = \frac{\langle 0 | \hat d{}^t | n \rangle \langle n | \hat d{}^s | 0 \rangle}{\omega_n - \omega} + \frac{\langle 0 | \hat d{}^t | n \rangle \langle n | \hat d{}^s | 0 \rangle}{\omega_n + \omega} \]
(
    + np.einsum("tP, nP, n, nQ, sQ -> ts", d_P, X + Y, 1 / (td_eig - omega), X + Y, d_P)
    + np.einsum("tP, nP, n, nQ, sQ -> ts", d_P, X + Y, 1 / (td_eig + omega), X + Y, d_P)
)
array([[ 7.28458, -0.05683, -2.08145],
       [-0.05683,  4.79845, -1.39731],
       [-2.08145, -1.39731, 37.73368]])

这就完成了从普适的 Casida 方程推演得到跃迁偶极矩表示的极化率的公式了。

若接受 Casida 方程的假设前提,那么上述的推演将会是严格的。

TD-HF 方程含频极化率与 CP-HF 方程间的关系#

我们先回顾静态极化率求取时所使用的 CP-HF 方程:

\[ A'_{ia, jb} U^t_{jb} = d^t_{ia} \]

写为双下标的形式,则为

\[ A'_{PQ} U^t_Q = d^t_P \]

写为矩阵形式,则为

\[ \mathbf{A}' \mathbf{U}^t = \mathbf{d}^t \]

留意到 \(A'_{PQ} = (\mathbb{A} + \mathbb{B})_{PQ}\),因此上式这对应到 Casida 方程的一个导出式 (5)。若 \(\omega = 0\),则

\[ (\mathbb{A} + \mathbb{B}) (\mathbf{X}'{}^t + \mathbf{Y}'{}^t) = 4 \mathbf{d}^t \]

因此,在静态情形 \(\omega = 0\) 下,\(\mathbf{U}^t = \frac{1}{4} (\mathbf{X}' + \mathbf{Y}')\)

但若 \(\omega \neq 0\),那么式 (5) 应写作

\[ \left( (\mathbb{A} + \mathbb{B}) - \omega^2 (\mathbb{A} - \mathbb{B})^{-1} \right) (\mathbf{X}'{}^t + \mathbf{Y}'{}^t) = 4 \mathbf{d}^t \]

若我们拓展 CP-HF 方程为含频形式

\[ \mathbf{A}' (\omega) \mathbf{U}^t (\omega) = \mathbf{d}^t \]

并且极化率可以写为

\[ \alpha_{ts} (\omega) = 4 \mathbf{U}^t (\omega)^\dagger \mathbf{d}^t \]

那么

\[\begin{split} \begin{align} \mathbf{A}' (\omega) &= (\mathbb{A} + \mathbb{B}) - \omega^2 (\mathbb{A} - \mathbb{B})^{-1} \\ \mathbf{U}^t (\omega) &= \frac{1}{4} (\mathbf{X}'{}^t + \mathbf{Y}'{}^t) \end{align} \end{split}\]

需要留意尽管我们之前一直没有引入 \((\omega)\) 记号来强调,但 \(\mathbf{X}'{}^t, \mathbf{Y}'{}^t\) 是随频率变化而变化的。

omega = 0.186
A_p_omega = (A + B) - omega**2 * np.linalg.inv(A - B)
U_omega = np.einsum("PQ, tQ -> tP", np.linalg.inv(A_p_omega), d_P)
4 * np.einsum("tP, sP -> ts", U_omega, d_P)
array([[ 7.28458, -0.05683, -2.08145],
       [-0.05683,  4.79845, -1.39731],
       [-2.08145, -1.39731, 37.73368]])

这就在含频情形下,将 CP-HF 与 TD-HF 的公式联系在了一起。

总结#

这篇文档我们简单且不太严格和完整地回顾了静态与含频极化率的计算,通过 Casida 方程推导了含频极化率,并将 TD-HF 分析方法与 CP-HF 方法联系起来。一些主要的结论和拓展思路会是:

  • TD-HF 方程与 CP-HF 方程在静态极化率情形下有极为紧密的联系,两种表达式完全等价;而含频极化率情形下,TD-HF 方程 (Casida 方程) 可以导出与 CP-HF 方程形式类似的方程。这可能是 Frequency-Dependent CP-HF 方程的原型。

  • 从 CP 的角度讲,根据下式

    \[ (\mathbb{A} + \mathbb{B}) (\mathbf{X}'{}^t + \mathbf{Y}'{}^t) = 4 \mathbf{d}^t + \omega (\mathbb{A} - \mathbb{B})^{-1} (\mathbf{X}'{}^t + \mathbf{Y}'{}^t) \]

    \[ \mathbf{A}' (\omega) \mathbf{U}^t (\omega) = \mathbf{d}^t \]

    我们可以想到等式左边是电子云的弛豫过程;等式右边是电子云所受到的外加微扰场。因此电子云的激发过程可以看作外场微扰。

    若退化为没有偶极微扰的分子激发过程,从 CP 的角度看,电子云变形的弛豫效应 (等式左边) 应当恰好能补足电子云激发所导致的变形外场 (等式右边)。当两者自洽时,电子云能态的激发成立。

  • 上述所述的 TD-HF 方程是 TD 分析中的 Linear Response 关系。事实上,TD 还具有更高阶的分析过程,但这里我们并没有涉及。

    一个显而易见的问题会是,电子云已经在外加偶极场中,因此电子云的性质应当已经发生变化;但 Linear Response 给出的 TD-HF 方程 (Casida 方程) 却告诉我们体系的激发能 \(\omega_n\) 并没有变化,无论偶极场的值有多大。这显然违背我们的物理直觉。这也可能意味着以 MP2、CC 为底层量化方法,若外加的相关能密度微扰近似场没有把握住物理实在,那么含频极化率也可能面临激发能 \(\omega_n\) 还仍然是 TD-HF 激发能的情况。

  • 文档中没有画出,但确实存在的情况是,若入射频率恰好处在分子的激发态密集区,那么单个入射频率下的含频极化率的计算很可能是没有意义的;因为在激发态密集区,含频极化率曲线的振荡情况及其严重,不仅近乎毫无规律可言,其绝对值也大到非常离谱。

    Raman 光谱可以认为是含频极化率对分子的简正坐标导数得来。从实验的角度来讲,这可能是表面增强 Raman (SERS) 中化学增强效应的一个很好的性质。若有机物负载在银原子簇上,入射的激发光处于银原子簇的激发能带但又不破坏有机物分子,或让银原子簇与有机物分子产生电荷转移时,Raman 信号确实可以成千上万倍地增强。但从计算的角度来讲,这可能是非常令人绝望的,因为计算过程中的误差把控近乎于不存在;若使用不同的密度泛函近似,即使得到相差 0.1 eV 的激发能带差,即使定性上结果正确,但计算得到的 Raman 信号也可能会有成百上千的误差。

    因此,这可能需要我们对入射光作某种频率增宽,以平缓极化率断点,得到相对定量的数值结果;或者将极化率当作原子可加性的物理性质,进行类似于 AIM (Atomic in Molecule) 的极化率拆分的定性近似分析。


磁性质数值导数 (1):RHF 的非 GIAO 磁化率#

创建时间:2020-08-27

在这篇文档中,我们会讨论使用 PySCF 以及其作为 libcint 的接口,计算非 GIAO 的 RHF 数值磁化率的程序。该文档大量参考 PySCF 的代码 magnetizability/rhf.pynmr/rhf.py。一些公式符号参考 Atkins, Friedman [1]

我们的讨论中所使用到的分子体系 mol 会是非对称的氨分子,并且取用最小基组。规范原点 (Gauge Origin) 会取在坐标原点上 coord_orig。其 RHF 计算放在实例 mf,而磁化率计算实例会放在 mf_mag

from pyscf import gto, scf
from pyscf.prop import nmr, magnetizability
import numpy as np

np.set_printoptions(precision=5, linewidth=150, suppress=True)
mol = gto.Mole()
mol.atom = """
N  0.  0.  0.
H  0.  1.  0.2
H  0.1 0.3 1.5
H  0.9 0.4 -.2
"""
mol.basis = "STO-3G"
mol.verbose = 0
mol.build()
coord_orig = np.zeros(3)

其自洽场能量为

mf = scf.RHF(mol).run()
mf.e_tot
-55.253540514686556

其磁化率张量 \(\xi_{ts}\) 为 (其中,\(t, s \in \{ x, y, z \}\) 表示三个坐标方向,需要注意这里选择了规范原点为坐标原点,若选取其它坐标则会得到非常不同的结果)

mf_mag = magnetizability.RHF(mf)
mf_mag.gauge_orig = coord_orig
mf_mag.kernel()
array([[-4.94475,  0.21773, -0.08268],
       [ 0.21773, -4.27801,  0.49885],
       [-0.08268,  0.49885, -4.15348]])

基础概念#

分子能量作为外加微扰量的函数#

我们指出,磁化率可以看作是分子处在某一恒定外加磁场 \(\boldsymbol{\mathscr{B}}\) 下 (作为三维矢量),所产生的能量变化的表征:

\[ E_\mathrm{tot} (\boldsymbol{\mathscr{B}}) = E_\mathrm{tot}^{(0)} + E_\mathrm{tot}^{(1)} \boldsymbol{\mathscr{B}} + E_\mathrm{tot}^{(2)} \boldsymbol{\mathscr{B}}^2 + \cdots \]

以一般物理的约定俗成而言,对于外加的磁场微扰 (Atkins and Friedman, eq 13.34)

\[ E_\mathrm{tot}^{(2)} = - \frac{1}{2} \boldsymbol{\mathscr{B}}^\dagger \boldsymbol{\xi} \boldsymbol{\mathscr{B}} = - \frac{1}{2} \sum_{t, s \in \{ x, y, z \}} \mathscr{B}_t \xi_{ts} \mathscr{B}_s \]

其中,\(\boldsymbol{\xi}\) 是二维对称矩阵 (或称张量,如之前代码所展示)。我们使用了 \(\boldsymbol{\xi}\) (Atkins and Friedman, eq 13.34, termed as magnetizability) 而非 \(\boldsymbol{\chi}\) (Atkins and Friedman, eq 13.3c, termed as magnetic susceptibility) 来表示磁化率。因此,磁化率本身可以表示为 (矩阵元的形式与向量形式)

\[ \xi_{ts} = - \frac{\partial^2 E_\mathrm{tot} (\boldsymbol{\mathscr{B}})}{\partial \mathscr{B}_t \partial \mathscr{B}_s}, \quad \boldsymbol{\xi} = - \boldsymbol{\nabla}_{\boldsymbol{\mathscr{B}}} \boldsymbol{\nabla}_{\boldsymbol{\mathscr{B}}}^\dagger E_\mathrm{tot} (\boldsymbol{\mathscr{B}}) \]

哈密顿算符作为外加微扰量的算符#

能量可以通过波函数在哈密顿算符的变分极小值处的期望获得:

\[ E_\mathrm{tot} (\boldsymbol{\mathscr{B}}) = \langle \Psi (\boldsymbol{\mathscr{B}}) | \hat H (\boldsymbol{\mathscr{B}}) | \Psi (\boldsymbol{\mathscr{B}}) \rangle \]

其中,

\[ \hat H (\boldsymbol{\mathscr{B}}) = \sum_{i} \hat h (\boldsymbol{\mathscr{B}}, \boldsymbol{r}_i) + \hat V_\mathrm{ee} + \hat V_\mathrm{NN} \]

上述算符是体系的多电子总哈密顿算符;而 \(\hat h (\boldsymbol{\mathscr{B}})\) 则是单电子的 Core Hamiltonian 算符;\(\hat V_\mathrm{ee}\) 为电子互斥算符,\(\hat V_\mathrm{NN}\) 为原子核互斥算符。需要注意,由于我们不使用 GIAO,因此 \(\hat V_\mathrm{ee}\) 就是普通的电子互斥算符,不受外场 \(\boldsymbol{\mathscr{B}}\) 干扰;但使用 GIAO 的情况下,可能需要额外考虑这部分贡献。

\[ \hat h (\boldsymbol{\mathscr{B}}) = \hat h {}^{(0)} + \hat h {}^{(1)} (\boldsymbol{\mathscr{B}}) + \hat h {}^{(2)} (\boldsymbol{\mathscr{B}}) \]

\(\hat h {}^{(0)}\) 是没有外加场的算符 (这与自洽场计算过程所用到的算符相同)。其余的算符则为 (Atkins and Friedman, eq 13.26, eq 13.29)

\[\begin{split} \begin{align} \hat h {}^{(1)} (\boldsymbol{\mathscr{B}}) &= \frac{1}{2} \boldsymbol{\mathscr{B}} \cdot \boldsymbol{r} \times \boldsymbol{\hat{p}} \\ \hat h {}^{(2)} (\boldsymbol{\mathscr{B}}) &= \frac{1}{8} \big( \boldsymbol{\mathscr{B}}^2 \boldsymbol{r}^2 - (\boldsymbol{\mathscr{B}} \cdot \boldsymbol{r})^2 \big) \end{align} \end{split}\]

其中,

\[\begin{split} \boldsymbol{r} = \begin{pmatrix} x \\ y \\ z \end{pmatrix}, \quad \boldsymbol{\hat p} = \begin{pmatrix} \displaystyle - i \frac{\partial}{\partial x} \\ \displaystyle - i \frac{\partial}{\partial y} \\ \displaystyle - i \frac{\partial}{\partial z} \end{pmatrix} = -i \nabla \boldsymbol{r} \end{split}\]

其中,

\[ E_\mathrm{tot}^{(2)} = 2 \langle \Psi^{(0)} (\boldsymbol{\mathscr{B}}) | \sum_i \hat h {}^{(1)} (\boldsymbol{\mathscr{B}}, \boldsymbol{r}_i) | \Psi^{(1)} (\boldsymbol{\mathscr{B}}) \rangle + \langle \Psi^{(0)} | \sum_i \hat h {}^{(2)} (\boldsymbol{\mathscr{B}}, \boldsymbol{r}_i) | \Psi^{(0)} \rangle \]

前一项被称为顺磁项 (Paramagnetic),后一项称为抗磁项 (Diamagnetic)。\(\Psi^{(0)}\) 是指未微扰的体系哈密顿算符 \(\hat H {}^{(0)}\) 的本征态,\(\Psi^{(1)} (\boldsymbol{\mathscr{B}})\) 则是一阶微扰的波函数;其解析的求取方法是在程序中表示为 U 矩阵,通过 CP-HF 方程求取;我们在这里不会对解析方法作说明,但了解这两项的区分是有帮助的。

Core Hamiltonian 的程序实现#

我们知道,PySCF 中,在自洽场实例中更改 Core Hamiltonian 的类方法函数 (method function) 就可以实现外场微扰下的能量计算。这在 pyxdh 偶极矩的计算 文档 中有所说明。在这里我们也要做类似的工作。

顺磁项#

在 PySCF 中,顺磁项 \(\hat h {}^{(1)} (\boldsymbol{\mathscr{B}}) = \frac{1}{2} \boldsymbol{\mathscr{B}} \cdot \boldsymbol{r} \times \boldsymbol{\hat p}\) 有其对应的积分 hcore_1 (\(h_{t \mu \nu}^{(1)}\),需要注意它不包含作为标量的 \(\mathscr{B}_t\))

\[ h_{t \mu \nu}^{(1)} \cdot \mathscr{B}_t = \langle \mu | \hat h {}^{(1)} (\mathscr{B}_t) | \nu \rangle \]
hcore_1 = - 0.5 * mol.intor("int1e_cg_irxp") * 1j
hcore_1.shape, hcore_1.dtype
((3, 8, 8), dtype('complex128'))

上述的程序看起来会有些奇怪,因为这里出现了复数。我们需要分段对其作解释。

积分字符

我们使用到了积分字符 int1e_cg_irxp。关于这段字符,其意义需要通过 auto_intor.cl 程序了解:

  '("int1e_cg_irxp"             (#C(0 1) \| rc cross p))

其右侧是积分的具体形式,说明在 README 文件中,意义为

\[ \mathtt{int1e\_cg\_irxp} = i \langle \mu | \boldsymbol{r} \times \boldsymbol{\hat p} | \nu \rangle \]

其维度是 \((t, \mu, \nu)\),但其第一个维度是通过向量叉乘给出,因此它与 \(\boldsymbol{r}\)\(\boldsymbol{p}\) 的维度不是直接相关的。如果我们令动量算符 \(\boldsymbol{\hat l} = \boldsymbol{r} \times \boldsymbol{\hat p}\),那么可以将上述积分写为

\[ \mathtt{int1e\_cg\_irxp}_{t \mu \nu} = i \langle \mu | \hat l_t | \nu \rangle \]

反对称性厄米性

我们应当留意到 \(\mathtt{int1e\_cg\_irxp}_{t \mu \nu}\) 是一个反对称矩阵 (即随 \(\mu, \nu\) 交换成相反值)

np.allclose(mol.intor("int1e_cg_irxp"), - mol.intor("int1e_cg_irxp").swapaxes(-1, -2))
True

这是由于 \(\nabla\) 算符本身是一个反对称算符。但是需要留意到,动量算符在此基础上乘以了虚数单位 \(- i\),因此,该矩阵是厄米的,即其转置后的共轭是其本身。我们所定义的 \(h_{t \mu \nu}^{(1)}\) 就具有这样的性质:

np.allclose(hcore_1, hcore_1.swapaxes(-1, -2).conj())
True

因此,我们会说 hcore_1 \(h_{t \mu \nu}^{(1)}\) 是厄米的。

抗磁项#

抗磁项 \(\hat h {}^{(2)} (\boldsymbol{\mathscr{B}}) = \frac{1}{8} \big( \boldsymbol{\mathscr{B}}^2 \boldsymbol{r}^2 - (\boldsymbol{\mathscr{B}} \cdot \boldsymbol{r})^2 \big)\) 需要一些技巧生成。PySCF 中可以生成张量 int1e_rr

\[ \mathtt{int1e\_rr}_{ts \mu \nu} = \langle \mu | ts | \nu \rangle \]

我们定义 hcore_2 \(h_{ts \mu \nu}^{(2)}\)

\[ h_{ts \mu \nu}^{(2)} = \frac{1}{8} \big( \delta_{ts} \langle \mu | x^2 + y^2 + z^2 | \nu \rangle - \langle \mu | ts | \nu \rangle \big) \]
with mol.with_common_orig(coord_orig):
    int1e_rr = mol.intor("int1e_rr").reshape(3, 3, mol.nao, mol.nao)
hcore_2 = 1/8 * (np.einsum("ts, uv -> tsuv", np.eye(3), int1e_rr.diagonal(0, 0, 1).sum(-1)) - int1e_rr)

并且,上述张量具有下述性质:

\[ h_{ts \mu \nu}^{(2)} \cdot \mathscr{B}_t \mathscr{B}_s = \langle \mu | \hat h {}^{(2)} (\mathscr{B}_t, \mathscr{B}_s) | \nu \rangle \]

Core Hamiltonian 程序实现#

我们最后可以编写外加磁场微扰下的 Core Hamiltonian,以及在此微扰下的分子体系能量。为了加快计算速度,我们会使用为微扰的自洽场密度作为初猜 dm_guess。Core Hamiltonian 表达式为

\[ h_{\mu \nu} (\boldsymbol{\mathscr{B}}) = h_{\mu \nu} (\mathscr{B}_x, \mathscr{B}_y, \mathscr{B}_z) = h_{\mu \nu}^{(0)} + \sum_{t} h_{t \mu \nu}^{(1)} \mathscr{B}_t + \sum_{ts} h_{ts \mu \nu}^{(2)} \mathscr{B}_t \mathscr{B}_s \]
dm_guess = mf.make_rdm1()

def hcore_mag_field(dev_xyz):
    mf = scf.RHF(mol)
    def hcore(mol_):
        hcore_total  = np.asarray(scf.rhf.get_hcore(mol_), dtype=np.complex128)
        hcore_total += np.einsum("tuv, t -> uv", hcore_1, dev_xyz)
        hcore_total += np.einsum("tsuv, t, s -> uv", hcore_2, dev_xyz, dev_xyz)
        return hcore_total
    mf.get_hcore = hcore
    return mf.kernel(dm=dm_guess)

上述函数的参数 t, s 表示坐标方向分量,dev_t, dev_s 表示外加微扰大小,单位为 a.u.。

譬如,若在 \(x\) 方向的磁场上施加 \(\mathscr{B}_x = 1 \, \mathsf{a.u.}\),而 \(y\) 方向上施加 \(\mathscr{B}_y = 2 \, \mathsf{a.u.}\) (即 \(\boldsymbol{\mathscr{B}} = (\mathscr{B}_x, \mathscr{B}_y, \mathscr{B}_z) = (1, 2, 0) \, \mathsf{a.u.}\)),那么下述程序会给出该自洽场能量 \(E_\mathrm{tot} (\mathscr{B}_x, \mathscr{B}_y, \mathscr{B}_z)\)

hcore_mag_field(0, 1, 1, 2)
-57.997213868466154

数值导数求取磁化率#

我们已经有了求取 \(E_\mathrm{tot} (\mathscr{B}_x, \mathscr{B}_y, \mathscr{B}_z)\) 的程序了,接下来就可以进行数值导数计算。数值导数的计算公式可以简单地使用三点差分法;对于被求导量 \(x :\neq y\),有 (当 \(h\) 足够小时)

\[ \frac{\partial^2 f}{\partial x \partial y} \simeq \frac{1}{4 h^2} \big[ f(x + h, y + h) - f(x - h, y + h) - f(x + h, y - h) + f(x - h, y - h) \big] \]

而对被求导量相同的情形,有

\[ \frac{\partial^2 f}{\partial x^2} \simeq \frac{1}{h^2} \big[ f(x + h) - 2 f(x) + f(x - h) \big] \]

下面的程序就依照上述两个公式进行二阶数值导数求取。求导的原点取在 \((\mathscr{B}_x, \mathscr{B}_y, \mathscr{B}_z) = (0, 0, 0)\) 即不受外磁场影响的情形的自洽场能量 eng_origin,差分大小为 interval \(h = 10^{-3} \, \mathsf{a.u.}\)。需要注意,根据约定俗成,

\[ \xi_{ts} = - \frac{\partial^2 E_\mathrm{tot} (\boldsymbol{\mathscr{B}})}{\partial \mathscr{B}_t \partial \mathscr{B}_s} \]

因此求取得到的磁化率 num_polar \(\xi_{ts}\) 需要乘以 -1。

eng_origin = hcore_mag_field((0, 0, 0))
interval = 1e-3
num_polar = np.zeros((3, 3))
for t in range(3):
    for s in range(3):
        if t != s:
            dev_xyzs = np.zeros((4, 3))
            dev_xyzs[0, t] = dev_xyzs[0, s] = dev_xyzs[1, t] = dev_xyzs[2, s] =  interval
            dev_xyzs[3, t] = dev_xyzs[3, s] = dev_xyzs[2, t] = dev_xyzs[1, s] = -interval
            num_polar[t, s] = (
                + hcore_mag_field(dev_xyzs[0])
                - hcore_mag_field(dev_xyzs[1])
                - hcore_mag_field(dev_xyzs[2])
                + hcore_mag_field(dev_xyzs[3])
            ) / (4 * interval**2)
        else:
            dev_xyzs = np.zeros((2, 3))
            dev_xyzs[0, t], dev_xyzs[1, t] = interval, -interval
            num_polar[t, t] = (
                + hcore_mag_field(dev_xyzs[0])
                + hcore_mag_field(dev_xyzs[2])
                - eng_origin * 2
            ) / (interval ** 2)
num_polar *= -1
num_polar
array([[-4.94475,  0.21773, -0.08268],
       [ 0.21773, -4.27801,  0.49885],
       [-0.08268,  0.49885, -4.15348]])

我们再与 PySCF 的解析结果作对照:

mf_mag.kernel()
array([[-4.94475,  0.21773, -0.08268],
       [ 0.21773, -4.27801,  0.49885],
       [-0.08268,  0.49885, -4.15348]])

磁性质数值导数 (2):RHF 的 GIAO 磁化率#

创建时间:2020-08-30

危险

该文档的数值导数策略有误。数值导数应当要对双电子排斥积分 (ERIs) 作改动,而非将 Fock 矩阵的贡献纳入 Core Hamiltonian 中。

近日该文档将会作修订。

在这篇文档中,我们会讨论使用 PySCF 以及其作为 libcint 的接口,计算 GIAO 的 RHF 数值磁化率的程序。该文档大量参考 PySCF 的代码 magnetizability/rhf.pynmr/rhf.py。一篇公式记号比较清晰的文章是 Laasner, Blum, et al. [1]

与上一篇文档一样,我们的讨论中所使用到的分子体系 mol 会是非对称的氨分子,并且取用最小基组。其 RHF 计算放在实例 mf,而磁化率计算实例会放在 mf_mag

在这篇文档中,我们仍然需要保留规范原点 (Gauge Origin) 的概念。规范原点会取在坐标原点上 coord_orig

from pyscf import gto, scf, dft
from pyscf.prop import nmr, magnetizability
import numpy as np

np.set_printoptions(precision=5, linewidth=150, suppress=True)
np.random.seed(0)
mol = gto.Mole()
mol.atom = """
N  0.  0.  0.
H  0.  1.  0.2
H  0.1 0.3 1.5
H  0.9 0.4 -.2
"""
mol.basis = "STO-3G"
mol.verbose = 0
mol.build()
coord_orig = np.zeros(3)
nocc, nao, nmo = mol.nelec[0], mol.nao, mol.nao

其自洽场能量为

mf = scf.RHF(mol).run()
mf.e_tot
-55.253540514686556

我们定义磁化率计算类为 mf_mag。其磁化率张量 \(\xi_{ts}\) 为:

mf_mag = magnetizability.RHF(mf)
mf_mag.kernel()
array([[-3.72497, -0.02158, -0.07514],
       [-0.02158, -2.88877,  0.20345],
       [-0.07514,  0.20345, -3.57485]])

但留意到,与上一份文档不同地,如果我们对分子作平移操作 (譬如下述平移操作是将原点移动 \((x, y, z) = (10, -10, 5) \, \mathsf{a.u.}\)),其磁化率仍然保持不变:

mol_trans = mol.copy()
mol_trans.set_geom_(mol.atom_coords() + np.array([[10, -10, 5]]), unit="AU")
mol_trans.build()
<pyscf.gto.mole.Mole at 0x7fa032502d00>
magnetizability.RHF(scf.RHF(mol_trans).run()).kernel()
array([[-3.72498, -0.0216 , -0.07515],
       [-0.0216 , -2.8888 ,  0.20345],
       [-0.07515,  0.20345, -3.57482]])

GIAO 规范不变原子轨道#

基本概念#

我们从上一篇文档中,得知对分子作平移操作后,磁化率可能会改变。

GIAO 规范不变原子轨道 (Gauge Invariant Atomic Orbital) 是一种解决方案,它可以让分子在平移操作后,保证磁化率不变。事实上,分子旋转操作下磁化率也不变,但这会额外引入三维旋转矩阵,为了方便我们就不讨论分子旋转的情形。

GIAO 的基本思路是对于外磁场引入的微扰,其波函数更换为

\[ \phi^\mathrm{GIAO}_\mu (\boldsymbol{r}) = e^{- \frac{i}{2} (\boldsymbol{R}_\mu \times \boldsymbol{r}) \cdot \boldsymbol{\mathscr{B}}} \phi_\mu (\boldsymbol{r}) \]

其中,\(\phi_\mu (\boldsymbol{r})\) 是普通的原子轨道基组,\(\boldsymbol{R}_\mu\) 是原子轨道 \(\mu\) 作为 Gaussian 基组的中心坐标相对于规范原点 (Gauge Origin) 向量,\(\boldsymbol{r}\) 是电子坐标,\(\boldsymbol{\mathscr{B}}\) 是外加微扰磁场。根据其定义,我们知道,GIAO 方法必须要使用原子轨道基组下使用。需要注意,这是一个复函数轨道。

使用该转换后的原子轨道就可以达到平移操作后磁化率不变的结果。关于这一点的证明,参考 Pople [2] eq 2.5 附近的讨论。

事实上,规范 (原点) 不变 原子轨道的称呼可能是不恰当的。Pople [3] 曾在文章里使用了规范 (原点) 依赖 原子轨道 (Gauge Dependent Atomic Orbital);这是因为 \(\phi^\mathrm{GIAO}_\mu (\boldsymbol{r})\) 实际上是依规范原点位置不同而不同的 (注意 \(\boldsymbol{R}_\mu\) 的定义)。当然,我们仍然沿用 GIAO 的约定俗成。

实例分析:GIAO 重叠矩阵在外磁场下的微扰#

概念与程序准备

我们拿一个具体的例子来说明 GIAO 的计算;最简单的例子是重叠矩阵:

\[ S_{\mu \nu}^\mathrm{GIAO} = \langle \mu | e^{- \frac{i}{2} (\boldsymbol{R}_{\mu \nu} \times \boldsymbol{r}) \cdot \boldsymbol{\mathscr{B}}} | \nu \rangle \]

其中,\(\boldsymbol{R}_{\mu \nu} = \boldsymbol{R}_\nu - \boldsymbol{R}_\mu\),它表示原子轨道 \(\mu\)\(\nu\) 作为 Gaussian 基组的中心坐标的向量之差。之所以会出现 \(- \boldsymbol{R}_\mu\) 的负号,是因为 \(\phi^\mathrm{GIAO}_\mu (\boldsymbol{r})\) 出现在左矢时,其虚数使得在作复共轭时,指数项应当乘以负号。

我们在实际计算中,只关心其一阶算符的矩阵形式:

\[ \nabla_{\boldsymbol{\mathscr{B}}} S_{\mu \nu}^\mathrm{GIAO} = \langle \mu | - \frac{i}{2} \boldsymbol{R}_{\mu \nu} \times \boldsymbol{r} | \nu \rangle \]

其分量形式不太容易写出,但我们下面会用程序来具体地求取其分量。

我们先使用格点积分来计算矩阵。借助 PySCF 的 DFT 格点,我们写出下述代码:

  • ni 格点积分引擎

  • grids (50, 194) 大小的格点

  • ao \(\phi_\mu (\boldsymbol{r}_g)\) 即处于格点 \(\boldsymbol{r}_g\)\(\phi_\mu\) 基轨道的函数值,维度表示为 \((g, \mu)\)

ni = dft.numint.NumInt()

grids = dft.Grids(mol)
grids.atom_grid = (50, 194)
grids.build()

ao = ni.eval_ao(mol, grids.coords)
ao.shape
(26896, 8)

数值格点积分

我们预先已经知道,第 \(\mu = 3\) 根基轨道是第 0 个原子 (N 原子),第 \(\nu = 6\) 跟轨道是第 2 个原子 (H 原子) (按照 0-index 计数),那么向量 \(\boldsymbol{R}_{\mu \nu} = \boldsymbol{R}_{36} = \boldsymbol{R}_{6} - \boldsymbol{R}_{3}\)

R_36 = (mol.atom_coord(2) - coord_orig) - (mol.atom_coord(0) - coord_orig)
R_36
array([0.18897, 0.56692, 2.83459])

上述代码中,看起来在 coord_orig 上有多余的代码;这是因为 \(\boldsymbol{R}_\mu\) 本身应当是原子核坐标相对于规范原点的距离;这里我们的规范原点恰好是原点 coord_orig

下面我们就求取积分值 \(\nabla_{\boldsymbol{\mathscr{B}}} S_{36}^\mathrm{GIAO}\)

\[ \nabla_{\boldsymbol{\mathscr{B}}} S_{36}^\mathrm{GIAO} = \int \phi_3 (\boldsymbol{r}) \left( - \frac{i}{2} \boldsymbol{R}_{36} \times \boldsymbol{r} \right) \phi_6 (\boldsymbol{r}) \, \mathrm{d} \boldsymbol{r} \simeq - \frac{i}{2} \sum_g w_g \phi_3 (\boldsymbol{r}_g) \phi_6 (\boldsymbol{r}_g) \cdot \boldsymbol{R}_{36} \times \boldsymbol{r}_g \]
- 0.5j * np.einsum("g, g, g, gr -> r", grids.weights, ao[:, 3], ao[:, 6], np.cross(R_36, grids.coords))
array([0.+0.22657j, 0.-0.j     , 0.-0.0151j ])

上述结果是一个纯虚数向量,其三个维度分别相当于 \((\mathscr{B}_x, \mathscr{B}_y, \mathscr{B}_z)\) 的维度。

最后,我们指出,在 PySCF/libcint 中,上述积分的虚部负值可以通过输入 int1e_igovlp 字符串实现。其意义可以参考 auto_intor.clREADME。留意在 libcint 中,这类积分被表示为 \(i \langle \boldsymbol{U}_\mathrm{g} \mu | \nu \rangle\),其 \(\boldsymbol{U}_\mathrm{g} = (U_\mathrm{g}^x, U_\mathrm{g}^y, U_\mathrm{g}^z)\) 的意义相当于三维向量算符

\[ \boldsymbol{U}_\mathrm{g} = - \frac{i}{2} \boldsymbol{R}_{\mu \nu} \times \boldsymbol{r} \]
mol.intor("int1e_igovlp")[:, 3, 6]
array([-0.22658, -0.     ,  0.01511])

PySCF 导出是实 (反对称) 矩阵 \(i \langle \boldsymbol{U}_\mathrm{g} \mu | \nu \rangle\);因此在实际使用该积分时,我们就需要乘以 \(- i\),让该矩阵变为复厄米矩阵,成为真正的 GIAO 变换算符一阶的矩阵形式 \(\langle \boldsymbol{U}_\mathrm{g} \mu | \nu \rangle\)

- 1j * mol.intor("int1e_igovlp")[:, 3, 6]
array([0.+0.22658j, 0.+0.j     , 0.-0.01511j])

后文我们也会用 \(\boldsymbol{U}_\mathrm{g}\) 来简化一阶导数下的 GIAO 变换算符。

当然,我们需要知道这并不是一个很严格的写法,因为 \(\boldsymbol{U}_\mathrm{g}\) 的等式右边与 \(\mu, \nu\) 有关;因此,使用这种简写时一定需要留意哪些轨道与该 GIAO 变换算符一阶导数有关。拿双电子积分 \(( U_\mathrm{g}^t \mu \lambda | \kappa \nu )\) 来说,由于 \(U_\mathrm{g}^t\) 作用在 \(\mu\) 上,并且 \(\lambda\)\(\mu\) 在积分过程中使用相同的电子坐标,因此 \(U_\mathrm{g}^t\) 的作用对象就是 \(\mu\)\(\lambda\)

从偶极积分出发给出微扰的 GIAO 重叠矩阵

实际上,

\[\begin{split} \begin{align} \nabla_{\boldsymbol{\mathscr{B}}} S_{\mu \nu}^\mathrm{GIAO} &= \langle \mu | - \frac{i}{2} \boldsymbol{R}_{\mu \nu} \times \boldsymbol{r} | \nu \rangle \\ &= - \frac{i}{2} \boldsymbol{R}_{\mu \nu} \times \langle \mu | \boldsymbol{r} | \nu \rangle \end{align} \end{split}\]

我们仍然拿 \((\mu, \nu) = (3, 6)\) 的情形讨论。我们应当注意到,\(\langle \mu | \boldsymbol{r} | \nu \rangle\) 与偶极积分形式几乎一致 (依照不同的定义方式,偶极积分可能是该值或其相反数),可以用字符串表示为 int1e_r。因此,\(\nabla_{\boldsymbol{\mathscr{B}}} S_{36}^\mathrm{GIAO}\) 还可以程序化为

- 0.5j * np.cross(R_36, mol.intor("int1e_r")[:, 3, 6])
array([0.+0.22658j, 0.-0.j     , 0.-0.01511j])

微扰的 GIAO 重叠矩阵并非“规范不变”

我们知道“规范不变”的意义是,作为最终结果的磁化率在任意的规范原点下值相同。但这并不意味着中间过程的矩阵或数值结果也相同。重叠矩阵就不满足这种“规范不变”性质:

- 1j * mol.intor("int1e_igovlp")[:, 3, 6]
array([0.+0.22658j, 0.+0.j     , 0.-0.01511j])
- 1j * mol_trans.intor("int1e_igovlp")[:, 3, 6]
array([0.-0.52235j, 0.-0.65814j, 0.+0.16645j])

PySCF 所使用的默认规范原点就是坐标系原点,因此我们这里都没有严格地使用 with mol.with_common_orig(coord_orig) 语句。当然,由于 GIAO 下的磁化率应当不受规范原点的选取变化而受到影响,因此仅从最终结果上来说,也不需要刻意地规定规范原点。

Core Hamiltonian 的程序实现#

一阶 Hamiltonian Core#

前一篇文档中,所有的一阶微扰是

\[ \hat h {}^{(1)} (\boldsymbol{\mathscr{B}}) = \frac{1}{2} \boldsymbol{\mathscr{B}} \cdot \boldsymbol{r} \times \boldsymbol{\hat{p}} \]

但当考虑到 GIAO,一阶微扰算符则应当写作

\[ \hat h {}^{(1)} (\boldsymbol{\mathscr{B}}, \boldsymbol{U}_\mathrm{g}) | \nu \rangle = \frac{1}{2} \boldsymbol{\mathscr{B}} \cdot (\boldsymbol{r} - \boldsymbol{R}_\nu) \times \boldsymbol{\hat{p}} + \boldsymbol{\mathscr{B}} \cdot \boldsymbol{U}_\mathrm{g} \hat f {}^{(0)} | \nu \rangle \]

需要注意到,这里使用到了零阶 Fock 算符 \(\hat f {}^{(0)}\),该算符需要代入未受外场微扰的密度矩阵才能获得,而不是单纯地由分子构型与基组就能决定的。该项贡献的来源是 \(\langle \Psi^{(0), \mathrm{GIAO}} | \hat H^{(0)} | \Psi^{(0), \mathrm{GIAO}} \rangle\)

因此,顺磁项对应的积分可以公式表达为

\[\begin{split} \begin{align} h_{t \mu \nu}^{(1)} &= \frac{1}{2} \langle \mu | \hat l_t | \nu \rangle_{\mathrm{Gauge} \rightarrow \boldsymbol{R}_\nu} \\ & \quad + \langle U_\mathrm{g}^t \mu | \hat t | \nu \rangle + \langle U_\mathrm{g}^t \mu | \hat v_\mathrm{nuc} | \nu \rangle \\ & \quad + \sum_{\kappa \lambda} ( U_\mathrm{g}^t \mu \nu | \kappa \lambda ) D_{\kappa \lambda}^{(0)} - \frac{1}{2} \sum_{\kappa \lambda} ( U_\mathrm{g}^t \mu \lambda | \kappa \nu ) D_{\kappa \lambda}^{(0)} - \frac{1}{2} \sum_{\kappa \lambda} ( U_\mathrm{g}^t \kappa \nu | \mu \lambda ) D_{\kappa \lambda}^{(0)} \end{align} \end{split}\]

其中,积分字符与公式表达之间的关系为

  • int1e_cg_irxp \(i \langle \mu | \hat l_t | \nu \rangle_{\mathrm{Gauge} \rightarrow \boldsymbol{R}_\nu}\)

  • int1e_igkin \(i \langle \boldsymbol{U}_\mathrm{g} \mu | \hat t | \nu \rangle\)

  • int1e_ignuc \(i \langle \boldsymbol{U}_\mathrm{g} \mu | \hat v_\mathrm{nuc} | \nu \rangle\)

  • int2e_ig1 \(i ( \boldsymbol{U}_\mathrm{g} \mu \nu | \kappa \lambda )\)

需要留意 \(i\),因为这会使得上式中很多项的正负号在编写程序时是相反的。

因此,一阶 Core Hamiltonian hcore_1 \(h_{t \mu \nu}^{(1)}\) 的表达式可以用下述程序表示:

dm_guess = mf.make_rdm1()
hcore_1 = 1j * (
    - 0.5 * mol.intor("int1e_giao_irjxp")
    - mol.intor("int1e_igkin")
    - mol.intor("int1e_ignuc")
    - np.einsum("tuvkl, kl -> tuv", mol.intor("int2e_ig1"), dm_guess)
    + 0.5 * np.einsum("tulkv, kl -> tuv", mol.intor("int2e_ig1"), dm_guess)
    + 0.5 * np.einsum("tkvul, kl -> tuv", mol.intor("int2e_ig1"), dm_guess)
)

PySCF 中有对应的函数,生成 GIAO 下的一阶 Core Hamiltonian 矩阵 (事实上上述程序块就是从下述函数中获得的):

np.allclose(
    hcore_1,
    1j * nmr.rhf.make_h10(mol, dm_guess)
)
True

最后,我们指出,关于库伦积分,实际上应当表示为

\[ h_{t \mu \nu}^{(1)} \leftarrow - i \sum_{\kappa \lambda} ( U_\mathrm{g}^t \mu \nu | \kappa \lambda ) D_{\kappa \lambda}^{(0)} - i \sum_{\kappa \lambda} ( \mu \nu | U_\mathrm{g}^t \kappa \lambda ) D_{\kappa \lambda}^{(0)} \]

但由于反对称性质 \(( \mu \nu | U_\mathrm{g}^t \kappa \lambda ) = - ( \mu \nu | U_\mathrm{g}^t \lambda \kappa )\) (反映到复数的情形其实是复共轭),导致它与对称的零阶密度矩阵 \(D_{\kappa \lambda}^{(0)}\) 相乘并求和后必为零值。

np.allclose(mol.intor("int2e_ig1"), - mol.intor("int2e_ig1").swapaxes(-3, -4))
True

在程序初步编写时,ERI 积分上很容易遇到角标顺序应该怎么写的问题。这是因为在实对称矩阵情形下,\(\sum_{\kappa \lambda} (\mu \kappa | \nu \lambda) D_{\kappa \lambda}\)\(\sum_{\kappa \lambda} (\mu \lambda | \kappa \nu) D_{\kappa \lambda}\) 是完全相同的,但当 \(\boldsymbol{U}_g\) 作用于 ERI 后,这种性质就不满足了。一个比较容易达成正确程序编写与公式推导的技巧是,保证 \(\mu, \kappa\) 处在 ERI 积分的复共轭位上,而 \(\nu, \lambda\) 处在普通位上 (或者用物理的记号来讲,当需要交换 \(\langle \mu \kappa | \nu \lambda \rangle\) 的角标时,只能在竖线左或者竖线右相互交换,不能跨线)。

二阶 Hamiltonian Core#

在前一篇文档中,所有的二阶微扰是

\[ \hat h {}^{(2)} (\boldsymbol{\mathscr{B}}) = \frac{1}{8} \big( \boldsymbol{\mathscr{B}}^2 \boldsymbol{r}^2 - (\boldsymbol{\mathscr{B}} \cdot \boldsymbol{r}) (\boldsymbol{\mathscr{B}} \cdot \boldsymbol{r})^\mathrm{T} \big) \]

但当考虑到 GIAO,二阶微扰算符则应当写作

\[\begin{split} \begin{align} \hat h {}^{(2)} (\boldsymbol{\mathscr{B}}, \boldsymbol{U}_\mathrm{g}) | \nu \rangle &= \frac{1}{8} \big( \boldsymbol{\mathscr{B}}^2 (\boldsymbol{r} - \boldsymbol{R}_\nu)^2 - \boldsymbol{\mathscr{B}}^\mathrm{T} \big( (\boldsymbol{r} - \boldsymbol{R}_\nu) (\boldsymbol{r} - \boldsymbol{R}_\nu)^\mathrm{T} \big) \boldsymbol{\mathscr{B}} | \nu \rangle \\ & \quad + \frac{i}{4} \boldsymbol{\mathscr{B}}^\mathrm{T} \big( (\boldsymbol{r} - \boldsymbol{R}_\nu) \times \boldsymbol{\hat{p}} \big) \boldsymbol{U}_\mathrm{g}^\mathrm{T} \boldsymbol{\mathscr{B}} | \nu \rangle + \frac{i}{4} \boldsymbol{\mathscr{B}}^\mathrm{T} \boldsymbol{U}_\mathrm{g} \big( (\boldsymbol{r} - \boldsymbol{R}_\nu) \times \boldsymbol{\hat{p}} \big)^\mathrm{T} \boldsymbol{\mathscr{B}} | \nu \rangle \\ & \quad + \frac{1}{2} \boldsymbol{\mathscr{B}}^\mathrm{T} \big( \boldsymbol{U}_\mathrm{g} \boldsymbol{U}_\mathrm{g}^\mathrm{T} \big) \boldsymbol{\mathscr{B}} \hat f^{(0)} | \nu \rangle \end{align} \end{split}\]

其中,\(\mathbf{T}\) 是向量算符的转置,这种转置会生成矩阵形式的磁化率。

落实到程序中,则可以写为

\[\begin{split} \begin{align} h_{ts \mu \nu}^{(2)} \cdot \mathscr{B}_t \mathscr{B}_s &= \frac{1}{8} \big( \delta_{ts} \langle \mu | x^2 + y^2 + z^2 | \nu \rangle_{\mathrm{Gauge} \rightarrow \boldsymbol{R}_\nu} - \langle \mu | ts | \nu \rangle_{\mathrm{Gauge} \rightarrow \boldsymbol{R}_\nu} \big) \\ &\quad + \frac{1}{4} \langle U_g^t \mu | \hat l_s | \nu \rangle_{\mathrm{Gauge} \rightarrow \boldsymbol{R}_\nu} + \frac{1}{4} \langle U_g^s \mu | \hat l_t | \nu \rangle_{\mathrm{Gauge} \rightarrow \boldsymbol{R}_\nu} \\ &\quad + \frac{1}{2} \langle U_g^t U_g^s \mu | \hat t | \nu \rangle + \frac{1}{2} \langle U_g^t U_g^s \mu | \hat v_\mathrm{nuc} | \nu \rangle \\ &\quad + \frac{1}{4} \sum_{\kappa \lambda} \big( (U_g^t U_g^s \mu \nu | \kappa \lambda) + (U_g^t \mu \nu | U_g^s \kappa \lambda) \big) D_{\kappa \lambda}^{(0)} + \frac{1}{4} \sum_{\kappa \lambda} \big( (U_g^t U_g^s \kappa \lambda | \mu \nu) + (U_g^t \kappa \lambda | U_g^s \mu \nu) \big) D_{\kappa \lambda}^{(0)} \\ &\quad - \frac{1}{8} \sum_{\kappa \lambda} \big( (U_g^t U_g^s \mu \lambda | \kappa \nu) + (U_g^t \mu \lambda | U_g^s \kappa \nu) \big) D_{\kappa \lambda}^{(0)} - \frac{1}{8} \sum_{\kappa \lambda} \big( (U_g^t U_g^s \kappa \nu | \mu \lambda) + (U_g^t \kappa \nu | U_g^s \mu \lambda) \big) D_{\kappa \lambda}^{(0)} \\ \end{align} \end{split}\]

其中,积分字符与公式表达之间的关系为

  • int1e_rr_origj \(\langle \mu | ts | \nu \rangle_{\mathrm{Gauge} \rightarrow \boldsymbol{R}_\nu}\)

  • int1e_grjxp \(\langle U_g^t \mu | \hat l_s | \nu \rangle_{\mathrm{Gauge} \rightarrow \boldsymbol{R}_\nu}\)

  • int1e_ggkin \(\langle U_g^t \mu | \hat t | \nu \rangle_{\mathrm{Gauge} \rightarrow \boldsymbol{R}_\nu}\)

  • int1e_ggnuc \(\langle U_g^s \mu | \hat v_\mathrm{nuc} | \nu \rangle_{\mathrm{Gauge} \rightarrow \boldsymbol{R}_\nu}\)

  • int2e_gg1 \((U_g^t U_g^s \mu \nu | \kappa \lambda)\)

  • int2e_g1g2 \((U_g^t \mu \nu | U_g^s \kappa \lambda)\)

int1e_rr_origj = mol.intor("int1e_rr_origj").reshape(3, 3, nao, nao)
int1e_grjxp = mol.intor('int1e_grjxp').reshape(3, 3, nao, nao)
int1e_ggkin = mol.intor('int1e_ggkin').reshape(3, 3, nao, nao)
int1e_ggnuc = mol.intor('int1e_ggnuc').reshape(3, 3, nao, nao)
int2e_gg1   = mol.intor("int2e_gg1")  .reshape(3, 3, nao, nao, nao, nao)
int2e_g1g2  = mol.intor("int2e_g1g2") .reshape(3, 3, nao, nao, nao, nao)
int2e_gg    = int2e_gg1 + int2e_g1g2

因此,二阶 Core Hamiltonian hcore_2 \(h_{ts \mu \nu}^{(2)}\) 的表达式可以用下述程序表示:

hcore_2 = (
    + 1/8 * (np.einsum("ts, uv -> tsuv", np.eye(3), int1e_rr_origj.diagonal(0, 0, 1).sum(-1)) - int1e_rr_origj)
    + 1/4 * mol.intor('int1e_grjxp').reshape(3, 3, nao, nao)
    + 1/4 * mol.intor('int1e_grjxp').reshape(3, 3, nao, nao).swapaxes(0, 1)
    + 1/2 * mol.intor('int1e_ggkin').reshape(3, 3, nao, nao)
    + 1/2 * mol.intor('int1e_ggnuc').reshape(3, 3, nao, nao)
    + 1/4 * np.einsum("tsuvkl, kl -> tsuv", int2e_gg, dm_guess)
    + 1/4 * np.einsum("tskluv, kl -> tsuv", int2e_gg, dm_guess)
    - 1/8 * np.einsum("tsulkv, kl -> tsuv", int2e_gg, dm_guess)
    - 1/8 * np.einsum("tskvul, kl -> tsuv", int2e_gg, dm_guess)
)

重叠矩阵的程序实现#

在 GIAO 下,除了 Hamiltonian Core 会发生改变,重叠矩阵也同样会产生变化。

一阶重叠矩阵程序中表示为

\[ S_{t \mu \nu}^{(1)} = \langle U_g^t \mu | \nu \rangle \]

二阶重叠矩阵程序中表示为

\[ S_{t \mu \nu}^{(2)} = \frac{1}{2} \langle U_g^t U_g^s \mu | \nu \rangle \]

其中,积分字符与公式表达之间的关系为

  • int1e_igovlp \(i \langle U_g^t \mu | \nu \rangle\)

  • int1e_ggovlp \(\langle U_g^t U_g^s \mu | \nu \rangle\)

ovlp_1 = - 1j * mol.intor("int1e_igovlp")
ovlp_2 = 0.5 * mol.intor('int1e_ggovlp').reshape(3, 3, mol.nao, mol.nao)

数值导数求磁化率#

与上一篇文档一样,我们已经获得了原子轨道形式的一阶、二阶 Core Hamiltonian、重叠积分。通过在 scf.RHF 的自洽场过程中重载 (override) 函数 get_hcoreget_ovlp,就可以得到受外磁场微扰的分子能量 \(E_\mathrm{tot} (\mathscr{B}_x, \mathscr{B}_y, \mathscr{B}_z)\)

dm_guess = mf.make_rdm1()

def hcore_mag_field(dev_xyz):
    mf = scf.RHF(mol)
    def get_hcore(mol_=mol):
        hcore_total  = np.asarray(scf.rhf.get_hcore(mol_), dtype=np.complex128)
        hcore_total += np.einsum("tuv, t -> uv", hcore_1, dev_xyz)
        hcore_total += np.einsum("tsuv, t, s -> uv", hcore_2, dev_xyz, dev_xyz)
        return hcore_total
    def get_ovlp(mol_):
        ovlp_total  = np.asarray(scf.rhf.get_ovlp(mol_), dtype=np.complex128)
        ovlp_total += np.einsum("tuv, t -> uv", ovlp_1, dev_xyz)
        ovlp_total += np.einsum("tsuv, t, s -> uv", ovlp_2, dev_xyz, dev_xyz)
        return ovlp_total
    mf.get_hcore = get_hcore
    mf.get_ovlp  = get_ovlp
    return mf.kernel(dm=dm_guess)

我们可以用与上一篇文档相同的代码实现二阶梯度:

\[ \xi_{ts} = - \frac{\partial^2 E_\mathrm{tot} (\boldsymbol{\mathscr{B}})}{\partial \mathscr{B}_t \partial \mathscr{B}_s} \]
eng_origin = hcore_mag_field((0, 0, 0))
interval = 1e-4
num_polar = np.zeros((3, 3))
for t in range(3):
    for s in range(3):
        if t != s:
            dev_xyzs = np.zeros((4, 3))
            dev_xyzs[0, t] = dev_xyzs[0, s] = dev_xyzs[1, t] = dev_xyzs[2, s] =  interval
            dev_xyzs[3, t] = dev_xyzs[3, s] = dev_xyzs[2, t] = dev_xyzs[1, s] = -interval
            num_polar[t, s] = (
                + hcore_mag_field(dev_xyzs[0])
                - hcore_mag_field(dev_xyzs[1])
                - hcore_mag_field(dev_xyzs[2])
                + hcore_mag_field(dev_xyzs[3])
            ) / (4 * interval**2)
        else:
            dev_xyzs = np.zeros((2, 3))
            dev_xyzs[0, t], dev_xyzs[1, t] = interval, -interval
            num_polar[t, t] = (
                + hcore_mag_field(dev_xyzs[0])
                + hcore_mag_field(dev_xyzs[1])
                - eng_origin * 2
            ) / (interval ** 2)
num_polar *= -1
num_polar
array([[-3.72496, -0.02158, -0.07515],
       [-0.02158, -2.88877,  0.20344],
       [-0.07514,  0.20345, -3.57484]])

我们最后与 PySCF 所给出的结果进行核验:

mf_mag.kernel()
array([[-3.72497, -0.02158, -0.07514],
       [-0.02158, -2.88877,  0.20345],
       [-0.07514,  0.20345, -3.57485]])

磁性质数值导数 (3):RMP2 的 GIAO 核磁 (屏蔽) 共振常数 (NMR)#

创建时间:2020-08-31;修订前的文档 对原子轨道积分的磁化率梯度叙述上有错误。

修订时间:2022-02-09

在这篇文档中,我们会讨论使用 PySCF 以及其作为 libcint 的接口,计算 GIAO 的 RHF 数值核磁 (屏蔽) 共振常数 (Nuclear Magnetic Resonance constant, NMR) 的程序。该文档大量参考 PySCF 的代码 nmr/rhf.py

该文档经过修订。之所以发现先前文档的错误,是因为读到最近的一篇数值 NMR 工作[1]。目前版本的文档应当能处理类似于 MP2、dRPA@HF、CCSD 等 Post-HF 方法。DFT 方法的 NMR 暂不在本文档的讨论范畴。

警告

该文档已经修订完毕,但使用到了 先前的一份文档 对 GIAO 的公式、记号与讨论。先前的文档还未修订完毕。

之前犯的主要问题是,在解析导数求解时,将 Fock 贡献归入 Core Hamiltonian 是程序实现上非常方便的;但原理上,Fock 贡献要拆分为单电子与双电子部分。因此,在求数值导数时,自洽场部分的双电子积分 (ERIs) 也应是受了磁场影响的。

与之前的文档一样,我们的讨论中所使用到的分子体系 mol 会是非对称的氨分子,并且取用最小基组。其 RHF 计算放在实例 mf,而 NMR 计算实例会放在 mf_nmr

准备工作#

import warnings
warnings.filterwarnings("ignore")
from pyscf import gto, scf, mp
from pyscf.prop import nmr
from pyscf.data import nist
from scipy import constants
import numpy as np

np.set_printoptions(precision=5, linewidth=150, suppress=True)
mol = gto.Mole()
mol.atom = """
N  0.  0.  0.
H  0.  1.  0.2
H  0.1 0.3 1.5
H  0.9 0.4 -.2
"""
mol.basis = "STO-3G"
mol.verbose = 0
mol.build()
coord_orig = np.zeros(3)
nocc, nao, nmo, natm = mol.nelec[0], mol.nao, mol.nao, mol.natm

其自洽场能量为

mf = scf.RHF(mol).run()
mf.e_tot
-55.253540514686556

RHF 的核磁屏蔽张量 (Shielding Constant) \(\sigma_{ts}^A\) 可以表示如下 (维度 \((A, t, s)\)):

\[ \sigma_{ts}^A = \frac{\partial^2 E_\mathrm{tot}}{\partial \mathscr{B}_t \partial \mu_{A_s}} \]
mf_nmr = nmr.RHF(mf)
mf_nmr.kernel()
array([[[305.25641,  22.67382,  12.29714],
        [-22.38193, 180.11491,  -2.84064],
        [ 13.65836, -28.07439, 383.02872]],

       [[ 25.68677,  -0.27932,  -0.31477],
        [ -0.84315,  35.70697,  -4.26016],
        [ -1.06334,   2.07184,  28.17535]],

       [[ 24.06192,  -0.12066,   0.81476],
        [  0.99821,  24.76595,  -0.86457],
        [ -0.74954,   0.2574 ,  31.65924]],

       [[ 39.48533,   4.28363,  -7.95068],
        [  0.89957,  26.69818,  -0.9659 ],
        [ -4.40617,  -0.48147,  28.90276]]])

上述结果是以 ppm 为单位的表达。不过我们在这篇文档打算考虑 RMP2 的核磁计算;这里的 RHF NMR 张量只是用于演示而已。

微扰原子矩阵的表达#

外磁场微扰的一阶 Core Hamiltonian#

关于该微扰矩阵,即是统合磁效应在 Core Hamiltonian 的作用、以及 GIAO 轨道对动能与核静电势能的一阶贡献考虑进来:

\[ \begin{align*} h_{\mu \nu}^{\mathscr{B}_t} = \frac{1}{2} \langle \mu | \hat l_t | \nu \rangle_{\mathrm{Gauge} \rightarrow \boldsymbol{R}_\nu} + \langle U_\mathrm{g}^t \mu | \hat t | \nu \rangle + \langle U_\mathrm{g}^t \mu | \hat v_\mathrm{nuc} | \nu \rangle \end{align*} \]
hcore_1_B = - 1j * (
    + 0.5 * mol.intor('int1e_giao_irjxp', 3)
    + mol.intor('int1e_ignuc', 3)
    + mol.intor('int1e_igkin', 3))

外磁场微扰的一阶重叠矩阵#

\[ S_{\mu \nu}^{\mathscr{B}_t} = \langle U_\mathrm{g}^t \mu | \nu \rangle \]

我们用下述代码生成 ovlp_1_B \(S_{\mu \nu}^{\mathscr{B}_t}\)

ovlp_1_B = - 1j * mol.intor("int1e_igovlp")

外磁场微扰的一阶 ERI 张量#

\[ (\mu \nu | \kappa \lambda)^{\mathscr{B}_t} = (U_\mathrm{g}^t \mu \nu | \kappa \lambda) + (\mu \nu | U_\mathrm{g}^t \kappa \lambda) \]

我们用下述代码生成 eri_1_B \((\mu \nu | \kappa \lambda)^{\mathscr{B}_t}\)

eri_1_B = -1j * (
    + np.einsum("tuvkl -> tuvkl", mol.intor('int2e_ig1'))
    + np.einsum("tkluv -> tuvkl", mol.intor('int2e_ig1')))

核磁偶极的一阶 Core Hamiltonian#

核磁偶极 \(\mu_{A_t}\) 所产生的一阶算符贡献可以表达为

\[ \hat h {}^{(1)} (\boldsymbol{\mu}_A) = - i \alpha^2 \boldsymbol{\mu}_A \cdot \left( \boldsymbol{\nabla} \frac{1}{\boldsymbol{r - \boldsymbol{R}_A}} \times \boldsymbol{\nabla} \right) = - i \alpha^2 \boldsymbol{\mu}_A \cdot \left( \frac{\boldsymbol{r} - \boldsymbol{R}_A}{|\boldsymbol{r} - \boldsymbol{R}_A|^3} \times \boldsymbol{\nabla} \right) \]

其中,\(\boldsymbol{R}_A\) 表示原子 \(A\) 的核坐标,\(\alpha\) 表示精细结构常数,\(1/\alpha \simeq 137\)。该常数可以从 PySCF 中获得,也可以从 SciPy 中获得。注意等式左边的 \(\boldsymbol{\mu_A}\) 看作是外加的核磁偶极大小,而等号右边的 \(\mu\) 表示原子轨道,两者意义不同;等号左边角标 \(\boldsymbol{A}\) 表示原子核坐标向量,而之前两篇文档中的 \(A\) 在很多文章或教材中表示 \(\frac{1}{2} \boldsymbol{B} \times \boldsymbol{r}\)

1 / constants.alpha
137.0359990836958

但为了方便,在最后核算结果之前,我们会暂且将 \(\alpha\) 当作 1 来处理。

在 PySCF 中,实现上述过程的积分字符是 int1e_ia01p;但其使用需要告知 gto.mole.intor 函数以其 \(1 / \boldsymbol{r}\) 的规范原点位置具体处在哪个原子核中心。

\[ h_{\mu \nu}^{\mu_{A_t}} = - i \langle \mu | \boldsymbol{\nabla} \frac{1}{\boldsymbol{r}} \times \boldsymbol{\nabla} | \nu \rangle_{\text{Gauge of } \boldsymbol{r} \rightarrow \boldsymbol{R}_A} \]

我们用下述代码生成 hcore_1_m \(h_{\mu \nu}^{\mu_{A_t}}\):(维度为 \((A, t, \mu, \nu)\))

hcore_1_m = np.zeros((natm, 3, nao, nao), dtype=np.complex128)
for atom_idx in range(natm):
    with mol.with_rinv_orig(mol.atom_coord(atom_idx)):
        hcore_1_m[atom_idx] = - 1j * mol.intor("int1e_ia01p")

磁场与核磁偶极的二阶 Core Hamiltonian#

磁场与核磁偶极之间的算符乘积会产生二阶算符贡献项:

\[\begin{split} \begin{align} \hat h {}^{(2)} (\boldsymbol{\mathscr{B}}, \boldsymbol{\mu}_A) | \nu \rangle &= \frac{\alpha^2}{2} \boldsymbol{\mathscr{B}}^\mathrm{T} \left( (\boldsymbol{r} - \boldsymbol{R}_\nu) \cdot \boldsymbol{\nabla} \frac{1}{\boldsymbol{r} - \boldsymbol{R}_A} - (\boldsymbol{r} - \boldsymbol{R}_\nu) \boldsymbol{\nabla} \frac{1}{(\boldsymbol{r} - \boldsymbol{R}_A)^\mathrm{T}} \right) \boldsymbol{\mu}_A | \nu \rangle \\ &\quad + \alpha^2 \boldsymbol{\mathscr{B}}^\mathrm{T} \boldsymbol{U}_\mathrm{g} \left( \boldsymbol{\nabla} \frac{1}{\boldsymbol{r} - \boldsymbol{R}_A} \times \boldsymbol{\hat p} \right)^\mathrm{T} \boldsymbol{\mu}_A | \nu \rangle \end{align} \end{split}\]

去除精细结构常数 \(\alpha\) 的贡献后,其矩阵的表达形式则是

\[\begin{split} \begin{align} h_{\mu \nu}^{A_s t} \mathscr{B}_t \mu_{A_s} &= \langle \mu | - \frac{1}{2} \frac{(t - t_\nu) (s - s_A)}{|\boldsymbol{r} - \boldsymbol{R}_A|^3} | \nu \rangle - \delta_{ts} \langle \mu | - \frac{1}{2} \sum_{w} \frac{(w - w_\nu) (w - w_A)}{|\boldsymbol{r} - \boldsymbol{R}_A|^3} | \nu \rangle \\ &\quad + \langle U_\mathrm{g}^t \mu | \left( \boldsymbol{\nabla} \frac{1}{\boldsymbol{r} - \boldsymbol{R}_A} \times \boldsymbol{\hat p} \right)_s | \nu \rangle \end{align} \end{split}\]

我们用下述代码生成 hcore_2 \(h_{\mu \nu}^{A_s t}\):(维度为 \((A, t, s, \mu, \nu)\),注意维度 \(t\) 对应外磁场,而 \(A, s\) 对应核磁偶极)

hcore_2 = np.zeros((natm, 3, 3, nao, nao))
for atom_idx in range(natm):
    with mol.with_rinv_origin(mol.atom_coord(atom_idx)):
        hcore_2[atom_idx] += mol.intor("int1e_giao_a11part").reshape((3, 3, nao, nao))
        hcore_2[atom_idx] -= np.einsum("ts, uv -> tsuv", np.eye(3), mol.intor("int1e_giao_a11part").reshape((3, 3, nao, nao)).trace(axis1=0, axis2=1))
        hcore_2[atom_idx] += mol.intor("int1e_a01gp").reshape((3, 3, nao, nao))

数值导数求 RMP2 NMR 核磁屏蔽张量#

随后我们就可以通过数值梯度求核磁屏蔽张量 \(\sigma_{ts}^A\) (维度 \((A, t, s)\)):

\[ \sigma_{ts}^A = \frac{\partial^2 E_\mathrm{tot}}{\partial \mathscr{B}_t \partial \mu_{A_s}} \]

在此之前,我们仍然需要构造一个通过更改 get_hcore Core Hamiltonian、get_ovlp 重叠矩阵、_eri 双电子积分的 PySCF 自洽场实例,以施加外场获得能量的函数 eng_nmr_field。其输入的参数 dev_xyz_B 是三维外加磁场大小 (对应维度 \(t\)),dev_xyz_m 是三维外加核磁偶极大小 (对应维度 \(s\)),atom_idx 是原子序号 (对应维度 \(A\))。

\[\begin{split} \begin{align} h_{\mu \nu} &= h_{\mu \nu}^{(0)} + \mathscr{B}_t h_{\mu \nu}^{\mathscr{B}_t} + \mu_{A_s} h_{\mu \nu}^{\mu_{A_s}} + \mathscr{B}_t \mu_{A_s} h_{\mu \nu}^{\mathscr{B}_t \mu_{A_s}} + o(\mathscr{B}_t \mu_{A_s}) \\ S_{\mu \nu} &= S_{\mu \nu}^{(0)} + \mathscr{B}_t S_{\mu \nu}^{\mathscr{B}_t} + o(\mathscr{B}_t) \\ (\mu \nu | \kappa \lambda) &= (\mu \nu | \kappa \lambda)^{(0)} + (\mu \nu | \kappa \lambda)^{\mathscr{B}_t} + o(\mathscr{B}_t) \end{align} \end{split}\]

在跑完自洽场后,立即简单地执行 MP2,就可以得到受外磁场与核磁偶极扰动的 MP2 能量了。

def eng_nmr_field(dev_xyz_B, dev_xyz_m, atom_idx):
    mf = scf.RHF(mol)
    def get_hcore(mol_=mol):
        hcore_total  = np.asarray(scf.rhf.get_hcore(mol_), dtype=np.complex128)
        hcore_total += np.einsum("tuv, t -> uv", hcore_1_B, dev_xyz_B)
        hcore_total += np.einsum("tuv, t -> uv", hcore_1_m[atom_idx], dev_xyz_m)
        hcore_total += np.einsum("tsuv, t, s -> uv", hcore_2[atom_idx], dev_xyz_B, dev_xyz_m)
        return hcore_total
    def get_ovlp(mol_):
        ovlp_total  = np.asarray(scf.rhf.get_ovlp(mol_), dtype=np.complex128)
        ovlp_total += np.einsum("tuv, t -> uv", ovlp_1_B, dev_xyz_B)
        return ovlp_total
    mf.get_hcore = get_hcore
    mf.get_ovlp  = get_ovlp
    mf._eri = mol.intor("int2e") + np.einsum("tuvkl, t -> uvkl", eri_1_B, dev_xyz_B)
    mf.run()
    mf_mp = mp.MP2(mf).run()
    return mf_mp.e_tot

随后使用与前文类似的数值梯度方式就能得到核磁屏蔽常数了;所使用的数值差分大小对于外磁场与核磁偶极均为 \(3 \times 10^{-4}\) a.u.。

interval = 1e-4
num_nmr = np.zeros((natm, 3, 3))
for atom_idx in range(natm):
    for t in range(3):
        for s in range(3):
            dev_xyzs_B, dev_xyzs_m = np.zeros((2, 3)), np.zeros((2, 3))
            dev_xyzs_B[0, t] = dev_xyzs_m[0, s] = -interval
            dev_xyzs_B[1, t] = dev_xyzs_m[1, s] =  interval
            num_nmr[atom_idx, t, s] = (
                + eng_nmr_field(dev_xyzs_B[0], dev_xyzs_m[0], atom_idx)
                - eng_nmr_field(dev_xyzs_B[1], dev_xyzs_m[0], atom_idx)
                - eng_nmr_field(dev_xyzs_B[0], dev_xyzs_m[1], atom_idx)
                + eng_nmr_field(dev_xyzs_B[1], dev_xyzs_m[1], atom_idx)
            ) / (4 * interval**2)
num_nmr
array([[[ 5.63279,  0.41743,  0.26518],
        [-0.11122,  3.81459, -0.13199],
        [ 0.38918, -0.53118,  7.02831]],

       [[ 0.47397, -0.00892,  0.00238],
        [-0.02979,  0.68584, -0.06142],
        [-0.0151 ,  0.0332 ,  0.52897]],

       [[ 0.44192, -0.0033 ,  0.01888],
        [ 0.0113 ,  0.45509, -0.01335],
        [-0.01723,  0.00653,  0.59529]],

       [[ 0.73128,  0.08291, -0.13904],
        [ 0.03982,  0.50304, -0.0239 ],
        [-0.06955, -0.01146,  0.54013]]])

留意到我们之前一直都使用去除结构精细常数的结果,因此我们需要乘以 \(\alpha^2\)。同时由于单位是 ppm,因此最终我们需要乘以 \(10^6 \alpha^2\)

num_nmr_ppm = num_nmr * constants.alpha**2 * 10**6
num_nmr_ppm
array([[[299.95362,  22.2286 ,  14.12107],
        [ -5.92259, 203.13221,  -7.02871],
        [ 20.72447, -28.28604, 374.26729]],

       [[ 25.2397 ,  -0.47482,   0.12685],
        [ -1.58641,  36.52216,  -3.27057],
        [ -0.80421,   1.76797,  28.16842]],

       [[ 23.53299,  -0.17577,   1.00526],
        [  0.60148,  24.23406,  -0.71087],
        [ -0.91735,   0.3478 ,  31.69988]],

       [[ 38.9417 ,   4.41492,  -7.40411],
        [  2.12046,  26.78774,  -1.27294],
        [ -3.70343,  -0.61013,  28.76271]]])

RMP2 NMR 核磁屏蔽常数#

最终可以用于汇报的核磁屏蔽常数需要经过 \(3 \times 3\) 矩阵对角化给出。对于其中一个原子而言,其核磁屏蔽张量可以表示为

\[\begin{split} \boldsymbol{\sigma} = \begin{pmatrix} \sigma_{xx} & \sigma_{xy} & \sigma_{xz} \\ \sigma_{yx} & \sigma_{yy} & \sigma_{yz} \\ \sigma_{zx} & \sigma_{zy} & \sigma_{zz} \end{pmatrix} = X \begin{pmatrix} \sigma_{xx}' & 0 & 0 \\ 0 & \sigma_{yy}' & 0 \\ 0 & 0 & \sigma_{zz}' \end{pmatrix} X^\dagger \end{split}\]

其中,\((\sigma_{xx}', \sigma_{yy}', \sigma_{zz}')\) 是屏蔽张量的本征值,不随规范原点变化或坐标旋转而变化;而屏蔽张量 \(\boldsymbol{\sigma}\) 本身是随规范原点或坐标旋转变化而可以改变的。因此,汇报 NMR 数据时,应当要汇报与本征值有关的结果。

一般的 NMR 谱会打出同性核磁屏蔽常数 \(\sigma_\text{iso} = \frac{1}{3} (\sigma_{xx}' + \sigma_{yy}' + \sigma_{zz}')\)。有时异性屏蔽常数 \(\sigma_\text{aniso} = \sigma_{zz}' - \frac{1}{2} (\sigma_{xx}' + \sigma_{yy}')\) 也会使用到[2]。我们可以用下面的程序,对 MP2/STO-3G 的 NMR 屏蔽常数与Gaussian 结果 作对照。

# Gaussian results
! grep "Isotropic" NH3-nmr.out | tail -n 4
      1  N    Isotropic =   292.5252   Anisotropy =   130.4969
      2  H    Isotropic =    29.9780   Anisotropy =    10.0555
      3  H    Isotropic =    26.4878   Anisotropy =     7.8257
      4  H    Isotropic =    31.4998   Anisotropy =    15.9451
def proc_nmr_tensor(tsr):
    tsr = (tsr + tsr.T) / 2
    eigs = np.linalg.eigvalsh(tsr)
    eigs.sort()
    nmr_iso = eigs.sum() / 3
    nmr_ani = eigs[2] - (eigs[0] + eigs[1]) / 2
    return nmr_iso, nmr_ani
# Numerical derivative results
for tsr in num_nmr_ppm:
    print("Isotropic = {:8.4f}  Anisotropy = {:8.4f}".format(*proc_nmr_tensor(tsr)))
Isotropic = 292.4510  Anisotropy = 130.5998
Isotropic =  29.9768  Anisotropy =  10.0491
Isotropic =  26.4890  Anisotropy =   7.8233
Isotropic =  31.4974  Anisotropy =  15.9435

Autograd (1):PyTorch 自动一阶求导在标量、向量、矩阵、张量运算中的定义#

创建时间:2019-12-18

这一份文档中,我们将尝试理解 PyTorch 的自动求导的一些结论。

我们将不深究 PyTorch 的求导过程与程序问题,比如叶节点或中间矩阵导数等。我们只是探究不同的矩阵运算下自动求导的结论。

同时,我们也只讨论一阶导数的自动求导。一般的机器学习任务也只关心一阶导数。但在譬如分子力场等学习目标就包含了一阶导数的应用中,二阶导数可能是有意义的。二阶或高阶函数的求导在 PyTorch 中是存在的 (参考 Stackoverflow 问答 [1]),关于这部分探讨可能会放在以后的文档。

import torch
import numpy as np

torch.random.manual_seed(0)
torch.set_printoptions(precision=5, sci_mode=False, linewidth=120)

默认签名更改

这篇文档中,由于我们会经常地在求取自变量梯度后再次使用 backward 函数;为了避免程序的复杂性,我们会将 retain_graph 可选参数设为 True

torch.Tensor.backward
<function torch.tensor.Tensor.backward(self, gradient=None, retain_graph=None, create_graph=False)>
torch.Tensor.backward.__defaults__ = (None, True, False)

一元函数的自动求导#

谈及导数,最容易想到的是一元函数的导数。举例来说现在我们定义函数

\[ y (b) = b^3 + 10 \exp \left( - \frac{b^2}{10} \right) \]

\(b = 3\) 时,\(y \simeq 31.07\)

b = torch.tensor(3., requires_grad=True)
y = b**3 + 10 * torch.exp(-b**2 / 10)
y
tensor(31.06570, grad_fn=<AddBackward0>)

利用求导法则,我们会很容易地知道,

\[ y^b = \frac{\partial y}{\partial b} = 3 b^2 - 2 b \exp \left( - \frac{b^2}{10} \right) \]

\(b = 3\) 时,\(y^b \simeq 24.56\)

符号定义

这篇文档中,定义 \(b, \boldsymbol{b}, \textbf{B}\) 分别为标量、向量、多维张量自变量;\(\boldsymbol{a}, \textbf{A}\) 分别为向量、矩阵常量。\(y, \boldsymbol{y}, \textbf{Y}\) 为表因变量 (函数),\(z\) 为实际的标量因变量 (函数)。

作为张量分量的下角标统一采用 \(i, j, k, l, \cdots\)

在这篇文档中,导数用类似于上述上标 \(y^b\) 的方式来简写定义。这样的定义方式可能是不常规或不合适的,但在后面定义矩阵导数、查看矩阵维度时多少会有方便之处。

float(3 * b**2 - 2 * b * torch.exp(-b**2 / 10))
24.56058120727539

写出上面一行代码显然对我们不仅仅有着脚本技工的要求,还有着初级的微积分的能力;除此之外,这种代码的可移植性差,若是换一个函数就需要重写一行导数代码。

当然,我们也可以用数值的方式求取导数。这样至少能保证代码的可移植性:

def num_deriv_3p_stencil(func, var, interval):
    return (func(var + interval) - func(var - interval)) / (2 * interval)

y_func = lambda b: b**3 + 10 * np.exp(-b**2 / 10)
num_deriv_3p_stencil(y_func, 3, 1e-6)
24.560582046362356

但 PyTorch 作为包含了自动求导这个强大工具的程序库,我们不一定需要手动或数值地求取导数就可以给出解析的导数结果:

b.grad = None
y.backward()
b.grad
tensor(24.56058)

上面代码输出的精度可能不算太高。如果我们显示更多小数位数,我们会发现其精度比数值导数的精度高出很多:

float(b.grad)
24.56058120727539

关于数值导数的精度,读者可以尝试调整各种 interval 的数值;但不论多少 interval,误差总在 1e-7 或更高。

关于自动求导,需要补充的是,其中的 y.backward 允许引入一个与 y 相同维度的张量 (在这个例子中,y 是一个标量)。这个张量在函数的签名中是 gradient,其意义相当于是规定作为参数 \(b\) 的导数方向性。

拿现在具体的例子而言,我们认为 \(y\) 变量也是具有导数的,其导数定义为

\[ z^y = \frac{\partial z}{\partial y} \]

其中,\(z\) 是一个标量,它代表真正用于计算的损失函数。尽管我们也称 \(y\) 是损失函数;若梯度 \(y^b = 0\) 时损失函数也确实降到了最低值 (若损失函数是凸的);但实际程序中并不是拿 \(y^b\) 计算,而是使用

\[ z^b = \frac{\partial z}{\partial b} = \frac{\partial z}{\partial y} \frac{\partial y}{\partial b} = z^y y^b \]

来作为真正的导数 b.grad。之所以上面我们安全地说 \(y_b \simeq 24.56\) 是因为在 PyTorch 中,\(y\) 维度为 1 时的 \(z^y\) 默认值就是 1。但若我们打算自己定义 \(z^y = 10\),那么可以预期给出的 b.grad 的值为

\[ z^b = z^y y^b \simeq 10 \times 24.56 = 245.6 \]
b.grad = None
gy = torch.tensor(10.)
y.backward(gradient=gy)
b.grad
tensor(245.60582)

定义 gy \(z^y\) 看起来似乎不是很重要,在实际机器学习的训练中这与定义学习率的效果应当会是等价的。但在下述两个问题中,定义 \(z^y\) 可能有着它的意义:

  • \(z^y\) 若恰好是 \(y^{bb} = \frac{\partial^2 y}{\partial b^2}\),那么 \(z^b\) 可以看作是凸优化区域中牛顿法数值迭代的 \(b\) 的方向。这应当比单纯的梯度下降的效率高不少。关于这一点的说明与应用可能会在以后的二阶自动求导的文档中给出。

  • \(y, b\) 不再是一元的标量,那么从程序的角度出发必须要求 \(z^y\) 的定义。关于这一点后文马上就会说明。

向量点积的自动求导#

现在我们将 \(\boldsymbol{a}, \boldsymbol{b}\) 当作向量。

b = torch.randn(3, requires_grad=True)
b
tensor([ 1.54100, -0.29343, -2.17879], requires_grad=True)
a = torch.randn(3)
a
tensor([ 0.56843, -1.08452, -1.39860])

我们定义向量点积 \(y = \boldsymbol{a} \boldsymbol{b}\) (由于这里不涉及矩阵,因此就没有明确写出转置),或更清晰地,

\[ y = \sum_{i} a_i b_i \]

其中,\(i\) 取值范围在 0-2 之间。

y = a @ b
y
tensor(4.24143, grad_fn=<DotBackward>)

向量点积的导数是非常容易给出的,这可以表现为向量表示

\[ \frac{\partial y}{\partial \boldsymbol{b}} = \boldsymbol{a} \]

也可以表现为标量表示

\[ y^{b_i} = \frac{\partial y}{\partial b_i} = a_i \]
y.backward()
b.grad
tensor([ 0.56843, -1.08452, -1.39860])

关于向量或矩阵的微分问题,可以参考 ^Matrix Calculus 网站的结果。譬如对于当前的 \(y = \boldsymbol{a} \boldsymbol{b}\) 关于向量 \(\boldsymbol{b}\) 的导数,可以使用下述的表达式:

derivative of a' * b w.r.t. b

应当能很容易地发现其导数就是 \(\boldsymbol{a}\)

上述的讨论中,我们始终假定了 \(z^y = 1\)

矩阵-向量乘积的自动求导#

下面我们讨论更为广泛的问题。对于矩阵 \(\mathbf{A}\) 与向量 \(\boldsymbol{b}\) 的乘积

\[ \boldsymbol{y} = \mathbf{A} \boldsymbol{b} \]

或者更详细地,

\[ y_i = \sum_{j} A_{ij} b_j \]
A = torch.randn(4, 3)
A
tensor([[ 0.40335,  0.83803, -0.71926],
        [-0.40334, -0.59664,  0.18204],
        [-0.85667,  1.10060, -1.07119],
        [ 0.12270, -0.56632,  0.37311]])
b = torch.randn(3, 1, requires_grad=True)
b
tensor([[-0.89200],
        [-1.50911],
        [ 0.37039]], requires_grad=True)
y = A @ b
y.retain_grad()
y
tensor([[-1.89086],
        [ 1.32759],
        [-1.29354],
        [ 0.88338]], grad_fn=<MmBackward>)

这个时候我们可能会遇到两个理解上的困境:到底导数要如何计算?程序要如何实现?

关于导数要如何计算,根据 Matrix Calculus 网站的结果,

\[ \frac{\partial \boldsymbol{y}}{\partial \boldsymbol{b}} = \mathbf{A} \]

或者更详细地,

\[ y_i^{b_j} = \frac{\partial y_i}{\partial b_j} = A_{ij} \]

这样的结论是比较容易理解的。

但从程序的角度上,既然求自动导数的目的是给 \(\boldsymbol{b}\) 向量 (作为自变量) 以一个下降的方向,让 \(\boldsymbol{y}\) 向量 (作为因变量) 的值变小,那么这个梯度量 b.grad 应当与 \(\boldsymbol{b}\) b 的维度相等才是。显然,\(\mathbf{A}\) 从维度上就已经不可能是一个合理的 b.grad

不仅如此,PyTorch 不允许不加假定地使用 y.backward

try:
    y.backward()
except RuntimeError as e:
    print("\033[31mRuntimeError: \033[0m" + "".join(e.args))
RuntimeError: grad can be implicitly created only for scalar outputs

事实上,解决方案是需要引入关于 \(\boldsymbol{y}\) 的偏导数 \(z^{y_i} = \frac{\partial z}{\partial y_i}\);需要注意 \(z\) 始终是一个标量。因此,真正作为 b.grad 的量并非是 \(y_i^{b_j}\),而是 \(z^{b_j}\)

\[ z^{b_j} = \frac{\partial z}{\partial b_j} = \sum_i \frac{\partial z}{\partial y_i} \frac{\partial y_i}{\partial b_j} = \sum_i z^{y_i} y_i^{b_j} = \sum_i z^{y_i} A_{ij} \]

但这就给我们了自由发挥的空间了。如果我们对 \(z^{y_i}\) 给出一个随机数组 gy

gy = torch.randn_like(y)

我们应当可以验证,b.grad 作为 \(z^{b_j}\) 确实满足上述的表达式:

b.grad = None
y.backward(gy)
torch.allclose(b.grad, A.T @ gy)
True

但下一个问题会是,在通常的机器学习的任务中,gy \(z^{y_i}\) 一般应当要如何选取?

这里举的一个例子是 L1 范数误差。如果我们假设向量 \(\boldsymbol{y}\) 的目标 (target) 是零值,那么作为标量的误差函数 z \(z\) 可以写作

\[ z = \underset{i}{\mathrm{avg}} \left\Vert \sum_j A_{ij} b_j \right\Vert_1 = \frac{1}{\dim(i)} \sum_i \left\Vert \sum_j A_{ij} b_j \right\Vert_1 \]
z = torch.nn.L1Loss()(A @ b, torch.zeros(A.shape[0], 1))
z
tensor(1.34885, grad_fn=<L1LossBackward>)

我们可以很容易地用程序对其求导:

b.grad = None
z.backward()
b.grad
tensor([[ 0.04317],
        [-0.77540],
        [ 0.58640]])

对于上式,可以知道此时 gy

\[ z^{y_i} = \frac{1}{\dim(i)} \mathrm{sgn} \left( \sum_j A_{ij} b_j \right) \]
gy = y.sign() / y.shape[0]
gy
tensor([[-0.25000],
        [ 0.25000],
        [-0.25000],
        [ 0.25000]], grad_fn=<DivBackward0>)

我们可以用上述的 gy 给出与 torch.nn.L1Loss 一样的反向传播的效果:

b.grad = None
y.backward(gy)
b.grad
tensor([[ 0.04317],
        [-0.77540],
        [ 0.58640]])

关于 gy \(z^{y_i}\) 在程序实现上的意义就在这里表述结束了。

总结来说,狭义上的 \(y_i\)\(b_j\) 的导数 \(y_i^{b_j}\) 应当是一个二维矩阵 (正如符号 \(y_i^{b_j}\) 具有双下标 \(i, j\) 这个事实),且该矩阵为 \(A_{ij}\)。对于 PyTorch 的 Autograd 而言,一般来说,表达式 \(y_i^{b_j}\) 通常是不会求取的;而真正被求取的量是在给定 \(z\)\(y_i\) 的导数 \(z^{y_i}\) 下,\(z\)\(b_j\) 的导数 \(z^{b_j}\)\(z^{b_j}\) 的导数与 \(b_j\) 作为向量的维度相同且向量长度为 \(\dim(j)\)

尽管 \(y_i^{b_j} = A_{ij}\) 通常情况下是不会被直接求取的,但是否真的意味着无法被求取?答案是否定的。对于给定的整数 \(i_0\) 且满足 \(0 \leqslant i_0 < \dim(i)\),我们若令 \(z^{y_i} = \delta_{ii_0}\),那么

\[ z^{b_j} = \sum_{i} z^{y_i} y_i^{b_j} = \sum_{i} \delta_{ii_0} A_{ij} = A_{i_0 j} \]

这意味着当 \(z^{y_i} = \delta_{ii_0}\) 时,我们计算得到的 b.grad \(z^{b_j}\) 会返回矩阵 \(\mathbf{A}\) 的第 \(i_0\) 行。如此往复,就可以通过 b.grad 的堆叠矩阵 gbs 反推出整个 \(\mathbf{A}\) 矩阵 A

def gy_to_bgrad(gy):
    b.grad = None
    y.backward(gy)
    return b.grad
gy_list = torch.eye(y.shape[0], dtype=torch.float)[:, :, None]
gbs = torch.stack([gy_to_bgrad(gy).flatten() for gy in gy_list])
gbs
tensor([[ 0.40335,  0.83803, -0.71926],
        [-0.40334, -0.59664,  0.18204],
        [-0.85667,  1.10060, -1.07119],
        [ 0.12270, -0.56632,  0.37311]])
torch.allclose(gbs, A)
True

任意张量的自动求导#

有了上面的讨论,我们可以尝试对任意张量的缩并过程进行导数求取,并与 PyTorch 的 Autograd 进行对比。

下面的例子是

\[ Y_{ilm} = \sum_{jk} A_{ijkl} B_{jklm} \]

其中,\(i, j, k, l, m\) 指代的维度分别为 \(3, 4, 5, 6, 7\)

A = torch.randn(3, 4, 5, 6)
B = torch.randn(4, 5, 6, 7, requires_grad=True)
Y = torch.einsum("ijkl, jklm -> ilm", A, B)
Y.shape
torch.Size([3, 6, 7])

如果现在我们定义 \(z^{Y_{ilm}} = \frac{\partial z}{\partial Y_{ilm}}\) gY 为任意的与 \(Y_{ilm}\) 相同维度的张量;以及通过其进行 Autograd 导出的 \(z^{B_{jklm}} = \frac{\partial z}{\partial B_{jklm}}\)gB

gY = torch.randn_like(Y)
Y.backward(gY)
gB = B.grad
gB.shape
torch.Size([4, 5, 6, 7])

那么 \(z^{B_{jklm}}\) 可以展示为下述表达式:

\[ z^{B_{jklm}} = \sum_{i} A_{ijkl} z^{Y_{ilm}} \]
torch.allclose(
    torch.einsum("ijkl, ilm -> jklm", A, gY),
    gB
)
True

尽管我们最后并没有拓展到任意的张量的自动求导过程;但上面的这个例子相信已经具有足够的代表性了。依据上述的例子,也应当可以很容易地退化到普通矩阵、向量乃至标量的自动求导问题。


Autograd (2):深度前馈网络——前向传播与反向传播#

创建时间:2019-12-20

这一份文档将会回顾比较多的内容。

  • 第一段中,我们会使用以向量模长为学习目标的模型训练过程,来作最为简单的深度网络模型:多层感知的学习与热身。

  • 第二段中,我们会了解网络中的参数如何调取与浏览,并且解释多层感知模型本质上几乎就是普通的矩阵与向量运算,学习前向传播过程。

  • 网络参数导数是模型训练过程中非常重要的参考。第三段中,我们会了解网络参数关于损失函数的导数,学习反向传播过程。

import torch
import torch.nn as nn
import numpy as np

from IPython.display import Image

torch.set_printoptions(precision=5, sci_mode=False, linewidth=120)
torch.Tensor.backward.__defaults__ = (None, True, False)

PyTorch 多层感知 (MLP) 学习向量模长的简易例子#

小样本学习#

相信绝大多数脚本砖工在入门深度学习的时候,都是拿一些现实的问题,譬如 MNIST 数据集或 Iris 数据集来作训练。这可以加深砖工们学习的积极性与目的性,训练的结果也确实可以有效地泛化 (不严格地说,泛化类似于实用化),固然不错。

但这里尝试用另一种方式来作入门级说明。我们拿一种事实上不太适合深度学习的问题来举例,不管是从问题的意义上还是实用性的角度上。这个问题是用多层感知 (multi-layer perceptron) 模型,学习固定长度 (5 长度) 向量的模长。但这样一个问题非常容易用数学的方式作定义,其训练集与学习目标都是完全可重复、不需要任何先验的知识就可以构建;并且不需要脚本砖工最关心和头疼的数据集处理 (特征向量处理) 的过程。

在 PyTorch 中,这样一个简单的模型 model 可以通过下述简单的 layer 堆叠得到:

torch.random.manual_seed(0)
model = nn.Sequential(nn.Linear(5, 4), nn.ReLU(), nn.Linear(4, 3), nn.ReLU(), nn.Linear(3, 1))
model
Sequential(
  (0): Linear(in_features=5, out_features=4, bias=True)
  (1): ReLU()
  (2): Linear(in_features=4, out_features=3, bias=True)
  (3): ReLU()
  (4): Linear(in_features=3, out_features=1, bias=True)
)

关于上述每一段代码的意义,后文会作更详细的描述。下面的图片就是对上述模型的一个简单描述:

Image(filename="assets/vector_to_norm.png", width=515)
_images/e067b35f9a423cac623e811b1f9279aeb88a1088649fee3f40a082532056dcde.png

因此,上述网络的输入是 5 长度的向量,通过 MLP 过程,即两层神经网络层 (线性层与激活层的叠加) 与一层线性层后,得到长度为 1 的向量模长。第一层的神经元数量为 4,第二层则为 3。

网络构建完毕后,我们可以构建用于训练的数据集 X

  • X 的第一维度为 6,意指总共有 6 个数据点用于训练;

  • X 的第二维度为 5,意指作为特征 (Feature) 或者说数据点的向量长度为 5。

X = torch.randn(6, 5)
X
tensor([[ 0.25963, -0.17396, -0.67875,  0.93826,  0.48887],
        [-0.67309,  0.87283,  1.05536, -0.00479, -0.51807],
        [-0.30670, -1.58099,  1.70664, -0.44622,  2.08196],
        [ 1.70671,  2.38037, -1.12560, -0.31700, -1.09247],
        [-0.08519, -0.09335, -0.76071, -1.59908,  0.01849],
        [-0.75043,  0.18541,  0.62114,  0.63818, -0.24600]])

我们可以很容易地构建该数据集下的真实值 t。真实值就是对数据集 X 表示数据点的维度 (第二维度) 取模长就得到:

t = X.norm(dim=1, keepdim=True)
t
tensor([[1.29526],
        [1.61155],
        [3.16858],
        [3.33766],
        [1.77540],
        [1.20463]])

在当前简单的问题中,MLP 的意义就是一个拟合映射:

\[ \mathbf{X} \xrightarrow{\text{MLP}} \mathbf{y} \]

我们定义 y 是 MLP 模型下,输入数据 X 给出的结果。

我们会希望在参数学习的过程中,y 渐渐地接近真实结果 t。但在完全没有进行训练的状况下,我们应该会预期这两者的差距会相当大 (我们暂时拿 L1 损失函数来衡量误差):

y = model(X)
float(nn.L1Loss()(y, t))
1.9639991521835327

若要让 MLP 模型的结果 y 接近真实结果 t,我们就需要对模型的参数进行优化。下面的代码使用了以下的优化参数:

  • 优化器使用 SGD (stochastic gradient descent) (在一般的深度学习问题中,通常大家会更推荐 Adam 优化器);

  • 学习率始终为 0.02;

  • 使用 L1 作为损失函数;

  • 学习过程中使用梯度下降次数为 1001 次。

但需要指出,实际的深度学习的优化过程的参数是需要一些经验性的手动调整,以及引入验证集等降低泛化误差的做法。下面的代码只是非常简单的实例而已。

optimizer = torch.optim.SGD(model.parameters(), 0.02)
def train(model, X, t, optimizer):
    y = model(X)
    loss = nn.L1Loss()(y, t)
    optimizer.zero_grad()
    loss.backward()
    optimizer.step()
    return loss
for epoch in range(0, 1001):
    loss = train(model, X, t, optimizer)
    if epoch % 200 == 0:
        print("Loss in epoch {:4d}: {:10.6f}".format(epoch, loss))
Loss in epoch    0:   1.963999
Loss in epoch  200:   0.397570
Loss in epoch  400:   0.049674
Loss in epoch  600:   0.098072
Loss in epoch  800:   0.103638
Loss in epoch 1000:   0.021809

这就完成了一次 MLP 的学习!我们可以看看现在模型作用在训练集上得到的结果 y

model(X)
tensor([[1.24895],
        [1.53482],
        [3.02538],
        [3.18011],
        [1.70085],
        [1.18437]], grad_fn=<AddmmBackward>)

你应该会认为这已经比较接近真实的目标模长值 t 了吧。

从刚才训练过程的输出中,我们应当知道训练集的误差大约在 0.02 左右。但这个模型 model 是否真的能用于预测任意的长度为 5 的向量的模长呢?答案是不仅否定的,而且刚才的模型甚至会比随机的预测还要糟糕!

下面的代码是取了足够大样本数量 (\(1,048,576 = 1024^2\)) 来作测试集 XX_test,并取另一个取自同一分布的 1,048,576 数量的长度为 5 的向量集合 Xr_test 作为随机向量。我们可以经验地认为,对一个向量模长的随机的猜测,可以是与测试集相同分布的另一个随机向量的模长。

如果一个理想的机器学习模型应当要比随机猜测要好许多。但下面的代码显示,随机猜测的误差为 0.78 左右,但模型 model 所给出的误差却是 0.91 左右。这意味着模型给出的结果甚至不如瞎蒙!

torch.random.manual_seed(0)
XX_test = torch.randn(1048576, 5)
Xr_test = torch.randn(1048576, 5)
print("Loss for  random guess: {:10.6f}".format(nn.L1Loss()(XX_test.norm(dim=1, keepdim=True), Xr_test.norm(dim=1, keepdim=True))))
print("Loss for trained model: {:10.6f}".format(nn.L1Loss()(model(XX_test), XX_test.norm(dim=1, keepdim=True))))
Loss for  random guess:   0.776423
Loss for trained model:   0.911286

一般来说,对这种现象的解释是,模型的参数较多,而训练集 X 的样本数量只有 6 个,因此会发生相当强的过拟合现象。简单粗暴地说来,就是模型会强烈地喜好接近训练集出现的 6 个样本的数据点,而不能很好地处理不太像训练集的数据点。

大样本学习#

解决上述过拟合问题的一种解决方案是提高训练集数据量。现在我们重启一个从结构上完全等价,但参数的选取可以不同的模型 model_large。我们更换一个数据集:这次使用 1024 个样本作为数据集 X_large 来学习;并且使用以下的优化参数:

  • 优化器使用 Adam;

  • 学习率从 0.5 降低到 1e-5,降低的标准为连续 5 次 epoch 损失函数大于最低一次 epoch 损失函数值时,学习率降低到 0.9 倍;

  • 使用 L1 作为损失函数;

  • 学习过程中使用梯度下降次数为 1001 次。

torch.random.manual_seed(0)
model_large = nn.Sequential(nn.Linear(5, 4), nn.ReLU(), nn.Linear(4, 3), nn.ReLU(), nn.Linear(3, 1))
X_large = torch.randn(1024, 5)
t_large = X_large.norm(dim=1, keepdim=True)
optimizer = torch.optim.Adam(model_large.parameters(), .5)
scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau(optimizer, mode="min", factor=0.9, patience=5, min_lr=1e-5)
def train_with_scheduler(model, X, t, scheduler):
    y = model(X)
    loss = nn.L1Loss()(y, t)
    scheduler.optimizer.zero_grad()
    loss.backward()
    scheduler.optimizer.step()
    scheduler.step(loss)
    return loss
for epoch in range(0, 1001):
    loss = train_with_scheduler(model_large, X_large, t_large, scheduler)
    if epoch % 200 == 0:
        print("Loss in epoch {:4d}: {:10.6f}".format(epoch, loss))
Loss in epoch    0:   2.000235
Loss in epoch  200:   0.344131
Loss in epoch  400:   0.308635
Loss in epoch  600:   0.307540
Loss in epoch  800:   0.307498
Loss in epoch 1000:   0.307497

对于上述学得的模型 model_large,我们发现其训练集上的误差约为 0.31,明显比小样本学习中的 0.02 要大许多;但在相同的测试集中,其误差却降低到 0.35 左右。这意味着 model_large 尽管仍然不能很好地预测向量的模长,但至少已经比随机猜测好许多。

print("Loss for  random guess: {:10.6f}".format(nn.L1Loss()(XX_test.norm(dim=1, keepdim=True), Xr_test.norm(dim=1, keepdim=True))))
print("Loss for trained model: {:10.6f}".format(nn.L1Loss()(model_large(XX_test), XX_test.norm(dim=1, keepdim=True))))
Loss for  random guess:   0.776423
Loss for trained model:   0.352113

在这种模型下,训练集与测试集的误差都不算太小但比较接近。一般认为这种情况已经有效地解决过拟合的效应,但存在相当严重的欠拟合效应。欠拟合效应反映的是模型本身的缺陷。

在这个例子中,这意味着 model_large 的参数不论如何学习,恐怕都无法作出有效的预测。一般来说会有效的解决方案可以是增大模型的大小 (反映在增加 MLP 神经元数量与层的数量),或干脆更换一套模型框架。

如何解决当前例子的欠拟合问题,不在这个文档的讨论范畴之内。

PyTorch 普通前馈网络的定义#

段落目标:重复模型过程#

刚才我们花了不少精力,除了展示一个比较简单易行、但比较完整的深度学习流程之外,更重要的目的是说明了这篇文档的模型与数据。

后面的文档中,我们将会拿上面提到过的小样本模型 model 与数据集 X,来探讨 PyTorch 中 MLP 层具体是怎样计算,以及如何进行自动求导以更新参数表。这一段中我们先会解决前一个问题。我们的目标是,对于任何学习过最基础矩阵运算的人,都可以很轻易地理解到在给定参数的情况下,MLP 的前向传播过程不同于很多机器学习模型,在本质上仅仅是由非常简单的矩阵运算构成的。

在解决问题前,我们先将模型换为没有经过训练的状况。这么做的目的单纯地是为了让梯度变得大一些,以便后面比较有效的定性与定量分析。

torch.random.manual_seed(0)
model = nn.Sequential(nn.Linear(5, 4), nn.ReLU(), nn.Linear(4, 3), nn.ReLU(), nn.Linear(3, 1))

我们代入的训练集是 (6, 5) 的矩阵 X \(\mathbf{X}\);其第一维度为数据点数量,第二维度为特征向量长度。

X = torch.randn(6, 5)
X
tensor([[ 0.25963, -0.17396, -0.67875,  0.93826,  0.48887],
        [-0.67309,  0.87283,  1.05536, -0.00479, -0.51807],
        [-0.30670, -1.58099,  1.70664, -0.44622,  2.08196],
        [ 1.70671,  2.38037, -1.12560, -0.31700, -1.09247],
        [-0.08519, -0.09335, -0.76071, -1.59908,  0.01849],
        [-0.75043,  0.18541,  0.62114,  0.63818, -0.24600]])

在模型的作用下,得到的结果是 (6, 1) 的矩阵向量 y \(\mathbf{y}\),其第一维度为数据点数量。

y = model(X)
y
tensor([[0.05652],
        [0.11903],
        [0.10917],
        [0.10579],
        [0.10462],
        [0.11395]], grad_fn=<AddmmBackward>)

我们的目标是用普通的、容易理解的矩阵过程重新描述 layer(X)

模型参数#

模型 model 的定义是通过参数给出的。这些参数在 PyTorch 中以字典的形式储存在 state_dict 方法下:

param = model.state_dict()
type(param)
collections.OrderedDict

这意味着这些参数是可以被读取的。由于这个模型并不庞大,我们可以打出所有的参数数值以及它们在字典中对应的键值来。我们还可以同时计算出这个模型的参数总量为 43。

parameter_number = 0

for name, tensor in param.items():
    print("------")
    print("KEY:", name)
    print("shape:", tuple(tensor.shape))
    print(tensor)
    parameter_number += torch.prod(torch.tensor(tensor.shape))
    
print("======")
print("Parameter number:", int(parameter_number))
------
KEY: 0.weight
shape: (4, 5)
tensor([[-0.00335,  0.23990, -0.36808, -0.32912, -0.17225],
        [ 0.11992, -0.00886,  0.35459, -0.03969,  0.11834],
        [-0.13515, -0.08791, -0.42724, -0.29618, -0.18435],
        [ 0.01657,  0.17680,  0.26834, -0.30318, -0.19474]])
------
KEY: 0.bias
shape: (4,)
tensor([ 0.16244,  0.37136, -0.09204,  0.33466])
------
KEY: 2.weight
shape: (3, 4)
tensor([[-0.08059,  0.05291,  0.45274, -0.46384],
        [-0.31477, -0.12658, -0.19490,  0.43200],
        [-0.32409, -0.23017, -0.34932, -0.46828]])
------
KEY: 2.bias
shape: (3,)
tensor([-0.29187,  0.42980,  0.22311])
------
KEY: 4.weight
shape: (1, 3)
tensor([[ 0.27983,  0.03036, -0.29600]])
------
KEY: 4.bias
shape: (1,)
tensor([0.09768])
======
Parameter number: 43

如果我们希望取出其中第一个矩阵 0.weight,我们可以用下述代码:

param["0.weight"]
tensor([[-0.00335,  0.23990, -0.36808, -0.32912, -0.17225],
        [ 0.11992, -0.00886,  0.35459, -0.03969,  0.11834],
        [-0.13515, -0.08791, -0.42724, -0.29618, -0.18435],
        [ 0.01657,  0.17680,  0.26834, -0.30318, -0.19474]])

使用 model.state_dict() 是比较标准的取出参数表的方法;我们之后为了能对参数进行导数计算,这里换用 model.named_parameters() 取出参数,并使用其它变量名替代其中的参数:

param = {}
for i in model.named_parameters():
    param[i[0]] = i[1]
W_0, b_0 = param["0.weight"], param["0.bias"]
W_2, b_2 = param["2.weight"], param["2.bias"]
W_4, b_4 = param["4.weight"], param["4.bias"]

现在我们要解决如下的几个问题。

第一个问题是,键值的名称是如何被定义的。我们现在先回头看一下模型 model 是如何定义的:

model
Sequential(
  (0): Linear(in_features=5, out_features=4, bias=True)
  (1): ReLU()
  (2): Linear(in_features=4, out_features=3, bias=True)
  (3): ReLU()
  (4): Linear(in_features=3, out_features=1, bias=True)
)

这就意味着,在 PyTorch 中,程序认定这个模型当前是 5 层 (而不是我们刚才提到的 MLP 的 2 层神经元的层数);其中第 0, 2, 4 层是线性层,1, 3 层是 ReLU 激活层。一般来说,一层神经元是指一层线性层叠加一层激活层;因此我们会说第一层神经元对应 model 的 0, 1 层,而第二层神经元对应 model 的第 2, 3 层。之后我们通常在提到“层”的时候,指的是程序中出现的 5 层。

其中具有参数的层只有线性层。W_0 表示的是第 0 层的权重 \(\mathbf{W}^0\),而 b_0 表示第 0 层的偏置 \(\mathbf{b}^0\)。第 2, 4 层类推。留意在这篇文档中,上标数字并不代表矩阵或向量的幂运算。

第二个问题是,每个参数值的维度是如何被定义的。

我们仍然需要回头看一下 model 的定义。以第 0 层为例,其输入特征长度 in_features 为 5,这与 X \(\mathbf{X}\) 的维度是相同;而其输出特征长度 out_features 为 4。另一个例子是最后一层即第 4 层,其输出特征长度 out_features 为 1,这与 y \(\mathbf{y}\) 的维度是相同。

对于任何线性层而言,其权重矩阵的维度为 (out_features, in_features);其偏置向量的维度为 (out_features, )。以第 0 层为例,

W_0.shape
torch.Size([4, 5])
b_0.shape
torch.Size([4])

关于权重矩阵与偏置向量,它们的作用将会马上在下一段介绍。

线性层#

现在我们简单地了解一下第 0 层线性层的作用。实际上,线性层的作用是相当简单的:

\[ \mathbf{X}^0 = \mathbf{X} \mathbf{W}^{0,\mathrm{T}} + \mathbf{b}^0 \]

上面的记号中,X_0 \(\mathbf{X}^0\) 表示第 0 层输出矩阵,X \(\mathbf{X}\) 表示第 0 层输入矩阵 (恰好为数据集),W_0 \(\mathbf{W}^{0}\) 表示第 0 层作为线性层的权重矩阵,b_0 \(\mathbf{b}^0\) 表示第 0 层作为线性层的偏置向量。上标 \(\mathrm{T}\) 表示矩阵转置。

X_0 = X @ param["0.weight"].T + param["0.bias"]
X_0
tensor([[-0.02335,  0.18398, -0.18986, -0.25360],
        [ 0.07644,  0.59601, -0.43176,  0.86336],
        [-1.05575,  1.21783, -0.89241,  0.23785],
        [ 1.43460,  0.03912,  0.24424,  0.79060],
        [ 0.94343,  0.15788,  0.72290,  0.59383],
        [-0.18687,  0.44553, -0.41596,  0.37610]], grad_fn=<AddBackward0>)

但上述矩阵的表示相对来说不太直观;一种相对来说直观的方式可以是 (但不是标准的表示方法,只是作者写多了化学反应式自然会生成的一种习惯)

\[ \mathbf{X} \xrightarrow{\text{Linear} (5, 4)} \mathbf{X}^0 \]

对于第 2, 4 层线性层,其过程是非常类似的:

\[\begin{split} \begin{align} \mathbf{X}^1 &\xrightarrow{\text{Linear} (4, 3)} \mathbf{X}^2 \\ \mathbf{X}^3 &\xrightarrow{\text{Linear} (3, 1)} \mathbf{X}^4 = \mathbf{y} \end{align} \end{split}\]

激活层#

MLP 的线性层一般就是普通的矩阵乘法,但激活层的选取可以较为丰富。一种典型与常用的激活层是 ReLU (Rectified Linear Unit),其定义非常简单:

\[ \mathrm{ReLU} (x) = \max(x, 0) \]

因此,若我们记 X_0 \(\mathbf{X}^0\) 表示第 0 层输出矩阵,X_1 \(\mathbf{X}^0\) 表示第 1 层输出矩阵;那么

\[ \mathbf{X}^1 = \mathrm{ReLU} (\mathbf{X}^0) = \max(\mathbf{X}^0, \mathbf{0}) \]

若联合线性层,其比较直观的表示方式为

\[\begin{split} \begin{align} \mathbf{X} &\xrightarrow[\mathrm{ReLU}]{\text{Linear} (5, 4)} \mathbf{X}^1 \\ \mathbf{X}^1 &\xrightarrow[\mathrm{ReLU}]{\text{Linear} (4, 3)} \mathbf{X}^3 \end{align} \end{split}\]

对于程序的实现上,X_1 可以表示为

X_1 = torch.max(X_0, torch.tensor(0.))
X_1
tensor([[0.00000, 0.18398, 0.00000, 0.00000],
        [0.07644, 0.59601, 0.00000, 0.86336],
        [0.00000, 1.21783, 0.00000, 0.23785],
        [1.43460, 0.03912, 0.24424, 0.79060],
        [0.94343, 0.15788, 0.72290, 0.59383],
        [0.00000, 0.44553, 0.00000, 0.37610]], grad_fn=<MaxBackward2>)

总结#

现在我们可以写出 model 模型下,从 \(\mathbf{X}\)\(\mathbf{y}\) 的流程:

\[ \mathbf{X} \xrightarrow[\mathrm{ReLU}]{\text{Linear} (5, 4)} \mathbf{X}^1 \xrightarrow[\mathrm{ReLU}]{\text{Linear} (4, 3)} \mathbf{X}^3 \xrightarrow{\text{Linear} (3, 1)} \mathbf{X}^4 = \mathbf{y} \]

若要写出每一步的详细过程,则可以是

\[\begin{split} \begin{align} \mathbf{X}^0 &= \mathbf{X} \mathbf{W}^{0,\mathrm{T}} + \mathbf{b}^0 \\ \mathbf{X}^1 &= \max (\mathbf{X}^0, \mathbf{0}) \\ \mathbf{X}^2 &= \mathbf{X}^1 \mathbf{W}^{2,\mathrm{T}} + \mathbf{b}^2 \\ \mathbf{X}^3 &= \max (\mathbf{X}^2, \mathbf{0}) \\ \mathbf{y} = \mathbf{X}^4 &= \mathbf{X}^3 \mathbf{W}^{4,\mathrm{T}} + \mathbf{b}^4 \end{align} \end{split}\]

我们将上述矩阵的计算操作结果记作 X_4 \(\mathbf{X}^4\),而仍然将 model(X) 的结果记作 y \(\mathbf{y}\)。我们可以发现这两者是完全等价的:

X_0 = X   @ param["0.weight"].T + param["0.bias"]
X_1 = torch.max(X_0, torch.tensor(0.))
X_2 = X_1 @ param["2.weight"].T + param["2.bias"]
X_3 = torch.max(X_2, torch.tensor(0.))
X_4 = X_3 @ param["4.weight"].T + param["4.bias"]
X_4
tensor([[0.05652],
        [0.11903],
        [0.10917],
        [0.10579],
        [0.10462],
        [0.11395]], grad_fn=<AddBackward0>)
y
tensor([[0.05652],
        [0.11903],
        [0.10917],
        [0.10579],
        [0.10462],
        [0.11395]], grad_fn=<AddmmBackward>)

如果我们希望在一个公式内解决一切与 \(\mathbf{X}\)\(\mathbf{y}\) 的映射,我们可以写作

\[ \mathbf{X} \xrightarrow{\text{MLP}} \mathbf{y} \Leftrightarrow \mathbf{y} = \mathbf{X}^4 = \max \left[ \max (\mathbf{X} \mathbf{W}^{0,\mathrm{T}} + \mathbf{b}^0, \mathbf{0}) \mathbf{W}^{2,\mathrm{T}} + \mathbf{b}^2, \mathbf{0} \right] \mathbf{W}^{4,\mathrm{T}} + \mathbf{b}^4 \]
X_4 = torch.max(torch.max(
    X @ param["0.weight"].T + param["0.bias"],
    torch.tensor(0.)) @ param["2.weight"].T + param["2.bias"],
    torch.tensor(0.)) @ param["4.weight"].T + param["4.bias"]
X_4
tensor([[0.05652],
        [0.11903],
        [0.10917],
        [0.10579],
        [0.10462],
        [0.11395]], grad_fn=<AddBackward0>)

至此,我们已经成功地用简单的矩阵运算还原了 PyTorch 封装过的 MLP 模型 model 了。这是可以是一个比较典型的模型重复的标准途径,也将会对文档第三部分了解模型的反向传播过程有重要的意义。

MLP 参数导数#

被求导对象#

这里我们就以这个非常短小、但是需要使用链式法则进行反向传播的 MLP 模型 model 进行分析。我们的目标是与 PyTorch 的 Autograd 的结果进行核对。但我们在上一份文档中提到,任何函数的导数都必须转化为标量的导数;即使输出并非是标量,也必须乘以与输出相同维度的张量并作求和得到变量。

对于当前的问题而言,作为标量的值是输出 y \(\mathbf{y}\) 与真实值 t \(\mathbf{t}\) 的 L1 损失函数值 loss \(\mathrm{loss}\)

\[ \mathrm{loss} = \Vert \mathbf{y} - \mathbf{t} \Vert_1 \]
loss = nn.L1Loss()(y, t)
loss
tensor(1.96400, grad_fn=<L1LossBackward>)

这一段的分析都是基于 loss 的导数得到。对该值的导数可以通过下述方式得到:

model.zero_grad()
loss.backward()
print("grad of W_0"); print(W_0.grad)
print("grad of b_0"); print(b_0.grad)
print("grad of W_2"); print(W_2.grad)
print("grad of b_2"); print(b_2.grad)
print("grad of W_4"); print(W_4.grad)
print("grad of b_4"); print(b_4.grad)
grad of W_0
tensor([[ 0.00151,  0.00503, -0.00132, -0.00306, -0.00254],
        [-0.00285,  0.00299,  0.00823, -0.01116, -0.00508],
        [ 0.00160,  0.00226, -0.00186, -0.00189, -0.00106],
        [ 0.00024, -0.00386, -0.00327,  0.00378, -0.00053]])
grad of b_0
tensor([ 0.00478, -0.00751,  0.00197, -0.01093])
grad of W_2
tensor([[ 0.00000,  0.00000,  0.00000,  0.00000],
        [-0.01242, -0.01336, -0.00489, -0.01448],
        [ 0.00000,  0.00908,  0.00000,  0.00000]])
grad of b_2
tensor([ 0.00000, -0.03036,  0.04933])
grad of W_4
tensor([[ 0.00000, -0.41996, -0.03013]])
grad of b_4
tensor([-1.])

第 4 层参数导数:非参数矩阵引入#

在所有的参数中,最容易求的导数在最后一层。我们将 loss \(\mathrm{loss}\) 写为第 2 层参数的函数:

\[ \mathrm{loss} = \Vert \mathbf{X}^3 \mathbf{W}^{4, \mathrm{T}} + \mathbf{b}^4 - \mathbf{t} \Vert_1 \]
(X_3 @ W_4.T + b_4 - t).abs().sum() / t.shape[0]
tensor(1.96400, grad_fn=<DivBackward0>)

但从公式的表达角度上,上面的表达式不太容易被求导。在此,我们引入第 5 层非参数矩阵 l_5 \(\mathbf{l}^5 = \mathrm{dim} (\mathbf{y})^{-1} \mathrm{sgn} (\mathbf{y} - \mathbf{t})\),那么

\[ \mathrm{loss} = \mathbf{l}^{5, \mathrm{T}} \left( \mathbf{X}^3 \mathbf{W}^{4, \mathrm{T}} + \mathbf{b}^4 - \mathbf{t} \right) \]

或表示为矩阵元的形式,

\[ \mathrm{loss} = \sum_{ij} l_{i0}^{5} \left( X_{ij}^3 W_{0j}^{4} + b_0^4 - t_{i0} \right) \]

上式中出现的 \(0\) 表示该维度恰好为 1 维。

l_5 = (y - t).sign() / y.shape[0]
l_5.shape
torch.Size([6, 1])
l_5.T @ (X_3 @ W_4.T + b_4 - t)
tensor([[1.96400]], grad_fn=<MmBackward>)

根据上面公式的表述,我们就可以比较容易地给出关于 \(\mathbf{W}^{4, \mathrm{T}}\) 的导数

\[ \frac{\partial \mathrm{loss}}{\partial \mathbf{W}^{4}} = \mathbf{l}^{5, \mathrm{T}} \mathbf{X}^3 \]

或矩阵元的形式

\[ \frac{\partial \mathrm{loss}}{\partial W_{0j}^{4}} = \sum_{i} l_{i0}^{5} X_{ij}^3 \]

我们可以将其 gW_4 与 PyTorch 自动导数给出的 W_4.grad 进行比对:

gW_4 = l_5.T @ X_3
torch.allclose(gW_4, W_4.grad)
True

但这里需要指出,由于 \(\mathbf{b}^4\) 作为偏置矩阵的维度与其它矩阵不太相同,因此在求梯度时,需要对 \(\mathbf{l}^{5, \mathrm{T}}\) 的第一维度,或者说对表示数据点数量的维度求和:

\[ \frac{\partial \mathrm{loss}}{\partial b_0^4} = \sum_{i} l_{i0}^{5} \]

我们可以将其 gb_4 与 PyTorch 自动导数给出的 b_4.grad 进行比对:

gb_4 = l_5.T.sum(dim=-1)
torch.allclose(gb_4, b_4.grad)
True

第 2 层参数导数:链式法则#

第二层参数的导数会复杂不少。如果我们把 loss \(\mathrm{loss}\) 写为第 2 层参数的函数:

\[ \mathrm{loss} = \mathbf{l}^{5, \mathrm{T}} \left( \mathrm{ReLU} \left( \mathbf{X}^1 \mathbf{W}^{2, \mathrm{T}} + \mathbf{b}^2 \right) \mathbf{W}^{4, \mathrm{T}} + \mathbf{b}^4 - \mathbf{t} \right) \]
l_5.T @ (nn.ReLU()(X_1 @ W_2.T + b_2) @ W_4.T + b_4 - t)
tensor([[1.96400]], grad_fn=<MmBackward>)

如果要尝试对 \(\mathbf{W}^{2}\)\(\mathbf{b}^2\) 求导,我们又会遇到与刚才第四层导数相同的问题,即如何对 \(\mathrm{ReLU}\) 求导。我们引入第 3 层非参数矩阵 l_3 \(\mathbf{l}^3 = \mathrm{sgn}^+ (\mathbf{X}^{2})\)

\[\begin{split} \begin{align} \mathrm{loss} &= \mathbf{l}^{5, \mathrm{T}} \left( \mathbf{X}^3 \mathbf{W}^{4, \mathrm{T}} + \mathbf{b}^4 - \mathbf{t} \right) \\ &= \mathbf{l}^{5, \mathrm{T}} \left( \mathbf{l}^3 \odot \left( \mathbf{X}^1 \mathbf{W}^{2, \mathrm{T}} + \mathbf{b}^2 \right) \mathbf{W}^{4, \mathrm{T}} + \mathbf{b}^4 - \mathbf{t} \right) \end{align} \end{split}\]

或表示为矩阵元的形式,

\[\begin{split} \begin{align} \mathrm{loss} &= \sum_{ij} l_{i0}^{5} \left( X_{ij}^3 W_{0j}^{4} + b_0^4 - t_{i0} \right) \\ &= \sum_{ijk} l_{i0}^{5} \left( l_{ij}^3 (X_{ik}^1 W_{jk}^2 + b_{j}^2) W_{0j}^{4} + b_0^4 - t_{i0} \right) \end{align} \end{split}\]

其中,记号 \(\mathrm{sgn}^+\) 表示正值取 1,负值取 0:

\[ \mathrm{sgn}^+ (x) = \max( \mathrm{sgn} (x), 0 ) \]

上面的计算过程中,矩阵 elementwise 乘法与普通的矩阵乘法的运算优先级是相同的。

sgn_pos = lambda t: torch.max(t.sign(), torch.tensor(0.))
l_3 = sgn_pos(X_2)
l_3.shape
torch.Size([6, 3])
l_5.T @ (l_3 * (X_1 @ W_2.T + b_2) @ W_4.T + b_4 - t)
tensor([[1.96400]], grad_fn=<MmBackward>)

我们固然可以对上式直接求关于 \(\mathbf{W}^{2}\) 参数的导数得到 gW_2,并与 PyTorch 自动求导给出的 W_2.grad 进行比对:

\[ \frac{\partial \mathrm{loss}}{\partial \mathbf{W}^{2}} = \mathbf{W}^{4, \mathrm{T}} \mathbf{l}^{5, \mathrm{T}} \odot \mathbf{l}^{3, \mathrm{T}} \mathbf{X}^1 \]

\[ \frac{\partial \mathrm{loss}}{\partial W_{jk}^2} = \sum_{i} l_{ij}^3 (X_{ik}^1 W_{jk}^2 + b_{j}^2) W_{0j}^{4} \]
gW_2 = W_4.T @ l_5.T * l_3.T @ X_1
torch.allclose(gW_2, W_2.grad)
True

但我们可以有更为精妙的做法 (或者说思考方法)。注意到我们可以将 \(\mathrm{loss}\) 看成关于 \(\mathbf{X}^3\) 的函数,而 \(\mathbf{X}^3\) 又可以看作是关于 \(\mathbf{W}^2\) 的函数:

\[\begin{split} \begin{align} \mathbf{X}^3 &= \mathbf{l}^3 \odot \left( \mathbf{X}^1 \mathbf{W}^{2, \mathrm{T}} + \mathbf{b}^2 \right) \\ \mathrm{loss} &= \mathbf{l}^{5, \mathrm{T}} \left( \mathbf{X}^3 \mathbf{W}^{4, \mathrm{T}} + \mathbf{b}^4 - \mathbf{t} \right) \end{align} \end{split}\]

\[\begin{split} \begin{align} X_{ij}^3 &= \sum_{k} l_{ij}^3 (X_{ik}^1 W_{jk}^2 + b_{j}^2) \\ \mathrm{loss} &= \sum_{ij} l_{i0}^{5} \left( X_{ij}^3 W_{0j}^{4} + b_0^4 - t_{i0} \right) \\ \end{align} \end{split}\]

那么,我们就可以分别写出导数

\[\begin{split} \begin{align} \frac{\partial \mathrm{loss}}{\partial X_{ij}^3} &= l_{i0}^{5} W_{0j}^{4} \\ \frac{\partial X_{ij}^3}{\partial W_{jk}^2} &= l_{ij}^3 X_{ik}^1 \end{align} \end{split}\]

我们定义 \(\frac{\partial \mathrm{loss}}{\partial X_{ij}^3}\) 在程序中表示为 gX_3。依据链式法则,我们就可以给出

\[ \frac{\partial \mathrm{loss}}{\partial W_{jk}^2} = \sum_i \frac{\partial \mathrm{loss}}{\partial X_{ij}^3} \frac{\partial X_{ij}^3}{\partial W_{jk}^2} = \sum_i \frac{\partial \mathrm{loss}}{\partial X_{ij}^3} l_{ij}^3 X_{ik}^1 \]

拿上式与 PyTorch 自动求导给出的 W_2.grad 进行比对也能得到正确的结果:

gX_3 = l_5 @ W_4
gW_2 = gX_3.T * l_3.T @ X_1
torch.allclose(gW_2, W_2.grad)
True

而对于 \(\mathbf{b}^2\) 的导数 gb_2 的情况也会非常类似:

\[ \frac{\partial \mathrm{loss}}{\partial b_{j}^2} = \sum_i \frac{\partial \mathrm{loss}}{\partial X_{ij}^3} \frac{\partial X_{ij}^3}{\partial b_{j}^2} = \sum_i \frac{\partial \mathrm{loss}}{\partial X_{ij}^3} l_{ij}^3 \]
gb_2 = (gX_3.T * l_3.T).sum(dim=-1)
torch.allclose(gb_2, b_2.grad)
True

我们也许在这个例子中没有感受到链式法则的意义;即使不使用链式法则,程序的复杂性也没有增大许多。但对于 \(\mathbf{W}^0\)\(\mathbf{b}^0\),使用链式法则的意义就会表现出来。

第 0 层参数导数:反向传播#

上面一小段中,在求导过程中我们使用了一次链式法则。下面我们会比较系统地,从头回顾,将问题一步一步拆分,从而获得第 0 层参数 \(\mathbf{W}^0\)\(\mathbf{b}^0\) 的导数。

\[\begin{split} \begin{align} \mathbf{X}^0 &= \mathbf{X} \mathbf{W}^{0,\mathrm{T}} + \mathbf{b}^0 \tag{0} \\ \mathbf{X}^1 &= \mathbf{l}^1 \odot \mathbf{X}^0 \tag{1} \\ \mathbf{X}^2 &= \mathbf{X}^1 \mathbf{W}^{2,\mathrm{T}} + \mathbf{b}^2 \tag{2} \\ \mathbf{X}^3 &= \mathbf{l}^3 \odot \mathbf{X}^2 \tag{3} \\ \mathbf{X}^4 &= \mathbf{X}^3 \mathbf{W}^{4,\mathrm{T}} + \mathbf{b}^4 \tag{4} \\ \mathrm{loss} &= \mathbf{l}^{5, \mathrm{T}} \left( \mathbf{X}^4 - \mathbf{t} \right) \tag{5} \end{align} \end{split}\]

上述的表示在写程序时可能是方便的,但在分析时,容易会对维度的信息把握得不太好。我们在此将下标明确地写出:

\[\begin{split} \begin{align} X^0_{ik} &= \sum_l X_{il} W^0_{kl} + b^0_{k} \tag{0} \\ X^1_{ik} &= l^1_{ik} X^0_{ik} \tag{1} \\ X^2_{ij} &= \sum_k X^1_{ik} W^2_{jk} + b^2_{j} \tag{2} \\ X^3_{ij} &= l^3_{ij} X^2_{ij} \tag{3} \\ X^4_{i0} &= \sum_j X^3_{ij} W^4_{0j} + b^4_{0} \tag{4} \\ \mathrm{loss} &= \sum_i l^5_{i0} (X^4_{i0} - t_{i0}) \tag{5} \end{align} \end{split}\]

其中的第 1, 3, 5 层引入了非参数矩阵,它们的定义为

\[\begin{split} \begin{align} l^1_{ik} &= \mathrm{sgn}^+ (X^0_{ik}) \\ l^3_{ij} &= \mathrm{sgn}^+ (X^2_{ij}) \\ l^5_{i0} &= \mathrm{dim} (i)^{-1} \mathrm{sgn} (X^4_{i0} - t_{i0}) \end{align} \end{split}\]
l_1 = sgn_pos(X_0)
l_3 = sgn_pos(X_2)
l_5 = y.shape[0]**-1 * (y - t).sign()

我们的目标是第 0 层参数导数 \(\mathbf{W}^0\)\(\mathbf{b}^0\),但我们的被求导对象 \(\mathrm{loss}\) 在第 5 层。反向传播的一种解读方式是,导数的求取是从最后层向前传播。那么我们一层一层地对导数进行求取。

第 5 层 gX_4 \(\frac{\partial \mathrm{loss}}{\partial X^4_{i0}}\)

\[ \frac{\partial \mathrm{loss}}{\partial X^4_{i0}} = l^5_{i0} \]
gX_4 = l_5

第 4 层 gX_3 \(\frac{\partial \mathrm{loss}}{\partial X^3_{ij}}\)

\[ \frac{\partial X^4_{i0}}{\partial X^3_{ij}} = W^4_{0j}, \quad \frac{\partial \mathrm{loss}}{\partial X^3_{ij}} = \frac{\partial \mathrm{loss}}{\partial X^4_{i0}} \frac{\partial X^4_{i0}}{\partial X^3_{ij}} = \frac{\partial \mathrm{loss}}{\partial X^4_{i0}} W^4_{0j} \]
gX_3 = gX_4 @ W_4

第 3 层 gX_2 \(\frac{\partial \mathrm{loss}}{\partial X^2_{ij}}\)

\[ \frac{\partial X^3_{ij}}{\partial X^2_{ij}} = l^3_{ij}, \quad \frac{\partial \mathrm{loss}}{\partial X^2_{ij}} = \frac{\partial \mathrm{loss}}{\partial X^3_{ij}} \frac{\partial X^3_{ij}}{\partial X^2_{ij}} = \frac{\partial \mathrm{loss}}{\partial X^3_{ij}} l^3_{ij} \]
gX_2 = gX_3 * l_3

第 2 层 gX_1 \(\frac{\partial \mathrm{loss}}{\partial X^1_{ik}}\)

\[ \frac{\partial X^2_{ij}}{\partial X^1_{ik}} = W^2_{jk}, \quad \frac{\partial \mathrm{loss}}{\partial X^1_{ik}} = \sum_j \frac{\partial \mathrm{loss}}{\partial X^2_{ij}} \frac{\partial X^2_{ij}}{\partial X^1_{ik}} = \sum_j \frac{\partial \mathrm{loss}}{\partial X^2_{ij}} W^2_{jk} \]
gX_1 = gX_2 @ W_2

第 1 层 gX_0 \(\frac{\partial \mathrm{loss}}{\partial X^0_{ik}}\)

\[ \frac{\partial X^1_{ik}}{\partial X^0_{ik}} = l^1_{ik}, \quad \frac{\partial \mathrm{loss}}{\partial X^0_{ik}} = \frac{\partial \mathrm{loss}}{\partial X^1_{ik}} \frac{\partial X^1_{ik}}{\partial X^0_{ik}} = \frac{\partial \mathrm{loss}}{\partial X^1_{ik}} l^1_{ik} \]
gX_0 = gX_1 * l_1

第 0 层

gW_0 \(\frac{\partial \mathrm{loss}}{\partial W^0_{kl}}\)

\[ \frac{\partial X^0_{ik}}{\partial W^0_{kl}} = X_{il}, \quad \frac{\mathrm{loss}}{\partial W^0_{kl}} = \sum_i \frac{\mathrm{loss}}{\partial X^0_{ik}} \frac{\partial X^0_{ik}}{\partial W^0_{kl}} = \sum_i \frac{\mathrm{loss}}{\partial X^0_{ik}} X_{il} \]
gW_0 = gX_0.T @ X
torch.allclose(gW_0, W_0.grad)
True

gb_0 \(\frac{\partial \mathrm{loss}}{\partial b^0_{k}}\)

\[ \frac{\partial X^0_{ik}}{\partial b^0_{k}} = 1, \quad \frac{\mathrm{loss}}{\partial b^0_{k}} = \sum_i \frac{\mathrm{loss}}{\partial X^0_{ik}} \frac{\partial X^0_{ik}}{\partial b^0_{k}} = \sum_i \frac{\mathrm{loss}}{\partial X^0_{ik}} \]
gb_0 = gX_0.sum(dim=0)
torch.allclose(gb_0, b_0.grad)
True

至此我们已经成功地使用了反向传播的方法,对最难求解的 \(\mathbf{W}^0\)\(\mathbf{b}^0\) 的参数进行了导数求取。这样一个过程可以是非常清晰与规范化的。

对于该模型更简单的其它参数,或者对于层数更多的 MLP 网络,使用完全相同的方式也能生成所有的导数。

我们知道,深度网络的参数优化过程通常是梯度下降的衍生方法。如果知道了这些参数的导数量,我们至少相信可以很自然地写出一个 naive 梯度下降优参的程序,实现深度网络的学习。

Autograd (3):梯度下降法解量子化学 RHF 自洽场能量#

创建时间:2019-12-17

这一篇文档将会简单地介绍量子化学计算中经常使用的 RHF 方法,在自动求导下的一种应用。

RHF (Restricted Hartree-Fock) 方法是量化计算的基本方法;对于更高精度的分子体系或晶体等周期性体系的计算,譬如 DFT、Post-HF 方法如 CC 和 CI,通常都是以 HF 方法为原型开发。但 RHF 方法的求解方程通常都是通过自洽场方法 (self-consistent field, SCF) 迭代计算;一种经典的迭代计算算法是 DIIS (见 简单理解 SCF 中的 DIIS)。关于 SCF 求解 RHF 的入门文档可以参考 Szabo, Ostlund [1]。关于自洽场求解 RHF 的程序相关问题,可以参考 pyxdh 文档

而事实上,更经典但现在不太用于 RHF 方程求解的方法是将其化为局域凸优化问题。尽管通常这类凸优化问题属于类牛顿法的范畴,但如果将 Hessian 矩阵始终视为单位矩阵的常数倍,这就化为了普通的梯度下降问题,也恰好是自动求导可以解决的问题范畴。这篇文档就用 PyTorch 简单地实现这样一个功能。

在继续这篇文档之前,我们先引入程序库:

import torch
import math
import numpy as np
import scipy
from pyscf import gto, scf
import warnings

torch.set_printoptions(precision=5, sci_mode=False, linewidth=120)
np.set_printoptions(precision=5, suppress=True, linewidth=120)
warnings.filterwarnings("ignore")

device = torch.device("cuda" if torch.cuda.is_available() else "cpu")

注意

这篇文档是找了一个作者稍微熟悉的优化问题,用自动求导来解决。但这不是通常处理这类问题的做法,况且并不高效。

同时需要指出的是,一般来说 RHF 并不是一个凸优化问题;但在比较合理的初猜下,可以将问题 近似地 看成凸优化问题。后面尽管默认了凸优化这个条件,但许多现实的 RHF 收敛问题实际上来源于非凸优化的复杂性。作者也不是搞优化问题的,因此很多术语可能用得不对。

因此,这篇文档是 娱乐向 的文档。

分子体系的定义#

分子体系能量#

我们使用的分子体系是不对称的双氧水分子,基组为 6-31G,用 PySCF 进行计算。分子体系的总能量为 energy_tot \(E_\mathrm{tot}\) -150.5850338 Hartree。这篇文档的目标就是计算得到这个数值。

mol = gto.Mole()
mol.atom = """
O  0.0  0.0  0.0
O  0.0  0.0  1.5
H  1.0  0.0  0.0
H  0.0  0.7  1.0
"""
mol.basis = "6-31G"
mol.verbose = 0
mol.build()
<pyscf.gto.mole.Mole at 0x7fa30ea25160>
scf_eng = scf.RHF(mol).run()
energy_tot = scf_eng.e_tot
energy_tot
-150.5850337808369

恒定物理量与矩阵#

这里列举的是随分子不同而不同的量。这些量可能计算耗时小,也可能是计算过程中的重要构成部分;但它们与凸优化问题的变量无关,在这篇文档中姑且称为恒定量。

分子体系总能量中,一部分能量是无需通过量化方法,而只需要经典物理的库伦排斥力计算得到。这部分能量是原子核排斥能 energy_nuc \(E_\mathrm{nuc}\) 37.8846744 Hartree。为了简化叙述,我们就直接借用了 PySCF 的程序的结果。

energy_nuc = scf_eng.energy_nuc()
energy_nuc
37.88467440864127

下面是原子轨道角标的矩阵或张量。无电子相互作用的 Hamiltonian 积分 (Hamiltonian Core 积分) \(h_{\mu \nu}\) 记为 H,重叠积分 \(S_{\mu \nu}\) 记为 S,双电子积分 \((\mu \nu | \kappa \lambda)\) 记为 eri_ao

T = mol.intor("int1e_kin")
Vnuc = mol.intor("int1e_nuc")
H = T + Vnuc
S = mol.intor("int1e_ovlp")
eri_ao = mol.intor("int2e")

上述矩阵或张量都是通过 PySCF 给出的;PySCF 使用的默认引擎是 numpy。我们需要将它们转为 PyTorch 引擎的矩阵或张量。

H = torch.tensor(H, device=device)
S = torch.tensor(S, device=device)
eri_ao = torch.tensor(eri_ao, device=device)

凸优化自变量与密度矩阵#

在凸优化问题中,RHF 问题可以看作是对下述原子轨道空间下 \(\mathbf{X}\) 矩阵 (或写作矩阵元的形式,\(X_{\mu \nu}\)) 作为自变量的函数优化问题 (Helgaker et al. [2], eq 10.7.69,但这篇文档对密度矩阵 \(R_{\mu \nu}\) 的定义恰好是 Helgaker 课本中的两倍):

\[ E_\mathrm{tot} [\mathbf{X}] = \mathrm{tr} (\mathbf{R}[\mathbf{X}]) \mathbf{h} + \frac{1}{4} \mathrm{tr} (\mathbf{R}[\mathbf{X}] \mathbf{G} \mathbf{R}[\mathbf{X}]) + E_\mathrm{nuc} \]

其中,根据 Helgaker, eq 10.7.32,有

\[ \mathbf{R}[\mathbf{X}] = \exp (- \mathbf{X} \mathbf{S}) \mathbf{R}_0 \exp (\mathbf{S} \mathbf{X}) \]

关于这些公式的意义与计算过程,我们后面还会有更详细的描述。这里需要留意的是,\(\mathbf{G}\) 是一个四维张量;其它地方的记号与矩阵乘法没有区别。

我们现在需要知道的是,其一,\(E_\mathrm{tot} [\mathbf{X}]\) 是我们的凸优化目标;对应于统计或机器学习中的概念,就类似于损失函数 (loss function)。其二,尽管 \(\mathbf{X}\) 是我们的学习目标,但真正有物理意义的、可以被实验观测的量是 \(\mathbf{R}[\mathbf{X}]\);该量的意义是电子云密度 (简称密度)。换一种说法的话,RHF 方法作为量子化学方法的核心目标是给出基态的电子云密度;有了电子云密度之后,许多其它的分子性质是可以从中导出的。因此,对于 RHF 方法而言,其电子云密度的价值近乎等价于作为 Schrodinger 方程的求解目的的波函数。

方才我们已经用 PySCF 计算了分子的 RHF 的能量。PySCF 也可以给出分子基态下的 RHF 密度 D \(D_{\mu \nu}\)

D = scf_eng.make_rdm1()
D = torch.tensor(D, device=device)

对于 RHF 问题而言,我们会说,\(\mathbf{X}\) 取到函数 \(E_\mathrm{tot} [\mathbf{X}]\) 处于最小值的极小值几乎等价于问题

\[ \mathbf{D} = \mathbf{R} [\mathbf{X}] \]

我们等下会验证这个结论。

需要补充的是,PySCF 给出电子密度的方式与上述过程完全不相同。在一般的自洽场迭代过程中,电子态密度是由分子轨道系数得到的:

\[ \mathbf{D} = 2 \mathbf{C}_\mathrm{occ} \mathbf{C}_\mathrm{occ}^\mathrm{T} \]

或用 Einstein Summation Convention 的语言,

\[ D_\mathrm{\mu \nu} = 2 C_{\mu i} C_{\nu i} \]

这与 \(\mathbf{D} = \mathbf{R} [\mathbf{X}]\) 看似是完全无关的公式,但至少从结果上,两者确实相同。

损失函数的定义#

PyTorch 矩阵幂运算#

这一段只是讨论一个技术细节。尽管 PyTorch 功能确实很强大,但一个比较尴尬的地方是 PyTorch 不支持不少需要一些数学技巧才能保证效率与稳定性的算法。其中一个算法是矩阵幂 (作者 Bing 了一刻钟发现实在找不到,于是作出了断言,但高 zi 贵 bi 的作者是不会提 issue 或者 stackoverflow 的 >.>)。

为了解决这样一个量化的问题,我们无奈需要自己造轮子,目标是只要能用就行。

下面的程序 calc_exp 是根据矩阵幂的定义给出的:

\[ \exp(\mathbf{X}) = \sum_{k=0}^{K} \frac{\mathbf{X}^k}{k!} \]

其中用到了矩阵指数运算 \(\mathbf{X}^k\) 的函数 mat_power;该函数通过递归定义。

我们知道,这种函数的求和上界应当是 \(K \rightarrow \infty\);但实际的程序不能允许这种操作,必须让 \(K\) 作有界的截断。这种有界的截断的判据是,若通过截断计算得到的 \(\exp(\mathbf{X})\) 和 numpy 给出的不截断的 \(\exp(\mathbf{X})\) 近乎相等 (np.allclose 的判标下),那么就允许截断。

def mat_power(X, order):
    if order > 0:
        return X @ mat_power(X, order - 1)
    return torch.eye(X.shape[0], dtype=X.dtype, device=X.device)

def calc_exp(X, debug=False):
    target = scipy.linalg.expm(X.cpu().clone().detach().numpy())
    result = mat_power(X, 0) + mat_power(X, 1)
    order = 1
    while not np.allclose(result.cpu().clone().detach().numpy(), target):
        order += 1
        result += mat_power(X, order) / math.factorial(order)
        if debug: print(np.linalg.norm(result.cpu().clone().detach().numpy() - target))
    if debug: print("Current order is", order)
    return result

读者可以尝试几个矩阵幂运算的例子。但需要留意,这个程序的鲁棒性显然是不高的;遇到上述定义下 \(\mathbf{X}\) 超出收敛域的情形下,这个程序就失效了。

分子能量函数 \(E_\mathrm{tot} [\mathbf{X}]\)#

下面我们回到分子能量函数 (损失函数) \(E_\mathrm{tot} [\mathbf{X}]\) 的定义中。事实上让程序来做这件事其实是相当容易的,只需要几行代码就能搞定:

def calc_RX(X, S, R0):
    return calc_exp(-X @ S) @ R0 @ calc_exp(S @ X)
def calc_EX(X, S, R0, H, eri_ao, energy_nuc):
    RX = calc_RX(X, S, R0)
    return (
        torch.einsum("uv, uv ->", RX, H)
        + 0.5 * torch.einsum("uv, uvkl, kl ->", RX, eri_ao, RX)
        - 0.25 * torch.einsum("uv, ukvl, kl ->", RX, eri_ao, RX)
        + energy_nuc
    )

但我们需要对其中的过程作一些说明。首先是关于密度的导出式 calc_RX

\[ \mathbf{R}[\mathbf{X}] = \exp (- \mathbf{X} \mathbf{S}) \mathbf{R}_0 \exp (\mathbf{S} \mathbf{X}) \]

这里的 \(\mathbf{S}\) 是重叠积分。\(\mathbf{R}_0\) 原则上是任意的、满足下述三式的矩阵 (Helgaker, eq 10.7.25-27):

\[\begin{split} \begin{align} \mathbf{R}_0^\mathrm{T} &= \mathbf{R}_0 \\ \mathrm{tr} (\mathbf{R}_0 \mathbf{S}) &= N \\ \mathbf{R}_0 \mathbf{S} \mathbf{R}_0 &= \mathbf{R}_0 \end{align} \end{split}\]

上述三式对应的性质分别称为对称性、电子数守恒、基于重叠积分 \(\mathbf{S}\) 的幂等性。其中,\(N\) 代表体系的电子数。

但实际上,从数值稳定性的角度上讲,我们不希望让 \(\mathbf{X}\) 太大以至于超出收敛域;因此 \(\mathbf{R}_0\) 总是需要尽可能接近真实密度 \(\mathbf{D}\)。在这篇文档中,我们称 \(\mathbf{R}_0\) 为密度初猜。

同时,\(\mathbf{X}\) 是一种旋转矩阵;它必须要满足反对称性,即 \(\mathbf{X}^\mathrm{T} = - \mathbf{X}\)。这是相当重要且有用的性质,但我们这里不作更多分析。

而对于能量函数 calc_EX

\[ E_\mathrm{tot} [\mathbf{X}] = \mathrm{tr} (\mathbf{R}[\mathbf{X}]) \mathbf{h} + \frac{1}{4} \mathrm{tr} (\mathbf{R}[\mathbf{X}] \mathbf{G} \mathbf{R}[\mathbf{X}]) + E_\mathrm{nuc} \]

由于其中的四脚标张量的存在稍有麻烦,在程序编写便利、以及借用强大的 einsum 功能的角度上,我们采用 Einstein Summation Convention 可以将上式写作

\[ E_\mathrm{tot} [\mathbf{X}] = R_{\mu \nu} [\mathbf{X}] h_{\mu \nu} + \frac{1}{2} R_{\mu \nu} [\mathbf{X}] (\mu \nu | \kappa \lambda) R_{\kappa \lambda} [\mathbf{X}] - \frac{1}{4} R_{\mu \nu} [\mathbf{X}] (\mu \kappa | \nu \lambda) R_{\kappa \lambda} [\mathbf{X}] + E_\mathrm{nuc} \]

各个矩阵元都已经在上文中被定义了,因此程序的书写也并不困难。

最小值的极小点分析#

这一段我们分析极小点附近的性质。我们之前提到过,\(\mathbf{R}_0\) 总是需要尽可能接近真实密度 \(\mathbf{D}\)。如果这两者相等是什么情形?

现在我们假定 \(E_\mathrm{tot} [\mathbf{X}]\) 已经取到了处于最小值的极小点。我们说起过这种情况下与 \(\mathbf{D} = \mathbf{R} [\mathbf{X}]\) 几乎等价。但我们并没有验证过这个结论。下面我们就来验证之。

若同时有 \(\mathbf{R}_0 = \mathbf{D}\)\(\mathbf{D} = \mathbf{R} [\mathbf{X}]\),那么

\[ \exp (- \mathbf{X} \mathbf{S}) \mathbf{D} \exp (\mathbf{S} \mathbf{X}) = \mathbf{D} \]

一个显然满足上式的 \(\mathbf{X}\) 是零矩阵。在这种情况下,我们应当会期待分子体系总能量 energy_autograd 就是使用现成量化软件给出的总能量 energy_tot

X = torch.zeros_like(S, requires_grad=True, device=S.device)
R0 = D.clone()
energy_autograd = calc_EX(X, S, R0, H, eri_ao, energy_nuc)
float(energy_autograd)
-150.58503378083833

并且,我们会期待 \(\mathbf{X}\) 确实取到了极小值,通过自动求导得到的导数是不会发生任何变化的零矩阵:

energy_autograd.backward()
gX = X.grad
torch.norm(gX)
tensor(    0.00000, device='cuda:0', dtype=torch.float64)

从这个意义上,我们可以说,这一套工作流程是基本合理的。

真实密度初猜的分析#

但上面一段是假设我们已经知道体系密度的理想情况。现实情况是,我们并不知道体系的真正密度,从而需要求自变量 \(\mathbf{X}\) 的结果。

现实中有许多给出密度初猜 \(\mathbf{R}_0\) 的方式。其中最简单的密度初猜是将 Fock 矩阵当作 Hamiltonian Core 矩阵,进行对角化后得到的密度矩阵。在 PySCF 中,它通过 init_guess_by_1e 方法给出。同时,初始的自变量 (或称旋转矩阵) \(\mathbf{X}\) 定为零矩阵。

X = torch.zeros_like(S, requires_grad=True, device=S.device)
R0 = torch.tensor(scf_eng.init_guess_by_1e(), device=S.device)

以这个初始密度代入能量的计算公式中,会得到一个比刚才的 -150 Hartree 高出不少的能量:

energy_autograd = calc_EX(X, S, R0, H, eri_ao, energy_nuc)
energy_autograd
tensor(-140.54132, device='cuda:0', dtype=torch.float64, grad_fn=<AddBackward0>)

得到这样的结果也是情理之中:这完全满足变分原理的要求,即任意非基态的波函数 (在 RHF 中,也可以称为其对应的基态密度 \(\mathbf{D}\)),那么其给出的能量会高于最低能量。这也满足优化问题的要求,即任何偏离作为最小值的极小点附近的变量 \(\mathbf{X}\),一定给出高于最小值的结果来。

下面我们来考虑关于 \(\mathbf{X}\) 的梯度。首先,由于 \(\mathbf{X}\) 在最小值附近,我们假设其附近是凸性的,因此其梯度必然不为零:

energy_autograd.backward()
gX = X.grad
torch.norm(gX)
tensor(6.70115, device='cuda:0', dtype=torch.float64)

其次,我们说起过,\(\mathbf{X}\) 具有反对称的性质。如果让能量函数 \(E_\mathrm{tot} [\mathbf{X}]\) 作反向传播,那么 \(\mathbf{X}\) 的梯度也应当是满足这种反对称性质。

torch.allclose(-gX.T, gX)
True

RHF 的梯度下降求取#

学习率递降 class Scheduler#

这其实是一个非常细节的问题,但却需要不少代码来解决。

我们都知道在深度学习中,梯度下降算法与学习率递降都是非常有必要的。但由于我们需要在训练的过程中始终保证作为自变量的 \(\mathbf{X}\) 具有反对称性,因此它不能像 PyTorch 深度网络中参数一样,使用较为自由的优化方式;而是需要在优化 (机器学习中类似于训练) 过程中始终重新将 \(\mathbf{X}\) 反对称化。

因此,这里暂时不使用 PyTorch 提供的 optim 类的 AdamReduceLROnPlateau 类来解决问题,而需要手动造与学习率有关的轮子;至于梯度下降法只能用 Naive 的版本,而不使用 Adam 等更高级的版本。至于是不是一定不能用 PyTorch 提供的自动化工具,作者暂时还不清楚。

class Sheduler:
    
    def __init__(self, init_lr=0.05, min_lr=1e-6, factor=0.95, patience=3, stop_train=30, stop_thresh=1e-6, debug=True):
        self.lr = init_lr
        self.min_lr = min_lr
        self.factor = factor
        self.patience = patience
        self.stop_train = stop_train
        self.stop_thresh = stop_thresh
        self.debug = debug
        
        self.loss = 1e10
        self.epoch = 0
        
        self.flag_min = False
        self.end_train = False
        self.loss_list = []
        
    def step(self, loss, epoch_id=-1):
        self.epoch += 1
        self.loss_list.append(float(loss))
        if loss < self.loss:
            self.loss = loss
            self.epoch = 0
        if not self.flag_min:
            if self.epoch >= self.patience:
                self.lr *= self.factor
                self.epoch = 0
                print("learning rate decrease to {:7.4f} on epoch {:5d}.".format(self.lr, epoch)) if self.debug else None
            if self.lr < self.min_lr:
                self.flag_min = True
                print("Hit minimum learning rate.") if self.debug else None
        if self.flag_min:
            if self.epoch >= self.stop_train:
                print("End learning at epoch {:5d} since {:5d} epoch loss is larger than lowest loss.".format(epoch, self.stop_train)) if self.debug else None
                self.end_train = True
        if np.all(np.abs(np.array(self.loss_list[-self.stop_train:]) - float(self.loss)) < self.stop_thresh) and len(self.loss_list) > 30:
            print("End learning at epoch {:5d} since last {:5d} epoch loss is allclose to lowest loss".format(epoch, self.stop_train) +
                  " within threshold {:7.2e}.".format(self.stop_thresh)) if self.debug else None
            self.end_train = True

这个类的优化过程稍微有些一点点复杂 (但比起现成的程序已经简单太多),一些简单的说明是:

  • 优化过程的学习率从 init_lr (默认 0.05) 渐渐降低为 min_lr (默认 1e-6),每次降低 factor (默认 0.95) 倍,但降到最低后就不再下降;

  • 学习率降低的判据是,如果连续 patience (默认 3) 次的损失函数大于最低的损失函数值,就降低学习率

  • 上述过程比通常深度学习中的要求高很多;这是因为通常的深度学习不是凸优化问题,在损失函数势能面上游走不见得是坏事;但 RHF 问题我们假设在合理的密度初猜下是凸优化,损失函数不应该经常上升;

  • 停止优化的第一判据是,若学习率 self.lr 降到最低后连续 stop_train (默认 30) 次损失函数的值大于最低的那一次,那么停止优化;

  • 停止优化的第二判据是,若最后连续 stop_train (默认 30) 次损失函数都与最低的那一次之间差不超过 stop_thresh (默认 1e-6) Hartree 大小,那么停止优化。

优化过程#

有了上面文字的铺垫,后面的优化过程若读者有任何一点深度学习的脚本经验,应该是非常容易理解的。这里用到的初始条件是

  • 密度初猜 \(\mathbf{R}_0\):采用 Hamiltonian Core 作为 Fock 矩阵对角化所得密度

  • 初始自变量 \(\mathbf{X}\):采用零矩阵

X = torch.zeros_like(S, requires_grad=True, device=S.device)
R0 = torch.tensor(scf_eng.init_guess_by_1e(), device=S.device)

随后学习率从 0.04 开始递降,进行损失函数 (即能量函数 \(E_\mathrm{tot} [\mathbf{X}]\)) 的优化。

sheduler = Sheduler(init_lr=0.04)

for epoch in range(0, 5000):
    # Calculate loss function
    energy_autograd = calc_EX(X, S, R0, H, eri_ao, energy_nuc)
    energy_autograd.backward()
    # Make optimization step
    t = X - X.grad * sheduler.lr
    # Force anti-symmetrize X
    X = ((t - t.T) / 2).clone().detach().requires_grad_(True)
    # Update learning rate or stop training
    sheduler.step(energy_autograd, epoch)
    if sheduler.end_train:
        print("End at epoch {:5d}.".format(epoch))
        break
print(float(energy_autograd))
learning rate decrease to  0.0380 on epoch     7.
learning rate decrease to  0.0361 on epoch    10.
learning rate decrease to  0.0343 on epoch   182.
learning rate decrease to  0.0326 on epoch   196.
End learning at epoch   859 since last    30 epoch loss is allclose to lowest loss within threshold 1.00e-06.
End at epoch   859.
-150.5850332855298

这就完成了借助于自动求导的 RHF 的能量计算问题!我们可以拿它与 PySCF 作为量化程序计算得到的能量作比较:

energy_tot
-150.5850337808369

小结#

我们在这份文档中简单地讨论了在给定电子积分的情况下,使用 PyTorch 的自动求导功能,以 Naive 梯度下降法给出 RHF 能量。这样的程序既可以在 CPU 下运行,也能在 GPU 下运行。

简单的代码总结可以是,在定义了与量子化学问题无关的矩阵指数 mat_power、矩阵幂 calc_exp、学习率递降管理器 Sheduler 后,我们可以进行 RHF 的能量计算,其代码也非常简单。

分子定义

mol = gto.Mole()
mol.atom = """
O  0.0  0.0  0.0
O  0.0  0.0  1.5
H  1.0  0.0  0.0
H  0.0  0.7  1.0
"""
mol.basis = "6-31G"
mol.verbose = 0
mol.build()
<pyscf.gto.mole.Mole at 0x7fa2800cb908>

电子积分定义

H = torch.tensor(mol.intor("int1e_kin") + mol.intor("int1e_nuc"), device=device)
S = torch.tensor(mol.intor("int1e_ovlp"), device=device)
eri_ao = torch.tensor(mol.intor("int2e"), device=device)

核库伦排斥能定义

natm = mol.natm
Z_A, A_t = mol.atom_charges(), mol.atom_coords()
r_AB = np.linalg.norm(A_t[:, None, :] - A_t[None, :, :], axis=-1)
r_AB += np.diag(np.ones(natm) * np.inf)
energy_nuc = 0.5 * (Z_A[None, :] * Z_A[:, None] / r_AB).sum()

密度矩阵与能量损失函数定义

def calc_RX(X, S, R0):
    return calc_exp(-X @ S) @ R0 @ calc_exp(S @ X)
def calc_EX(X, S, R0, H, eri_ao, energy_nuc):
    RX = calc_RX(X, S, R0)
    return (
        torch.einsum("uv, uv ->", RX, H)
        + 0.5 * torch.einsum("uv, uvkl, kl ->", RX, eri_ao, RX)
        - 0.25 * torch.einsum("uv, ukvl, kl ->", RX, eri_ao, RX)
        + energy_nuc
    )

密度初猜与自变量定义

X = torch.zeros_like(S, requires_grad=True, device=S.device)
R0 = torch.tensor(scf_eng.init_guess_by_1e(), device=S.device)

梯度下降得到 RHF 能量

sheduler = Sheduler(init_lr=0.04, debug=False)

for epoch in range(0, 5000):
    energy_autograd = calc_EX(X, S, R0, H, eri_ao, energy_nuc)
    energy_autograd.backward()
    t = X - X.grad * sheduler.lr
    X = ((t - t.T) / 2).clone().detach().requires_grad_(True)
    sheduler.step(energy_autograd, epoch)
    if sheduler.end_train: break

最终能量

float(energy_autograd)
-150.5850332855298

Autograd (4):PyTorch 高阶自动导数#

创建时间:2019-12-22

PyTorch 的 Autograd 不仅可以作为一阶导数的计算工具,还可以对任意阶的高阶导数作计算。这篇文档我们简单地讨论这个问题。

这篇文档若没有 Stackoverflow 回答 [1],恐怕是不会写出来了。

%matplotlib notebook

import torch
from torch.autograd import grad
import numpy as np
import scipy
import scipy.linalg
from matplotlib import pyplot as plt

torch.set_printoptions(precision=5, sci_mode=False, linewidth=120)

一元函数的高阶导数#

我们仍然拿以下的函数

\[ y (b) = x^3 + 10 \exp \left( - \frac{x^2}{10} \right) \]

其中,当 \(x = 3\) 时,\(y \simeq 31.07\)

x = torch.tensor(3., requires_grad=True)
y = x**3 + 10 * torch.exp(-x**2 / 10)
float(y)
31.065696716308594

根据 Stackoverflow 回答 [1],定义如下高阶求导函数 nth_derivative

def nth_derivative(f, wrt, n):
    for i in range(n):
        if not f.requires_grad:
            return torch.zeros_like(wrt)
        grads = grad(f, wrt, create_graph=True)[0]
        f = grads.sum()
    return grads

其中输入的三个参数分别是 y 因变量,wrt 自变量,n 不小于零的导数阶数。

我们可以很容易地验证其一阶导数结果确实是如文档 Autograd (1) 所述的那样。

\[ \frac{\partial y}{\partial x} = 3 x^2 - 2 x \exp \left( - \frac{x^2}{10} \right) \]
float(nth_derivative(y, x, 1))
24.56058120727539
float(3 * x**2 - 2 * x * torch.exp(-x**2 / 10))
24.56058120727539

但不仅如此,我们还可以求出其二阶导数:

\[\begin{split} \begin{align} u &= \exp \left( - \frac{x^2}{10} \right) \\ \frac{\partial^2 y}{\partial x^2} &= \frac{2}{5} u x^2 + 6 x - 2 u \end{align} \end{split}\]
float(nth_derivative(y, x, 2))
18.6505126953125
u = torch.exp(-x**2 / 10)
float(2 / 5 * u * x**2 + 6 * x - 2 * u)
18.650510787963867

甚至是三阶导数:

\[ \frac{\partial^3 y}{\partial x^3} = - \frac{2}{25} u x^3 + \frac{6}{5} u x + 6 \]
float(nth_derivative(y, x, 3))
6.585460186004639
float(-2 / 25 * u * x**3 + 6 / 5 * u * x + 6)
6.585460186004639

向量二阶自动求导:Newton 法解极值点#

问题的定义#

下面我们以 Newton 法解极小值点为例,来讨论与向量的二阶自动求导有关的问题。

首先,我们的目标是求取下述标量 \(y\) 关于向量 \(x_i\) 函数的极小值:

\[ y = \sum_i A_i x_i + \sum_{ij} B_{ij} x_i x_j + \sum_{ijk} C_{ijk} x_i x_j x_k \]

其中,d \(\dim(i) = \dim(j) = \dim(k) = 2\)

d = 2

为了让极小值基本上确实存在,我们不能随意构造矩阵 \(B_{ij}\),并且让关于 \(i, j\) 角标的矩阵 \(B_{ij} + B_{ji}\) 为正定矩阵。一种构建非对称的 B \(B_{ij}\) 或写作 \(\mathbf{B}\) 的方式是

\[ \mathbf{B} = \exp(-\mathbf{K}) \mathbf{\Lambda} \exp(\mathbf{K}) + \mathbf{K} \]

其中,

  • K \(\mathbf{K}\) 为反对称矩阵;

  • eK \(\exp(-\mathbf{K})\) 为正交矩阵,具有性质 \(\exp(-\mathbf{K})^{-1} = \exp(-\mathbf{K})^\mathrm{T}\)

  • eig \(\mathbf{\Lambda}\) 为对角矩阵,其每个对角元的值是正值。

上述的向量 x \(x_i\),向量 A \(A_i\),反对称矩阵 K \(K_{ij}\),对角矩阵 eig \(\Lambda_{ij} \delta_{ij}\),张量 C \(C_{ijk}\) 的取法都是有条件地任意的。

矩阵 B \(B_{ij}\) 的构造过程如下:

np.random.seed(0)
K = np.random.randn(d, d)
K -= K.T
eK = scipy.linalg.expm(K)
eig = np.abs(np.random.randn(d))
B = eK.T @ np.diag(eig) @ eK + K

其余量的构造过程如下 (其中 C 取了较小的值):

torch.random.manual_seed(0)
A = torch.randn(d)
B = torch.tensor(B, dtype=torch.float)
C = torch.randn(d, d, d) / 25
A
tensor([ 1.54100, -0.29343])
B
tensor([[ 1.60134, -0.98618],
        [ 0.17098,  1.24350]])
C
tensor([[[-0.08715,  0.02274],
         [-0.04338, -0.05594]],

        [[ 0.01613,  0.03352],
         [-0.02877, -0.01613]]])

这里我们指出,极值点问题的解分别为 xmin_0;同时存在鞍点 xmin_1。该问题可以表示如下:

xmin_0 = np.array([-0.4719677, -0.0355982])
xmin_1 = np.array([ 9.3234644,  6.9193769])
y_func = lambda x: A @ x + B @ x @ x + C @ x @ x @ x
x_list = x_list = torch.arange(-5, 15, 0.05)
y_list = [[y_func(torch.tensor([x0, x1]))
           for x0 in x_list]
           for x1 in x_list]
x_list = x_list.numpy()
y_list = torch.tensor(y_list).detach().numpy()
fig, ax = plt.subplots()
cont = ax.contourf(x_list, x_list, y_list, 25)
plt.scatter(*xmin_0, s=100, marker="+")
plt.scatter(*xmin_1, s=100, marker="+")
ax.set_xlabel("dimension 0")
ax.set_ylabel("dimension 1")
ax = fig.colorbar(cont)

二阶自动求导:程序#

事实上,上述函数 \(y\) 并没有下界,这也能从上图中右上角的趋势中看出;因此简单的梯度下降法在初猜偏离极值点附近时,很可能会在学习率 lr 设置得较大时出现这种不收敛的情况:

x = torch.tensor([-10., 10.], requires_grad=True)
print("Initial x:", x)

lr = 0.5
for epoch in range(7):
    y = A @ x + B @ x @ x + C @ x @ x @ x
    print(float(y))
    g = grad(y, x)[0]
    x = x - g * lr
    x = x.detach().clone().requires_grad_()
Initial x: tensor([-10.,  10.], requires_grad=True)
465.36041259765625
-69.391845703125
-13590.681640625
-7374935.0
-1465419038720.0
-5.513727300032158e+22
nan

事实上以上述初猜,即使调变学习率也很难出现任何收敛的趋势。但对于下述使用了 Newton 二阶收敛的做法,一般来说只要学习率 lr 不超过 1,可以迅速收到极值点 (但也可能收到鞍点):

x = torch.tensor([-1., 1.], requires_grad=True) * 10
print("Initial x:", x)

lr = 1
for epoch in range(7):
    y = A @ x + B @ x @ x + C @ x @ x @ x
    print(float(y))
    g = grad(y, x, create_graph=True)[0]
    H = torch.stack([grad(g, x, grad_outputs=arr, create_graph=True)[0] for arr in torch.eye(d)]).detach()
    g = g.detach()
    x = x - torch.inverse(H) @ g * lr
    x = x.detach().clone().requires_grad_()
print("Optimized x:", x)
Initial x: tensor([-10.,  10.], grad_fn=<MulBackward0>)
465.36041259765625
23.22213363647461
0.332691490650177
-0.36113929748535156
-0.3630421757698059
-0.3630422055721283
-0.3630422055721283
Optimized x: tensor([-0.47197, -0.03560], requires_grad=True)

\(y\) 的一阶导数#

我们现在对一个任意确定的 x \(x_i\) 来分析函数 y \(y\) 求导的问题。

\[ y = \sum_i A_i x_i + \sum_{ij} B_{ij} x_i x_j + \sum_{ijk} C_{ijk} x_i x_j x_k \]
torch.random.manual_seed(0)
x = torch.randn(d, requires_grad=True)
y = A @ x + B @ x @ x + C @ x @ x @ x

任何优化问题需要求取一阶导数。一阶导数的求取我们应该已经比较熟悉了:

\[ \frac{\partial y}{\partial x_i} = A_i + \sum_j (B_{ij} + B_{ji}) x_j + \sum_{jk} (C_{ijk} + C_{jik} + C_{kji}) x_j x_k \]

上述推导过程中需要利用到 \(i, j, k\) 角标等价的技巧。

A + (B + B.T) @ x + (C + C.transpose(0, 1) + C.transpose(0, 2)) @ x @ x 
tensor([ 6.09431, -2.24798], grad_fn=<AddBackward0>)

PyTorch 的自动求导程序可以通过如下方式求取一阶导数 g

g = grad(y, x, create_graph=True)[0]
g
tensor([ 6.09431, -2.24798], grad_fn=<AddBackward0>)

\(y\) 的二阶导数#

二阶导数通常也称为 Hessian 矩阵。

\[ \frac{\partial^2 y}{\partial x_i \partial x_j} = (B_{ij} + B_{ji}) + \sum_{k} (C_{ijk} + C_{ikj} + C_{jik} + C_{jki} + C_{kji} + C_{kij}) x_k \]

上式仍然利用了 \(j, k\) 角标等价的技巧。我们用 Cp2 表示六个 \(\mathbf{C}\) 的转置张量之和:

Cp2 = (
    C.permute(0, 1, 2) + C.permute(0, 2, 1)
  + C.permute(1, 0, 2) + C.permute(1, 2, 0)
  + C.permute(2, 0, 1) + C.permute(2, 1, 0)
)

那么 Hessian 矩阵可以写为

B + B.T + Cp2 @ x
tensor([[ 2.39952, -0.79906],
        [-0.79906,  2.35762]], grad_fn=<AddBackward0>)

我们 曾经 指出过,在 PyTorch 中,真正的二阶梯度 H 是可以获得的,但需要依赖矩阵 \(\delta_{ii_0}\) torch.eye(d)。获得二阶梯度的前提是一阶梯度 g 已经被求出,并且一阶梯度的导数图 (create_graph=True 选项) 已经被绘制。

H = torch.stack([grad(g, x, grad_outputs=arr, retain_graph=True)[0] for arr in torch.eye(d)])
H
tensor([[ 2.39952, -0.79906],
        [-0.79906,  2.35762]])

简单了解 SISSO 特征筛选方法在回归问题下应用#

创建时间:2019-09-09

这份文档我们会用非常小的演示模型,简单地讨论 SISSO 的使用与输出。我们会尽可能地理解所有程序的输出文件,但对于 (我所认为的) SISSO 的最关键的两个技术问题,即特征自动构造以及高性能筛选 (以及 \(L_0\) 惩罚的算子选择算法) 问题,我们不做讨论。

SISSO (Sure Independence Screening and Sparsifying Operator) 是欧阳润海提出的算法。其原始文献为 Ouyang, Ghiringhelli et al. [1] [2]

针对回归问题,通过用户给定一系列数据的特征 (Feature) 和目标值 (Target or Property),以及一些 SISSO 特化的设定,可以导出哪些特征、以及特征间的运算所导出的描述子 (Descriptor) (运算包括但不限于四则运算、指数与对数、三角函数等) 会对目标值有最大程度的贡献;从而达到筛选出能最佳地描述目标的描述子的目的。

该算法尽管是出于解决材料设计问题而产生的,但它本身完全可以看作一种纯应用数学的方法。同时,该算法的根基应当被认为是传统机器学习 (非深度学习) 的方法。

由于 SISSO 并不是 Python 程序,因此这份文档中,我们会在 Jupyter 中使用 bash 命令。为了执行这份文档,读者需要事先编译好 SISSO 程序到当前目录的 sisso

%matplotlib notebook

import numpy as np
from numpy import exp, sqrt, log, sin, cos
from sklearn.linear_model import LinearRegression
from sklearn.metrics import mean_squared_error, max_error, r2_score
from matplotlib import pyplot as plt

np.set_printoptions(precision=5, linewidth=150, suppress=True)

运行前的准备#

数据集 (Dataset)#

数据集的构造如下。该数据集的样本数量为 5;目标值在第二列,三个初步的特征列在后三列。该数据集除了名称作简化外,其余与 SISSO Github 上的 train.dat_regression 一致。

! cat train.dat
materials  property  F1      F2      F3
sample1    3.0862    0.8626  0.7043  0.6312
sample2    2.8854    0.7260  0.7818  0.6119
sample3    0.6907    0.4943  0.0044  0.4420
sample4    0.9902    0.0106  0.0399  0.9877
sample5    0.7242    0.0970  0.3199  0.5504

我们之后会经常地用到这些数据,因此会使用变量与数学记号存储与表示。其中,

  • P \(\boldsymbol{P}\) (vector length: \(n_\mathrm{Sample}\)) 目标值

  • F1, F2, F3 \(\boldsymbol{f}_1\), \(\boldsymbol{f}_2\), \(\boldsymbol{f}_3\) (vector length: \(n_\mathrm{Sample}\)) 初始特征

这里指出,train.dat 中的 F1, F2, F3 可以是其它的字符串。同时,这些特征字符串可以通过 Python 的 exec statement 直接执行而不一定需要 Hard Coding;但为了程序可读且易执行,在这篇文档中我们就手动地声明这些变量了。

with open("train.dat", "r") as f:
    dataset = np.array([line.split() for line in f.readlines()])
    dataset = np.array(dataset[1:, 1:], dtype=float)
P, F1, F2, F3 = dataset.T
P
array([3.0862, 2.8854, 0.6907, 0.9902, 0.7242])

SISSO 参数#

SISSO 的参数也参考 Github 上的 SISSO.in_regression;但我们对其中一些设置做了更改。一些重要但也许需要额外说明的参数列举如下:

desc_dim 作为最终筛选结果的描述子的维度限制 \(n_\mathrm{Descriptor}\)。在这篇文档中,我们将“特征”与“描述子”分开;特征是用户输入的样本描述性信息,而描述子是导出的描述性信息。\(n_\mathrm{Descriptor}\) 会在后文使用。

! grep "desc_dim=" SISSO.in
desc_dim=3           ! dimension of the descriptor

rung 描述子所需要的构造次数。譬如,描述子

  • \(f_2\) (恰好也是输入特征) 是 Rung 0 级别的;

  • \(f_2 - f_1\), \(\exp(f_1)\) 等是 Rung 1 级别的 (分别使用了减号、指数);

  • \(\log(f_3) \mathrm{abs} (f_2 - f_1)\) 是 Rung 2 级别的 (其中,\(\log(f_3)\)\(\mathrm{abs} (f_2 - f_1)\) 分别是 Rung 1 级别的,它们之间的运算多了一个乘法,因此为 Rung 2 级别的);

  • \(\sqrt{\log(f_3)}\) 也是 Rung 2 级别的。

! grep "rung=" SISSO.in
rung=2               ! rung of the feature space to be constructed

maxcomplexity 从特征构造描述子时所使用的最多的符号数量。尽管我们设定了最大符号数量为 10,事实上 10 个符号是不可能达到的;因为 Rung 1 最多使用到 1 个符号,Rung 2 最多是两个 Rung 1 描述子的运算即 3 个符号;可以推知 Rung 3 最多使用 7 个符号,Rung \(n\) 最多使用 \(2^n - 1\) 个符号。

! grep "maxcomplexity=" SISSO.in
maxcomplexity=10     ! max feature complexity (number of operators in a feature)

dimclass 第 1, 2 号特征 \(f_1\), \(f_2\) 共享相同的物理单位,第 3 号特征 \(f_3\) 有独立的物理单位。这会避免构造类似于 \(f_1 - f_3\) 等不符合量纲的描述子。

! grep "dimclass=" SISSO.in
dimclass=(1:2)(3:3)  ! group features according to their dimension/unit; those not in any () are dimensionless

subs_sis 对于每个描述子维度,选取最好的 7 个描述子作为备选 \(n_\mathrm{sub}\)。后文会对其作补充说明。

! grep "subs_sis=" SISSO.in
subs_sis=7           ! SAME one size for every SIS-selected subspace. Otherwise, input a size for each dimension seperated by comma

SISSO 运行与结果#

当设置完毕数据集 train.dat 与 SISSO 设定 SISSO.in 后,就可以执行 SISSO 程序 sisso 了。SISSO 可以单核计算,也可以 MPI 多核计算。我们将屏幕输出定向到文件 SISSO.log,该文件没有实质性内容。

! mpirun -np 4 ./sisso > SISSO.log
[DESKTOP-8BRL880:01147] 3 more processes have sent help message help-btl-vader.txt / cma-permission-denied
[DESKTOP-8BRL880:01147] Set MCA parameter "orte_base_help_aggregate" to 0 to see all help / error messages

但程序会输出大量其他信息。最重要的信息放在 SISSO.out 文件中:

! grep -A 10000 "Final model" SISSO.out | grep -B 10000 "===="
Final model/descriptor to report
================================================================================
  3D descriptor (model): 
Total RMSE,MaxAE:   0.000083  0.000137
@@@descriptor: 
                      5:[((F2)^6/log(F1))]
                     12:[(exp(-F3)/abs(F1-F2))]
                     16:[((F1)^3/(F1-F2))]
       coefficients_001:    -0.2813852886E+01    0.2638598223E-01    0.4721473413E-02
          Intercept_001:     0.6547943803E+00
         RMSE,MaxAE_001:     0.8272942564E-04    0.1372716760E-03
================================================================================

这就意味着,从 SISSO 中导出的最佳描述子是 @@@descriptor: 下的三个描述子;这三行是所有输出中最为关键的信息:

\[\begin{split} \begin{align} D_1 &= f_2^6 \log(f_1)^{-1} \\ D_2 &= \exp(- f_3) |f_1 - f_2|^{-1} \\ D_3 &= f_1^3 (f_1 - f_2)^{-1} \\ \end{align} \end{split}\]

这从这三个描述子出发作 (包含截距 Interception,或者称偏置 Bias 的) 线性拟合:

\[ \tilde P = C_1 D_1 + C_2 D_2 + C_3 D_3 + b \]

可以得到参数 \(C_1, C_2, C_3\)、偏置 \(b\) 与误差 \(\boldsymbol{\tilde{P}} - \boldsymbol{P}\) 向量的 RMSE 值与 MaxAE 值。

我们可以用 scikit-learn 来验证上述结果。

D1 = ((F2)**6/log(F1))
D2 = (exp(-F3)/abs(F1-F2))
D3 = ((F1)**3/(F1-F2))
reg = LinearRegression()
reg.fit(np.array([D1, D2, D3]).T, P)
LinearRegression()

线性拟合参数 \(C_1, C_2, C_3\) 与偏置 \(b\) 列举为

reg.coef_, reg.intercept_
(array([-2.81385,  0.02639,  0.00472]), 0.6547943803493903)

RMSE 误差与 MaxAE 误差分别可以表达为

P_pred = reg.predict(np.array([D1, D2, D3]).T)
sqrt(mean_squared_error(P, P_pred)), max_error(P, P_pred)
(8.272942030223837e-05, 0.0001372716671202978)

如果用户不关心细节与中间输出,我们就只需要了解到这里即可。

SISSO 第一轮描述子筛选#

描述子表达式及其在数据点上的值#

SISSO 会经历多次描述子筛选。SISSO 每次都会构建数以万计,甚至亿记的描述子空间,随后依据线性拟合判标筛选出最好的 \(n_\mathrm{sub}\) 个描述子。我们不讨论 SISSO 是如何构建庞大的描述子空间的过程。一般来说desc_dim 的数量决定了特征筛选次数 (如果从特征构造出的描述子的空间不够大,筛选次数会变少;SISSO 会对这种情形作警告)。因此,当前的 SISSO 会有三次特征筛选。筛选的结果会储存在 feature_space 目录下。

我们先拿第一次筛选结果讨论。第一次所选出的描述子储存在下述文件中:

! cat feature_space/space_001d.name
((F1*F2)/log(F3))  corr=      0.9953
cos((F1*F2))  corr=      0.9949
((F1*F2))^2  corr=      0.9949
((F1*F2)*(F1+F2))  corr=      0.9948
((F2)^6/log(F1))  corr=      0.9947
((F1)^2*(F2)^3)  corr=      0.9946
((F1*F2)*sqrt(F1))  corr=      0.9938

Python 提供了 eval 函数,可以直接将特征的算式读入并作运算。我们使用该功能给出特征运算后得到的描述子矩阵 d_1D \(\mathbf{d}_\mathrm{1D}\) (matrix dim: \((n_\mathrm{sub}, n_\mathrm{sample})\))。

with open("feature_space/space_001d.name", "r") as f:
    tokens_1D = [line.split()[0].replace("^", "**") for line in f.readlines()]
d_1D = np.array([eval(token) for token in tokens_1D])
d_1D
array([[-1.32034, -1.15554, -0.00266, -0.03417, -0.05197],
       [ 0.82106,  0.8432 ,  1.     ,  1.     ,  0.99952],
       [ 0.36909,  0.32215,  0.     ,  0.     ,  0.00096],
       [ 0.95194,  0.85581,  0.00108,  0.00002,  0.01294],
       [-0.82577, -0.71309, -0.     , -0.     , -0.00046],
       [ 0.25995,  0.25186,  0.     ,  0.     ,  0.00031],
       [ 0.56425,  0.48362,  0.00153,  0.00004,  0.00966]])

譬如第一行代表在所有 \(n_\mathrm{sample} = 5\) 个数据点下,运算 \(f_1 f_2 \log (f_3)^{-1}\) 的结果:

((F1*F2)/log(F3))
array([-1.32034, -1.15554, -0.00266, -0.03417, -0.05197])

在 SISSO 原文中,也会用 \(\boldsymbol{S}_1\) 代表第一轮筛选得到的描述子空间。它只是一种符号表示,而不是可以用程序执行的记号。

相关性#

我们注意到文件 feature_space/space_001d.name 除了包含描述子的具体构造之外,还包括相关性说明。这种相关性实际上是筛选出的描述子与目标值之间的线性相关程度。譬如,对第 0 个描述子,\(\boldsymbol{d}_\mathrm{1D}^0\) 即所给出的每个数据点对应的值为

d_1D[0]
array([-1.32034, -1.15554, -0.00266, -0.03417, -0.05197])

这也与文件 feature_space/space_001d_p001.dat 的输出有关。该文件的首列是目标值,随后是描述子在给定数据点上的值:

! cat feature_space/space_001d_p001.dat
    0.3086200000E+01   -0.1320335268E+01    0.8210609668E+00    0.3690917046E+00    0.9519374721E+00   -0.8257705126E+00    0.2599512875E+00    0.5642503915E+00
    0.2885400000E+01   -0.1155542560E+01    0.8432007625E+00    0.3221547755E+00    0.8558073770E+00   -0.7130919424E+00    0.2518606035E+00    0.4836159293E+00
    0.6907000000E+00   -0.2663889108E-02    0.9999976349E+00    0.4730277006E-05    0.1084632604E-02   -0.1029830186E-13    0.2081321883E-07    0.1529109520E-02
    0.9902000000E+00   -0.3417345965E-01    0.9999999106E+00    0.1788782436E-06    0.2135847000E-04   -0.8874049547E-09    0.7137241920E-08    0.4354433812E-04
    0.7242000000E+00   -0.5196747734E-01    0.9995185989E+00    0.9628795181E-03    0.1293653207E-01   -0.4593698157E-03    0.3080251578E-03    0.9664332013E-02

它实际上与目标值有比较好的线性关系:

P
array([3.0862, 2.8854, 0.6907, 0.9902, 0.7242])

可能光是看这两组向量并不能清楚地把握情况。它们的线性关系可能通过两种方式呈现。一种是 Perason R2 的开方,即线性拟合的拟合优度 R 量标:

reg = LinearRegression()
reg.fit(d_1D[0].reshape(-1, 1), P)
np.sqrt(r2_score(P, reg.predict(d_1D[0].reshape(-1, 1))))
0.9952505043650735

上述值与 feature_space/space_001d.name 的结果相同。

另一种则是绘图呈现。我们不妨将所有 7 个描述子都用绘图呈现:

fig, ax = plt.subplots(figsize=(5, 3))
for i, d in zip(range(7), d_1D):
    ax.scatter(P, d, label="Desp {:}".format(i))
    reg = LinearRegression()
    reg.fit(d.reshape(-1, 1), P)
    ax.plot(reg.predict([[-1.5], [1.5]]), [-1.5, 1.5], linestyle=":")
ax.set_xlim(0, 4)
ax.set_ylim(-1.5, 1.2)
ax.set_xlabel("Target $P$")
ax.set_ylabel("Descriptor Value $d_\mathrm{1D}$")
ax.legend(loc='lower right', bbox_to_anchor=(1, 0., 0.5, 0.5))
fig.tight_layout()

残差与后一轮描述子筛选准备#

第一轮描述子筛选已经结束了;我们需要对第二轮描述子筛选作准备。

第一轮筛选的依据是描述子与目标值 \(\boldsymbol{P}\) 相关性。但我们知道,多个描述子之间一般不应当具有较强的相关性,否则这些描述子会被认为是接近线性相关的。

为了尽可能保证第二轮所选取的描述子与第一轮线性无关,第二轮筛选的依据是第一轮描述子中,对目标 \(\boldsymbol{P}\) 作线性拟合得到的最低残差 Delta_1D \(\boldsymbol{\Delta}_\mathrm{1D}\) (vector len: \(n_\mathrm{sample}\)) (或者说第一轮第 0 个描述子的拟合残差):

\[ \boldsymbol{\Delta}_\mathrm{1D} = \boldsymbol{P} - \boldsymbol{\tilde{P}} (\boldsymbol{d}_\mathrm{1D}^0) \]
reg = LinearRegression()
reg.fit(d_1D[0].reshape(-1, 1), P)
LinearRegression()
Delta_1D = P - reg.predict(d_1D[0].reshape(-1, 1)).reshape(-1)
Delta_1D
array([-0.04482,  0.05149, -0.06466,  0.17803, -0.12005])

这与文件 residual/res_001d_p001.dat 的结果一致:

! cat residual/res_001d_p001.dat
   -0.4481707500E-01
    0.5149135000E-01
   -0.6465814650E-01
    0.1780325505E+00
   -0.1200486783E+00

由于拟合过程中使用到了偏置 (Bias),因此原文中出现的类似于 \(\boldsymbol{\Delta}_\mathrm{1D} = \boldsymbol{P} - \boldsymbol{c}_\mathrm{1D}^{0, \mathrm{T}} \boldsymbol{d}_\mathrm{1D}^0\) 的公式表述会在实现中会有些不同。

SISSO 第二轮描述子筛选#

描述子的表达与相关性#

第二轮描述子筛选与第一轮过程几乎是相同的;但唯一不同的是筛选描述子的判标从 \(\boldsymbol{P}\) 更换到 \(\boldsymbol{\Delta}_\mathrm{1D}\)。因此,第二轮所选出的描述子几乎都与第一轮的第 0 号描述子某种程度上线性无关;或者说,这些描述子并不是希望能与最终目标值 \(\boldsymbol{P}\) 有很好的契合,但其实是契合最终目标与第一轮最好的描述子之间的误差。但第二轮描述子之间又比较线性相关,因为它们都需要较好地线性拟合到相同的 \(\boldsymbol{\Delta}_\mathrm{1D}\)

第二轮筛选得到的描述子和相关性列举如下:

! cat feature_space/space_002d.name
(abs(F1-F2))^-1  corr=      0.9693
(sqrt(F3)/abs(F1-F2))  corr=      0.9612
(sin(F3)/abs(F1-F2))  corr=      0.9540
(exp(F3)/abs(F1-F2))  corr=      0.9534
(exp(-F3)/abs(F1-F2))  corr=      0.9486
(F3/abs(F1-F2))  corr=      0.9474
(cos(F2)/abs(F1-F2))  corr=      0.9468

可以将所有第二轮描述子在所有数据点上的值储存到矩阵 d_2D \(\mathbf{d}_\mathrm{2D}\)

with open("feature_space/space_002d.name", "r") as f:
    tokens_2D = [line.split()[0].replace("^", "**") for line in f.readlines()]
d_2D = np.array([eval(token) for token in tokens_2D])

与第一轮描述子相同地,第二轮描述子空间会记为 \(\boldsymbol{S}_2\)

对于第二轮第 0 个描述子,其相对于 \(\boldsymbol{\Delta}_\mathrm{1D}\) 所拟合的相关性可以用下述程序给出:

reg = LinearRegression()
reg.fit(d_2D[0].reshape(-1, 1), Delta_1D)
np.sqrt(r2_score(Delta_1D, reg.predict(d_2D[0].reshape(-1, 1))))
0.9692849978551799

这与上述文件的首行信息相同。

残差与后一轮描述子筛选准备#

从第二轮描述子筛选开始,残差的表达式会较为不同。

我们现在已经筛选出 \(2 n_\mathrm{sub} = 2 \times 7 = 14\) 个描述子,我们会记两轮描述子的并空间为 \(\boldsymbol{S}_1 \cup \boldsymbol{S}_2\)

如果描述子空间是 \(\boldsymbol{S}_1\) 且我们只需要挑出一个描述子来线性拟合最终目标 \(\boldsymbol{P}\),那么这个被挑出的最好的描述子就是第一轮中第 0 个描述子。但如果描述子空间是 \(\boldsymbol{S}_1 \cup \boldsymbol{S}_2\),并且要挑出两个描述子来线性拟合最终目标 \(\boldsymbol{P}\),那就不一定是第一轮第 0 个与第二轮第 0 个描述子了。这种情况下,我们有必要将所有描述子成对地考察一遍。

现在该空间有 14 个描述子;如果允许一对相同的描述子,那么总共有 \(14 \times 14 = 196\) 对可能的描述子。我们需要对这 196 种所有可能的情况都作考察;最终判断哪一对描述子是最优秀的。

我们开一个 \((14, 14)\) 大小的矩阵 rmse_2D,以 RMSE 为判标,判断一对描述子能否比较好地拟合最终目标 \(\boldsymbol{P}\)dunion_2D 表示两轮描述子在数据点上数值的矩阵并 \([\mathbf{d}_\mathrm{1D}, \mathbf{d}_\mathrm{2D}]\)

rmse_2D = np.zeros((14, 14))
dunion_2D = np.concatenate([d_1D, d_2D])
for i in range(14):
    for j in range(14):
        reg = LinearRegression()
        reg.fit(np.array([dunion_2D[i], dunion_2D[j]]).T, P)
        P_pred = reg.predict(np.array([dunion_2D[i], dunion_2D[j]]).T)
        rmse_2D[i, j] = np.sqrt(mean_squared_error(P, P_pred))

我们对上述 rmse_2D 作热区图 (heatmap):

fig, ax = plt.subplots(figsize=(4, 3))
img = ax.imshow(np.log(rmse_2D), cmap="PuOr")
cbar = ax.figure.colorbar(img, ax=ax)
cbar.ax.set_ylabel("Logscale of RMSE", rotation=-90, va="bottom")
fig.tight_layout()

从上图中,我们可以看出,大多数情况下,当一个描述子处在 \(\boldsymbol{S}_1\) 空间,另一个描述子处在 \(\boldsymbol{S}_2\) 时 (即上述矩阵的非对角块,棕黄色部分),拟合的结果通常会比较好。这与描述子的筛选过程有着直接的关系。尽管绝大多数情况下,描述子要处于不同的空间中才能得到好的结果;但还可能有极少量例外。

经过简单的查找后,应当能知道当选取 \(\boldsymbol{S}_1\) 的第 2 个描述子,\(\boldsymbol{S}_2\) 的第 0 个描述子时,给出的线性拟合结果是最佳的。其残差 Delta_2D \(\boldsymbol{\Delta}_\mathrm{2D}\) 写为

reg = LinearRegression()
reg.fit(np.array([d_1D[2], d_2D[0]]).T, P)
Delta_2D = P - reg.predict(np.array([d_1D[2], d_2D[0]]).T)
Delta_2D
array([ 0.00406, -0.00466, -0.00297,  0.00161,  0.00196])

这与文件 residual/res_002d_p001.dat 的结果一致:

! cat residual/res_002d_p001.dat
    0.4058658000E-02
   -0.4655793000E-02
   -0.2972292700E-02
    0.1614245300E-02
    0.1955182100E-02

残差 Delta_2D \(\boldsymbol{\Delta}_\mathrm{2D}\) 就将会作为第三轮描述子筛选过程的指标了。

同时,上述拟合过程给出的 RMSE 值也与 models/top0091_002d 文件一致。其中,SISSO 输出的 Feature_ID 一栏给出的是 1-index 的 \(\boldsymbol{S}_1 \cup \boldsymbol{S}_2\) 的序号。

! cat models/top0091_002d | head -n 5
        Rank        RMSE       MaxAE  Feature_ID
           1    0.003268    0.004656  (       3       8)
           2    0.004235    0.005166  (       2       9)
           3    0.005273    0.006898  (       5       8)
           4    0.005682    0.008797  (       2       8)

事实上,第三轮描述子筛选的过程与第二轮过程是非常类似的。我们之后就不再对第三轮进行说明。

最终模型与备选模型#

作为用户,尽管中间过程与中间输出非常重要,也确实地能加深对 SISSO 算法的了解;但我们所最关心的是最终的结果。我们前面已经提及了从输出文件 SISSO.out 查看最佳模型;但 SISSO 的输出中,还包含众多拟合效果也尚可的各种备选模型。

由于我们在设置中选择了 desc_dim 为 3,即最终描述子数量为 3;因此在查看模型时,我们也要找到 model 文件夹下末尾为 003d 为名的文件。我们打出前 6 行:

! cat models/top0100_003d | head -n 6
        Rank        RMSE       MaxAE  Feature_ID
           1    0.000083    0.000137  (       5      12      16)
           2    0.000097    0.000147  (       4       5      11)
           3    0.000100    0.000165  (       5      12      20)
           4    0.000107    0.000178  (       6      12      17)
           5    0.000109    0.000182  (       6      12      18)

上述的排名是通过 RMSE 为量标给出的。我们会发现,排名第一的模型就是 SISSO.out 所给出的模型 (回见 上文)。线性拟合的参数在下述文件:

! cat models/top0100_003d_coeff | head -n 6
Model_ID, [(c_i,i=0,n)_j,j=1,ntask]
           1    0.6547943803E+00   -0.2813852886E+01    0.2638598223E-01    0.4721473413E-02
           2    0.6781322860E+00    0.1483762489E+01   -0.1156767698E+01    0.3405072802E-02
           3    0.6563705862E+00   -0.2822110944E+01    0.2627937583E-01    0.3542080508E-02
           4    0.6562623115E+00    0.8694024465E+01    0.2629715431E-01    0.2124739537E-01
           5    0.6560493168E+00    0.8311600413E+01    0.2628677203E-01    0.9378780197E+01

描述子的编号 (Feature_ID) 储存在下述文件中。但需要注意,该文件中的 corr= 以后所表现的相关性并不是统一的,因此不具有意义;有意义的部分只有描述子表达式。

! cat -n feature_space/Uspace.name
     1	((F1*F2)/log(F3))  corr=      0.9953
     2	cos((F1*F2))  corr=      0.9949
     3	((F1*F2))^2  corr=      0.9949
     4	((F1*F2)*(F1+F2))  corr=      0.9948
     5	((F2)^6/log(F1))  corr=      0.9947
     6	((F1)^2*(F2)^3)  corr=      0.9946
     7	((F1*F2)*sqrt(F1))  corr=      0.9938
     8	(abs(F1-F2))^-1  corr=      0.9693
     9	(sqrt(F3)/abs(F1-F2))  corr=      0.9612
    10	(sin(F3)/abs(F1-F2))  corr=      0.9540
    11	(exp(F3)/abs(F1-F2))  corr=      0.9534
    12	(exp(-F3)/abs(F1-F2))  corr=      0.9486
    13	(F3/abs(F1-F2))  corr=      0.9474
    14	(cos(F2)/abs(F1-F2))  corr=      0.9468
    15	((F1)^6/(F1-F2))  corr=      0.8401
    16	((F1)^3/(F1-F2))  corr=      0.8265
    17	((F1*F2)/(F1-F2))  corr=      0.8177
    18	((F2)^6*(F1-F2))  corr=      0.8177
    19	((F1)^2/(F1-F2))  corr=      0.8100
    20	((F2)^2/(F1-F2))  corr=      0.7965
    21	((F2)^3/(F1-F2))  corr=      0.7951

其中一些简单的结论会是:

  • 在较少描述子下结果良好的模型,并不意味着更多描述子下也会有更好的表现。譬如,1 号描述子在单描述子下表现最好,3, 8 号描述子在双描述子下表现最好;但这些描述子都没有在最终的三描述子模型的前五名中出现。

  • 一般来说,比较好的模型中,三个描述子分别会出现在三个描述子集 \(\boldsymbol{S}_1, \boldsymbol{S}_2, \boldsymbol{S}_3\) 中。但排名第 2 的模型是例外:两个描述子出现在 \(\boldsymbol{S}_1\),而一个描述子出现在 \(\boldsymbol{S}_2\) 中。

  • 最好的若干个模型的线性拟合 RMSE 误差并不能说相差很大。

  • 抛开偏置项 (Bias,即文件 models/top0100_003d_coeff 的首列) 看,越靠前的描述子对预测值的贡献越大;但三个描述子分配不均的排名第 2 模型的情况会是例外,并且也存在排名第 5 的例外情况。

实际上,SISSO 给出了许多可供替代的模型。最佳和稍次的模型的表现未必相差很大;若表现稍次的模型所使用的描述子可能更符合物理直觉,那么这些描述子也不应被忽视。所以,从这个角度上讲,SISSO 不只是推荐一个具体的模型,而可以是推荐一系列表现相近的模型与描述子。譬如就上面的例子而言,用户一定会发现到描述子子结构 \(f_1 f_2, \mathrm{abs} (f_1 - f_2)^{-1}, (f_1 - f_2)^{-1}\) 的重要性。因此,SISSO 可以启发用户使用这些描述子描述物理或材料问题的目的,或者从这些描述子出发进行下一步的机器学习过程。


Kennard-Stone 采样方法在 Python/C 下的高效实现 \(O(N^2)\)#

创建时间:2021-01-29

在这篇文档中,我们会非常简单地回顾 Kennard-Stone 采样方法的原理,并集中精力讨论其算法实现方式。

标题的 \(O(N^2)\)\(N\) 表示的是样本数量 \(n_\mathrm{sample}\)。严格的计算复杂度是 \(O(n_\mathrm{sample}^2 n_\mathrm{feature} + n_\mathrm{sample} n_\mathrm{result})\),其中 \(n_\mathrm{feature}\) 为特征向量长度且认为是常数值,\(n_\mathrm{result}\) 为采样数。

Kennard-Stone Algorithm 的原始文献是 Kennard, Stone [1]

Kennard-Stone 采样的重要性在于,对于绝大多数机器学习方法而言,选择一个较好的采样方式构建训练集,会一定程度地增强泛化能力。该采样方法在小样本数据集中较常使用;大样本数据集大多采用随机分割方法。

这篇文档的特色有四处:

  • 通过 Python 中的 numpy 函数,可以并行、低额外内存消耗、且快速地构建欧式距离 (Euclidean Distance) 矩阵;

  • 通过活用 numpy 的函数,可以高效串行地实现 Kennard-Stone 采样方法,且相信比目前大多数开源方案快很多;

  • 通过 C/OpenMP 与 ctype binding,可以并行且进一步压榨采样效率。

  • 通过高效的程序实现,对于 Intel-i5 7300HQ 的 4 核个人电脑,30000 个样本 (特征向量长度为 100) 的采样时间最快仅仅需要不到 10 秒。如果有更多的内存,这几乎意味着十万 (100k) 级别的数据可以迅速以 Kennard-Stone 方法采样。

需要注意,C 语言的程序需要在 gcc 的编译器上实现。同时,计算过程采用了单浮点,因此可能会在超级大样本中产生结果的差异。

import numpy as np
import numpy.ma as ma

np.set_printoptions(precision=6, linewidth=120, suppress=True)
np.random.seed(0)

Python 实现#

随机数据集#

首先我们创造一个随机数据集,该数据集包含 5000 个样本。

  • n_sample \(n_\mathrm{sample}\) 为样本数。现在定为 5000。

  • n_feature \(n_\mathrm{feature}\) 为特征数 (描述样本性质的数据)。现在定为 100。

  • X \(\mathbf{X}\) 为完整的数据集。它是一个 \((n_\mathrm{sample}, n_\mathrm{feature})\) 大小的矩阵。

    • 单个数据写为向量 \(\boldsymbol{x}_{i}\),矩阵元写为 \(x_{ia}\)。其中,\(i, j, k, \cdots\) 为样本角标,\(a\) 为特征角标。

n_sample  = 5000
n_feature = 100
X = np.random.randn(n_sample, n_feature)
X *= 100

由于这份文档的目标是说明代码的实现方式与效率,因此不打算具体地用小数据作结果演示,而是用使用中等的数据集。

距离矩阵的生成 (欧氏距离)#

两个样本之间的欧氏距离 (Euclidean Distance) 定义为

\[ d_{ij} = \Vert \boldsymbol{x}_i - \boldsymbol{x}_j \Vert_2 = \sqrt{\sum_a (x_{ia} - x_{ja})^2} \]

因此,距离矩阵 \(\mathbf{D}\) 会是一个维度为 \((n_\mathrm{sample}, n_\mathrm{sample})\)

但如果直接用上式计算欧式距离,计算代价会稍大。因此,一个取巧的方法是利用矩阵相乘的结果。

定义中间变量的矩阵 \(\mathbf{Y} = \mathbf{X} \mathbf{X}^\dagger\),并定义向量 \(\boldsymbol{t} = \mathrm{diag} (\mathbf{Y})\)\(\mathbf{Y}\) 的对角线,或写成

\[ y_{ij} = \sum_{a} x_{ia} x_{ja}, \quad t_i = y_{ii} \]

那么距离矩阵的平方就可以写为

\[ d_{ij}^2 = \sum_a (x_{ia} - x_{ja})^2 = \sum_a x_{ia}^2 - \sum_a x_{ia} x_{ja} + \sum_a x_{ia}^2 = t_i - 2 y_{ij} + t_j \]

这就可以很大程度上简化计算量,并且不会引入很大的内存损耗。对于 numpy,由于 Boardcasting 机制,内存消耗可以是严格的 \(n_\mathrm{sample}^2 + n_\mathrm{sample}\)

计算复杂度是 \(O(n_\mathrm{sample}^2 n_\mathrm{feature})\),这是因为两 \((n_\mathrm{sample}, n_\mathrm{sample})\) 维度的矩阵相乘复杂度便是如此。

现在我们考察的实现过程:

  • dist 距离矩阵 \(\mathbf{D}\) 或写成 \(d_{ij}\),维度 \((n_\mathrm{sample}, n_\mathrm{sample})\);它是这一步的输出。

  • t 中间变量矩阵 \(\boldsymbol{t} = \mathrm{diag} (\mathbf{Y})\) 或写成 \(t_i\),维度 \((n_\mathrm{sample}, )\)

def get_dist(X):
    dist = X @ X.T              # - 1
    t = dist.diagonal().copy()  # - 2
    dist *= -2                  # - 3
    dist += t[:, None]          # - 4
    dist += t[None, :]          # - 5
    return np.sqrt(dist)        # - 6

其具体算法过程是

  1. 生成 \(y_{ij} = \sum_a x_{ia} x_{ja}\),并将 \(y_ij\) 储存在 dist 中;

  2. 生成 \(t_i = y_{ii}\),并将 \(t_i\) 储存在 t 中;

  3. 生成 \(- 2 y_{ij}\),覆盖在 dist 中;

  4. 生成 \(- 2 y_{ij} + t_i\),覆盖在 dist 中;

  5. 生成 \(- 2 y_{ij} + t_i + t_j\),覆盖在 dist 中;

  6. 生成 \(\sqrt{- 2 y_{ij} + t_i + t_j}\),覆盖在 dist 中。

那么当前样本所对应的距离矩阵就通过下述代码生成:

%%time
dist = get_dist(X)
CPU times: user 592 ms, sys: 61.2 ms, total: 654 ms
Wall time: 223 ms

最后指出,这部分代码显然应当可通过 C 语言加速,但由于 numpy 的底层计算实现已经利用了 C 语言与经过优化的 Blas,因此我认为没有必要对距离矩阵的生成再使用 C 语言优化。

默认的种子样本:距离最远的两个样本#

Kennard-Stone 采样需要基于一部分“种子样本”(Seed) 进行。令记号 \(s_i\) 代表种子样本,\(n_\mathrm{seed}\) 代表种子数量。

默认的种子是距离最远的两个样本,即 \(n_\mathrm{seed} = 2\)

\[ (s_0, s_1) = \arg \max_{(i, j)} d_{ij} \text{, or equilvently, } d_{s_0 s_1} = d_{s_1 s_0} = \max_{(i, j)} d_{ij} \]

寻找最大的元素位置可以直接地通过 numpy.argmax 函数得到。

下面定义一些程序变量:

  • max_indexes:距离最远的两个样本指标 \(\arg \max_{(i, j)} d_{ij}\),为 tuple 型变量;

  • max_dist:最远的距离 \(d_\mathrm{max} = \max_{(i, j)} d_{ij}\)

  • seed:种子样本 \(\boldsymbol{s}\) 或写为 \(s_i\),维度为 \((n_\mathrm{seed}, )\);它原则上可以是任何非空样本集,但默认是 \((s_0, s_1) = \arg \max_{(i, j)} d_{ij}\)

%%time
max_indexes = np.unravel_index(np.argmax(dist), dist.shape)
max_dist = dist[max_indexes]
seed = max_indexes
seed
CPU times: user 78.7 ms, sys: 0 ns, total: 78.7 ms
Wall time: 19.7 ms
(450, 4092)

Kennard-Stone 采样#

\(r_0, r_1, \cdots, r_{n - 1}\) 代表的是第 \(0, 1, \cdots, n-1\) 次采样的样本序号。现在需要采样第 \(n\) 个样本。

如果我们现在知道,对于任意未采样的第 \(i\) 个样本,在第 \(n\) 步时有最小距离向量

\[ {}^n m_i = \min \{ d_{r_0 i}, d_{r_1 i}, \cdots, d_{r_{n-1} i} \} \]

那么第 \(n\) 步被采样的样本应当是 (到已采样点最小距离能取到最大值的样本)

\[ r_n = \underset{i \not \in \{ r_0, r_1, \cdots, r_{n-1} \}}{\text{arg max}} \big\{ \min \{ d_{r_0 i}, d_{r_1 i}, \cdots, d_{r_{n-1} i} \} \big\} = \underset{i \not \in \{ r_0, r_1, \cdots, r_{n-1} \}}{\text{arg max}} {}^n m_i \]

需要注意,上式的 \(i\) 必须是在未采样的样本中得到的;但这个记号较为冗余,因此后文会省略。

随后需要为下一步采样过程作准备。下一步需要使用到更新后的最小距离向量

\[ {}^{n+1} m_i = \min \{ d_{r_0 i}, d_{r_1 i}, \cdots, d_{r_{n-1} i}, d_{r_n i} \} \]

这个向量的构建对于单个数据 \(i\) 来说,不需要 \(O(n)\) 的计算复杂度,因为上式等价于

\[ {}^{n+1} m_i = \min \{ {}^{n} m_i, d_{r_n i} \} \]

随后就可以得到 \(r_{n+1}\),以此类推。这是算法可以变得较快的实际原因。

Kennard-Stone 算法的简述就是这些。对于程序作下述说明。

函数输入与输出

已经定义的量是

  • 输入量 dist 距离矩阵 \(d_{ij}\),维度 \((n_\mathrm{sample}, n_\mathrm{sample})\)

  • 输入量 seed 种子样本 \(s_i\),维度 \((n_\mathrm{seed}, )\)

还需要定义的

  • 输入量 n_result \(n_\mathrm{result}\) 待采样数据数量;这里我们进行 4990 个样本采样;

  • 输出量 result \(\boldsymbol{r}\)\(r_i\) 为采样结果序号,维度 \((n_\mathrm{result}, )\),并依照采样顺序记录结果;

  • 输出量 v_dist \(\boldsymbol{v}\)\(v_i\) 为采样距离,维度 \((n_\mathrm{result}, )\)

    \[ v_{i} = \max_j {}^{i + 1} m_j = \max_j \big\{ \min \{ d_{r_0 j}, d_{r_1 j}, \cdots, d_{r_i j} \} \big\} \]

中间变量定义 (Definition: Intermediate Variables)

  • n_seed 种子样本的维度 \(n_\mathrm{seed}\)

  • selected 储存是否已被采样的信息向量,维度 \((n_\mathrm{sample}, )\)

  • min_vals 最小值向量 \({}^n \boldsymbol{m}\)\({}^n m_i\),维度 \((n_\mathrm{sample}, )\)

初始化过程 (--- Initialization ---)

  1. 首先将种子 \((s_0, s_1, \cdots, s_{n_\mathrm{seed}})\) 放到结果中,即令 \(r_0 = s_0\), \(r_1 = s_1\), \(\cdots\), \(r_{n_\mathrm{seed}} = s_{n_\mathrm{seed}}\)

  2. 如果种子样本数量恰为 2,那么就认为选取了欧氏距离最远的两个点,并认为 \(v_0 = d_{s_0 s_1} = \max_{(i, j)} d_{ij}\)

  3. 初始化种子样本下的最小距离向量。注意到 \(r_0 = s_0\),因此

    \[ {}^1 m_i = \min \{ d_{r_0 i} \} = d_{s_0 i} \]
  4. 确定一个值 upper_bound,使得对于任意的 \(n, i\),都有 \({}^n m_i \leqslant \mathtt{upper\_bound}\)。这是利用了 \({}^n m_i \leqslant {}^n m_i \leqslant \max_i {}^n m_i = \mathtt{upper\_bound}\) 的性质而写的

    • 这个变量存在与否不影响算法本身,它是出于 numpy.min 函数在含有 where 传参时,必须要指定一个 initial 参数而定;

  5. 对于所有未选中的样本,都使用下述方式迭代更新最小距离向量:

    \[ {}^{n+1} m_i = \min \{ {}^{n} m_i, d_{r_n i} \} = \min \{ {}^{n} m_i, d_{s_n i} \} \]

    注意如果 \(i\) 已经在被采样样本中,就无需作更新了;通过 selected 确定是否要更新样本 \(i\)

循环过程 (--- Loop argmax minimum ---)

循环的目的是确定第 \(n\) n 个样本所在位置。\(n\) 从种子数 \(n_\mathrm{seed}\) 开始,到采样数 \(n_\mathrm{result}\) 结束。

  1. 获得采样的样本位置 \(r_n = \arg \max_i {}^n m_i\),并储存在临时变量 sup_index 中;

  2. 将样本位置放到向量 \(\boldsymbol{r}\) 中;

  3. 具体的数值 \(\max_i {}^n m_i\) 放到 \(\boldsymbol{v}\) 中;

  4. 告知 selected 样本 \(i\) 已经被选中了;

  5. 更新最小距离向量:\({}^{n+1} m_i = \min \{ {}^{n} m_i, d_{r_n i} \}\)

程序所需要的额外内存是 \(2 n_\mathrm{result} + 2 n_\mathrm{sample}\);这相对于 \(n_\mathrm{sample}^2\) 的距离矩阵来说已经不是很重要了。

程序的计算复杂度是 \(O(n_\mathrm{sample} n_\mathrm{result})\)。这是因为需要采样 \(n_\mathrm{result}\) 个样本;每次需要在 \(n_\mathrm{sample}\) 数量的最小距离向量中寻找最大值,以及更新最小距离向量,因此复杂度就是采样数与样本数的简单乘积。

n_result = 4990
result = np.zeros(n_result, dtype=int)
v_dist = np.zeros(n_result, dtype=float)
def ks_sampling_core(dist, seed, n_result):
    # Definition: Output Variables
    result = np.zeros(n_result, dtype=int)
    v_dist = np.zeros(n_result, dtype=float)
    # Definition: Intermediate Variables
    n_seed = len(seed)
    selected = np.zeros(n_sample, dtype=bool)
    min_vals = np.zeros(n_sample, dtype=float)
    # --- Initialization ---
    result[:n_seed] = seed                   # - 1
    if n_seed == 2:
        v_dist[0] = dist[seed[0], seed[1]]   # - 2
    min_vals[:] = dist[seed[0]]              # - 3
    upper_bound = min_vals.max()             # - 4
    for n in seed:                           # - 5
        np.min(np.array([min_vals, dist[n]]), axis=0, initial=upper_bound, where=np.logical_not(selected), out=min_vals)
    # --- Loop argmax minimum ---
    for n in range(n_seed, n_result):
        sup_index = ma.array(min_vals, mask=selected).argmax()  # - 1
        result[n] = sup_index                                   # - 2
        v_dist[n - 1] = min_vals[sup_index]                     # - 3
        selected[sup_index] = True                              # - 4     # | 5
        np.min(np.array([min_vals, dist[sup_index]]), axis=0, initial=upper_bound, where=np.logical_not(selected), out=min_vals)
    return result, v_dist

执行上述代码后,就可以得到筛选出来的 4990 个样本 \(\boldsymbol{r}\) 序号,以及前 4889 个样本的采样距离 \(\boldsymbol{v}\)

%%time
ks_sampling_core(dist, seed, n_result)
CPU times: user 628 ms, sys: 0 ns, total: 628 ms
Wall time: 610 ms
(array([ 450, 4092, 3342, ..., 1696, 4495, 4400]),
 array([1963.36697 , 1819.286499, 1770.680538, ...,  959.175021,  956.828118,    0.      ]))

C 语言代码#

在 C 语言代码中只进行默认种子筛选与 Kennard-Stone 采样过程。我们暂且相信 numpy 可以很好地处理距离矩阵计算的过程。

其编写是通过 ctype 的 Python/C binding 实现的。C 文件为 ks_cpp.c,其 Python 接口文件为 KS_Sampling.py。调用方式可以与上面相似:

from KS_Sampling import ks_sampling_core_cpp
%%time
ks_sampling_core_cpp(dist, seed, n_result)
CPU times: user 158 ms, sys: 2.97 ms, total: 161 ms
Wall time: 56.3 ms
(array([ 450, 4092, 3342, ..., 1696, 4495, 4400]),
 array([1963.366943, 1819.286499, 1770.680542, ...,  959.175049,  956.828125,    0.      ]))

如果不希望提供种子文件,且希望 C 程序自己找到两个距离最远的样本作为种子,那么通过下述方式调用:

%%time
ks_sampling_core_cpp(dist, n_result=n_result)
CPU times: user 250 ms, sys: 11.4 ms, total: 261 ms
Wall time: 65.5 ms
(array([4092,  450, 3342, ..., 1696, 4495, 4400]),
 array([1963.366943, 1819.286499, 1770.680542, ...,  959.175049,  956.828125,    0.      ]))

如果想抽取所有样本,查看样本的抽样顺序,那么只需要提供距离矩阵即可:

%%time
ks_sampling_core_cpp(dist)
CPU times: user 254 ms, sys: 8.13 ms, total: 262 ms
Wall time: 65.6 ms
(array([4092,  450, 3342, ..., 1768, 1419, 3485]),
 array([1963.366943, 1819.286499, 1770.680542, ...,  921.688293,  910.627808,    0.      ]))

在具体的 C 语言实现中,考虑了以下的优化代码因素:

  • 尽可能使用并行。在选取最大值、更新最小距离向量时,都使用 OpenMP 并行。并行的方式基本是默认。

  • 避免大量的指针地址计算。

完整的 Kennard-Stone 采样函数#

在引入上面定义过的函数 get_dist, ks_sampling_core, ks_sampling_core_cpp 的情况下,可以定义下述最终的采样函数;它的必选输入是样本数据,与其他类似程序的函数签名比较接近。

def ks_sampling(X, seed=None, n_result=None, get_dist=get_dist, backend="Python"):
    """
    ks_sampling(X, seed=None, n_result=None, backend="Python")
    
    KS Sampling Program
    
    Parameters
    ----------
    
    X: np.ndarray, shape: (n_sample, n_feature)
        Original data, need to be generated by user.
        
    seed: np.ndarray or list or None, shape: (n_seed, ), optional
        Initial selected seed.
        If set as `None`, the program will find the two samples
        which have largest distance as seed.
        
    n_result: int or None, optional
        Number of samples that should be selected.
        If set as `None`, `n_sample` will be used instead, i.e.
        selectet all data.
    
    get_dist: function
        A function `get_dist(X)` that will read original data, and
        return distance.
        Default Implementation is Euclidean distance.
    
    backend: str, "Python" or "C"
        Specify Kennard-Stone sampling function backend in Python
        language or C language.
    """
    X = np.asarray(X, dtype=np.float32)
    if n_result is None:
        n_result = X.shape[0]
    dist = get_dist(X)
    if backend == "Python":
        if seed is None or len(seed) == 0:
            seed = np.unravel_index(np.argmax(dist), dist.shape)
        return ks_sampling_core(dist, seed, n_result)
    elif backend == "C":
        return ks_sampling_core_cpp(dist, seed, n_result)
    else:
        raise NotImplemented("Other backends are not implemented!")

如果希望在含有 5000 个数据的样本 X 中,依 Kennard-Stone 算法挑选 4990 个样本,可以用下述函数给出结果:

%%time
ks_sampling(X, n_result=4990)
CPU times: user 1.32 s, sys: 47.6 ms, total: 1.37 s
Wall time: 738 ms
(array([4092,  450, 3342, ..., 1696, 4495, 4400]),
 array([1963.367065, 1819.286499, 1770.680542, ...,  959.174927,  956.828064,    0.      ]))

更为快速的方法是使用 C 语言作为 Kennard-Stone 算法引擎:

%%time
ks_sampling(X, n_result=4990, backend="C")
CPU times: user 530 ms, sys: 44.5 ms, total: 574 ms
Wall time: 151 ms
(array([4092,  450, 3342, ..., 1696, 4495, 4400]),
 array([1963.367065, 1819.286499, 1770.680542, ...,  959.174927,  956.828064,    0.      ]))

如果希望前两个被采样样本序号是 (345, 456),那么就需要需要指定种子大小 (采样顺序会严格地全部输出,但此时不一定会输出第一个样本距离):

%%time
ks_sampling(X, seed=[345, 456], n_result=4990, backend="C")
CPU times: user 514 ms, sys: 35.7 ms, total: 550 ms
Wall time: 138 ms
(array([ 345,  456,  450, ..., 1696, 4495, 4400]),
 array([1388.464722, 1649.251587, 1633.396362, ...,  959.174927,  956.828064,    0.      ]))

最后,如果希望计算的是 L1 范数距离而非欧氏距离 (即 L2 范数),那么就更改 get_dist 函数即可:

from sklearn.metrics import pairwise_distances
%%time
ks_sampling(X, seed=[345, 456], n_result=4990, get_dist=lambda X: pairwise_distances(X, metric="l1"), backend="C")
CPU times: user 1.28 s, sys: 36 ms, total: 1.31 s
Wall time: 1.2 s
(array([ 345,  456,  450, ...,  999, 2095, 2046]),
 array([11158.767578, 13465.605469, 13155.375   , ...,  7544.62793 ,  7521.103516,     0.      ]))

之所以上述计算过程耗时长,是因为 L1 范数距离的耗时很大。

代码效率比较#

下面是一些代码效率的比较,以示程序的高效程度与能力范围。

这些代码是在无图形界面的 Linux 下运行,CPU 为 Intel-i5 7300HQ,内存为 16 GB,默认在可以 4 核并行时尽可能并行。

距离矩阵生成 (5000×100 数据)#

我们这里比较三种做法。

  • get_dist 是上面的程序实现方法;

  • get_dist2 使用了更为直观的做法,即直接生成 \(d_{ij} = \sqrt{t_i - 2 y_{ij} + t_j}\)

  • euclidean_distances 是最为常用的生成欧氏距离的库函数之一。

from sklearn.metrics import pairwise_distances
def get_dist2(X):
    Y = X @ X.T
    t = Y.diagonal()
    dist = np.sqrt(t[:, None] - 2 * Y + t[:, None])
    return dist

get_dist 的耗时最少:

%%timeit -r 7 -n 3
get_dist(X)
161 ms ± 149 µs per loop (mean ± std. dev. of 7 runs, 3 loops each)

get_dist2 作为直观解法,耗时多一些,且内存消耗更大:

%%timeit -r 7 -n 3
get_dist2(X)
224 ms ± 63.2 µs per loop (mean ± std. dev. of 7 runs, 3 loops each)

pairwise_distances 库函数相对较慢,但仍然支持并行运算,效率相对还算可观。

%%timeit -r 7 -n 3
pairwise_distances(X)
283 ms ± 5.63 ms per loop (mean ± std. dev. of 7 runs, 3 loops each)

KS 采样程序 (500×10 数据)#

下面我们会考察一些具体的采样程序。我们会对网络上可以找到的开源代码做速度上的比较与测评,并简单验证我们 \(O(n_\mathrm{sample} n_\mathrm{result})\) 程序的正确性。

由于大多数开源的代码是 \(O(N^3)\) 计算量级,且编程细节存在差异,因此只能在短时间内处理非常小的样本。我们先拿 500 样本的数据做验证。

n_sample  = 500
n_feature = 10
X = np.random.randn(n_sample, n_feature)
X *= 100

下述代码是用 Python 程序,完整采样 500 个数据:

%%time
result_my_python = ks_sampling(X)
CPU times: user 74.8 ms, sys: 264 µs, total: 75.1 ms
Wall time: 25 ms

下述代码是用 C 程序,完整采样 500 个数据:

%%time
result_my_cpp = ks_sampling(X, backend="C")
CPU times: user 93.1 ms, sys: 0 ns, total: 93.1 ms
Wall time: 31 ms

通过 np.allclose 可以两种代码的验证采样顺序是否一致。上面的 C 语言代码并没有更快,是因为小样本下 C 语言代码体现不出速度优势。

np.allclose(result_my_python[0][2:], result_my_cpp[0][2:])
True

下面找了比较常见的三个开源实现的纯 Python 代码并做比对;用于比对的程序放在 KS_Sampling_Others.py 中:

  • https://hxhc.github.io/post/kennardstone-spxy/

  • https://github.com/karoka/Kennard-Stone-Algorithm/blob/master/kenStone.py

  • https://github.com/XiaqiongFan/PC-CCA/blob/master/PC-CCA.py

第一个程序的实现效率较快,但尚没有达到我们上文定义的 ks_sampling 函数。在大数据量下,这种差异会更明显。

from KS_Sampling_Others import ks_from_hxhc, ks_from_karoka, ks_from_XiaqiongFan
%%time
result_hxhc = ks_from_hxhc(X, test_size=0)
CPU times: user 651 ms, sys: 13.7 ms, total: 664 ms
Wall time: 275 ms
np.allclose(result_hxhc[0][2:], result_my_cpp[0][2:])
True
%%time
result_karoka = ks_from_karoka(X, n_sample)
CPU times: user 6.91 s, sys: 71.9 ms, total: 6.98 s
Wall time: 6.38 s
np.allclose(result_karoka[2:], result_my_cpp[0][2:])
True
%%time
result_XiaqiongFan = ks_from_XiaqiongFan(X, n_sample)
CPU times: user 9.26 s, sys: 114 ms, total: 9.38 s
Wall time: 9.2 s
np.allclose(result_XiaqiongFan[0][2:], result_my_cpp[0][2:])
True

KS 采样程序 (2000×100 数据)#

现在选用一个适中的数据大小。在当前大小下,我们的程序仍然可以快速进行计算:

n_sample  = 2000
n_feature = 100
X = np.random.randn(n_sample, n_feature)
X *= 100
%%time
result_my_python = ks_sampling(X)
CPU times: user 710 ms, sys: 15.8 ms, total: 726 ms
Wall time: 184 ms
%%time
result_my_cpp = ks_sampling(X, backend="C")
CPU times: user 96.4 ms, sys: 0 ns, total: 96.4 ms
Wall time: 24.2 ms
%%time
result_hxhc = ks_from_hxhc(X, test_size=0)
CPU times: user 19.4 s, sys: 1.1 s, total: 20.5 s
Wall time: 19.9 s
np.allclose(result_hxhc[0][2:], result_my_cpp[0][2:])
False

KS 采样程序 (30000×100 数据)#

在相当大的数据集下,我们的程序可以确实地在较短时间内进行采样。对于个人电脑而言,由于 30000 个数据点所需要的距离矩阵已经相当庞大 (对于单浮点的数据,它占用大约 3.4 GB),因此可以认为是个人计算机可以处理的数据量的极限。

30000**2 * 32/8 / 1024**3
3.3527612686157227

在这个数据量下,我们的程序仍然能相当快地进行采样。对于高效且并行的 C 程序而言,其速度是非常快的;其最耗时的一步或许不是 Kennard-Stone 采样过程,而是距离矩阵的生成过程。而 Python 程序尽管较慢,但耗时仍然是可以接受的。

n_sample  = 30000
n_feature = 100
X = np.random.randn(n_sample, n_feature)
X = np.array(100 * X, dtype=np.float32)
%%timeit -r 7 -n 1
result_my_python = ks_sampling(X)
19.8 s ± 10.6 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
%%timeit -r 7 -n 1
result_my_cpp = ks_sampling(X, backend="C")
5.21 s ± 5.35 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
%%timeit -r 7 -n 1
dist = get_dist(X)
2.95 s ± 2.24 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

有限内存的 Kennard-Stone 采样方法实现#

创建时间:2021-01-31

我们继续上一篇文档,对更大数据量,导致无法储存 \((n_\mathrm{sample}, n_\mathrm{sample})\) 距离矩阵时,可以采用的 Kennard-Stone 采样策略。

该方法的计算复杂度是 \(O(n_\mathrm{sample}^2 n_\mathrm{feature} + n_\mathrm{sample} n_\mathrm{result} n_\mathrm{feature})\),其中 \(n_\mathrm{result}\) 为采样数,\(n_\mathrm{feature}\) 为特征向量的长度。如果将 \(n_\mathrm{feature}\) 当做常数值,那么该方法大约是代价比较大的 \(O(n_\mathrm{sample}^2)\) 方法。

Kennard-Stone Algorithm 的原始文献是 Kennard, Stone [1]

这篇文档的特色是:

  • 可以在任意内存大小限制下,找到两个欧氏距离 (Euclidean Distance) 最远的样本点;

  • 上述算法可以通过 multiprocessing 实现,多核机器的效率会比先前的方法高;

  • 通过 on-the-fly 计算距离的方式,在 Kennard-Stone 采样过程中不需要使用完整的距离矩阵;

  • 对于 Kennard-Stone 的 C 语言实现,一样可以通过 OpenMP 实现并行化。

需要注意,内存有限的意思并非任意小的内存都可以实现算法,而是在至少能储存原始数据集 \(n_\mathrm{sample} n_\mathrm{feature}\) 大小之外,还有适量空余内存,其内存大小也是 \(O(n_\mathrm{sample})\) 量级。具体的讨论会放在后文中。

import numpy as np
from multiprocessing import Pool

np.set_printoptions(precision=6, linewidth=120, suppress=True)
np.random.seed(0)

Python 实现#

随机数据集#

  • n_sample \(n_\mathrm{sample}\) 为样本数。现在定为 20000。

  • n_feature \(n_\mathrm{feature}\) 为特征数 (描述样本性质的数据)。现在定为 100。

  • X \(\mathbf{X}\)\(x_{ia}\) 为完整数据集。

n_sample  = 20000
n_feature = 100
X = np.random.randn(n_sample, n_feature)
X *= 100

找到距离最远的两个样本#

我们仍然需要生成距离矩阵,来找到距离最远的两个样本。但这个过程不一定需要储存完整的距离矩阵 \(d_{ij}\),而是分块计算,最后统合。

我们考虑多进程分块。如果进程数量是 \(n_\mathrm{proc}\),在每个进程中计算的距离矩阵分块是 \((n_\mathrm{batch}, n_\mathrm{batch})\),那么为了计算距离分块需要 \(n_\mathrm{proc} n_\mathrm{batch}^2\)。同时,每个分块的最大值需要进行存储,因此需要 \(n_\mathrm{sample}^2 / n_\mathrm{batch}^2\) 的内存 (这块内存尽管可以节省,但为了程序容易编写,就保留了这部分内存)。因此,总内存需要

\[ n_\mathrm{proc} n_\mathrm{batch}^2 + n_\mathrm{sample}^2 / n_\mathrm{batch}^2 \]

根据三角不等式,最少的内存需求在 \(n_\mathrm{batch} = n_\mathrm{proc}^{-1/4} \sqrt{n_\mathrm{sample}}\) 时成立,即 \(2 \sqrt{n_\mathrm{proc}} n_\mathrm{sample}\)

但如果单从效率上考虑,每个分块越大,那么多进程的通讯次数越少,消耗时间也就越少。这里我们选用 \(n_\mathrm{batch}\) 为 1000。

n_batch = 1000
t = np.einsum("ia, ia -> i", X, X)

def get_dist_slice(X, t, sliceA, sliceB):
    distAB = t[sliceA, None] - 2 * X[sliceA] @ X[sliceB].T + t[None, sliceB]
    if sliceA == sliceB:
        np.fill_diagonal(distAB, 0)
    return np.sqrt(distAB)

def get_slices(n_sample, n_batch):
    p = list(np.arange(0, n_sample, n_batch)) + [n_sample]
    return [slice(p[i], p[i+1]) for i in range(len(p) - 1)]

def get_maxloc_slice(slice_pair):
    dist_slice = get_dist_slice(X, t, slice_pair[0], slice_pair[1])
    max_indexes = np.unravel_index(np.argmax(dist_slice), dist_slice.shape)
    return (dist_slice[max_indexes], max_indexes[0] + slice_pair[0].start, max_indexes[1] + slice_pair[1].start)

slices = get_slices(n_sample, n_batch)
n_slices = len(slices)
slice_pairs = [(slices[i], slices[j]) for i in range(n_slices) for j in range(n_slices) if i <= j]
n_slices
20

最终得到的两个样本就如下所述:

%%time
with Pool(4) as p:
    maxloc_slice_list = p.map(get_maxloc_slice, slice_pairs)
max_indexes = maxloc_slice_list[np.argmax([v[0] for v in maxloc_slice_list])][1:]
max_indexes
CPU times: user 21.6 ms, sys: 8.2 ms, total: 29.8 ms
Wall time: 1.38 s
(7794, 18772)

Kennard-Stone 采样:Python 程序#

事实上,此处的 Kennard-Stone 采样过程与上一篇文档完全一致,只是在所有需要索引距离矩阵处都进行了现场计算的工作而已。由于现场计算距离额外地引入了 \(n_\mathrm{feature}\) 的维度,因此此步的计算复杂度是 \(O(n_\mathrm{sample} n_\mathrm{result} n_\mathrm{feature})\);但内存复杂度没有变化,仍然是 \(O(n_\mathrm{sample})\)

尽管算法没有发生太大变化,但距离矩阵现算导致耗时会大量增加,并且要考虑到额外增加的变量与函数通讯过程。因此它可以看做是代价较大的复杂度 \(O(n_\mathrm{sample} n_\mathrm{result})\)。如果内存充足,实际上不是那么建议使用此方法。

def ks_sampling_core_mem(X, seed, n_result):
    # Definition: Output Variables
    result = np.zeros(n_result, dtype=int)
    v_dist = np.zeros(n_result, dtype=float)
    
    # Definition: Intermediate Variables
    n_seed = len(seed)
    n_sample = X.shape[0]
    min_vals = remains = None
    
    # --- Initialization ---
    def sliced_dist(idx):
        tmp_X = X[remains] - X[idx]
        return np.sqrt(np.einsum("ia, ia -> i", tmp_X, tmp_X))

    selected = [False] * n_sample
    remains = []
    for i in range(n_sample):
        if i not in seed:
            remains.append(i)
    result[:n_seed] = seed
    if n_seed == 2:
        v_dist[0] = np.linalg.norm(X[seed[0]] - X[seed[1]])
    min_vals = sliced_dist(seed[0])
    
    for n in seed:
        np.min(np.array([min_vals, sliced_dist(n)]), axis=0, out=min_vals)
    # --- Loop argmax minimum ---
    for n in range(n_seed, n_result):
        sup_index = min_vals.argmax()
        result[n] = remains[sup_index]
        v_dist[n - 1] = min_vals[sup_index]
        remains.pop(sup_index)
        min_vals[sup_index:-1] = min_vals[sup_index+1:]
        min_vals = min_vals[:-1]
        np.min(np.array([min_vals, sliced_dist(result[n])]), axis=0, out=min_vals)
    return result, v_dist

下面给出抽样 2000 个样本时的纯 Python 程序执行过程。可以看出耗时非常明显。

%%time
ks_sampling_core_mem(X, max_indexes, 2000)
CPU times: user 11.9 s, sys: 300 ms, total: 12.2 s
Wall time: 12.2 s
(array([ 7794, 18772, 11049, ..., 14861, 17154,   733]),
 array([2004.309858, 1794.599652, 1756.059579, ..., 1283.925941, 1283.855823,    0.      ]))

Kennard-Stone 采样:C 程序#

对于 C 语言的程序,其调用与上一篇文档类似,但作为 seed 的关键词不再是可选参数了。对于默认采样方式而言,用户需要自行提供最远处的两个样本序号。

对所有 20000 个样本采样,可以在 10 秒以内完成。其速度显然不如全部距离矩阵元素都能立即获得的算法,但仍然是可以接受的。

from KS_Sampling import ks_sampling_mem_core_cpp
%%time
ks_sampling_mem_core_cpp(X, max_indexes, 2000)
CPU times: user 3.5 s, sys: 0 ns, total: 3.5 s
Wall time: 878 ms
(array([ 7794, 18772, 11049, ..., 14861, 17154,   733]),
 array([2004.309937, 1794.599731, 1756.059448, ..., 1283.925903, 1283.855835,    0.      ]))
%%time
ks_sampling_mem_core_cpp(X, max_indexes, 20000)
CPU times: user 20.6 s, sys: 0 ns, total: 20.6 s
Wall time: 5.14 s
(array([ 7794, 18772, 11049, ...,  2941,  7521, 17265]),
 array([2004.309937, 1794.599731, 1756.059448, ...,  881.746399,  859.787659,    0.      ]))

实例演示:QM9 数据集 CM 特征的 Kennard-Stone 采样#

作为一个可以现实会遇到的问题,我们对化学分子中使用的 QM9 数据集 (131k 个分子) 的 CM (Coulumb Matrix) 特征进行 QM9 采样。其数据来源是下述文章的补充信息:

  • Faber, F. A., et al; *Lilienfeld, O. A. v. Prediction Errors of Molecular Machine Learning Models Lower than Hybrid DFT Error, J. Comput. Theory Chem. 2017, 13 (11), 5255-5264. doi: 10.1021/acs.jctc.7b00577

这里使用 Python 分块找到最远两点、C 语言执行 Kennard-Stone 算法的方式,进行样本的选取。

使用的程序与上文基本是相同的,但这些功能都已经整合到 KS_Sampling.py 文件中的函数 ks_sampling_mem 了。

与上一篇文档相似地,我们仍然用 4 核 CPU 计算。

这里所报出的警告表明存在一些分子间距离过小,导致程序产生数值误差,对非常小的负值求开方。大多数情况下,这不会导致很严重的错误。

from KS_Sampling import ks_sampling_mem
import numpy as np

np.set_printoptions(precision=6, linewidth=120, suppress=True)
n_sample = 130829
n_feature = 900
X = np.empty((n_sample, n_feature), dtype=np.float32)
with open("CM", "r") as f:
    for i in range(n_sample):
        X[i] = np.array(f.readline().split()[1:], dtype=np.float32)
%%time
QM9_CM_KS_result = ks_sampling_mem(X)
/home/a/Documents/2020-01-30-KS_Memory/KS_Sampling.py:135: RuntimeWarning: invalid value encountered in sqrt
  return np.sqrt(distAB)
/home/a/Documents/2020-01-30-KS_Memory/KS_Sampling.py:135: RuntimeWarning: invalid value encountered in sqrt
  return np.sqrt(distAB)
/home/a/Documents/2020-01-30-KS_Memory/KS_Sampling.py:135: RuntimeWarning: invalid value encountered in sqrt
  return np.sqrt(distAB)
/home/a/Documents/2020-01-30-KS_Memory/KS_Sampling.py:135: RuntimeWarning: invalid value encountered in sqrt
  return np.sqrt(distAB)
CPU times: user 2h 49min 3s, sys: 8.04 s, total: 2h 49min 11s
Wall time: 43min 45s
QM9_CM_KS_result
(array([  2858,   3284,  99137, ...,  67127,  64051, 103213]),
 array([  0.010785, 207.073349, 200.928101, ...,   0.000006,   0.000005,   0.      ]))

卷积神经网络推理 (1):Direct 卷积的纯 Python 实现、库函数实现#

公开时间:2022-01-03

备注

该工作是第五届 Ubiquant Challenge 量化新星挑战赛|并行程序设计竞赛的初赛入围相关工作。该工作在【天之孔】队长强宜澄的牵头下完成。

这份文档是原题背景的介绍。原题是使用 Winograd 算法高效实现 \(3 \times 3\) 卷积核神经网络的推理过程;我们将在之后的文档再对原题进行讨论。

在最近 10 年,卷积神经网络 (CNN, Convolutional Neural Network) 在图像识别中取得很大的成功。典型的例子会是 VGG16, ResNet, GoogLeNet。深度卷积网络的模型很大,并且大多数时候在作卷积、池化操作,而较少或只在最后进行多层感知,卷积网络计算时间相对较长

我们也知道,神经网络方法的学习需要用到反向传播 (BP, Backward Propagation);这是非常重要的议题,它推动了自动导数、机器学习框架的实现与推广。但在实际的应用情景中,更多地是针对已经学习完毕的模型,进行推理计算 (Inference),即网络的正向过程。这种情形可能是手机输入法、信息软件的文字翻译、自动驾驶等等 (或许金融超额收益也是一种情形,但我还不具备理解量化金融的知识 >.<)。它们都要求在获得输入后,以最迅速的方式通过模型计算并给出结果,而这速度经常是以毫秒 (ms) 而非秒 (sec) 为单位。快速的程序实现意味着优质的用户体验、意味着生命的安全、意味着稳定的金钱收益。写一个高效的卷积网络程序至关重要。

拯救生命大挑战!(误

您是 Tezla 的算法实现工程师。您需要高效率编写一个卷积神经网络,使得汽车能自动识别到高速路上是否有小孩横穿。您能否拯救一个充满未来与希望 (但现在不太负责) 的生命?

我们将会分为三份文档,讨论这个问题。

  • 第一份文档中,我们会

    • 简单介绍卷积网络的定义、公式、基础实现,并辅以图像加深理解;

    • 以 Python 为主要编程语言,介绍一些有效的高效率纯 Python 实现方法;

    • 指出并简单测试常用的库函数实现 (oneDNN, PyTorch),以及我们自己编写的 C/C++ 程序的效率;

    • 指出纯 Python 实现的不足,并对效率改进的思路做简单说明。

  • 第二份文档中,我们会

    • 介绍 Winograd 算法[1],并对 \(F(6, 3)\) 情形作纯 Python 实现;

    • 对 Winograd 算法作简单分析;

  • 第三份文档中,我们会

    • 详细介绍初赛工作中基于 x86-64 (允许 AVX-512) CPU 的 C/C++ 优化思路;

    • 对该程序的算法复杂度与调试技巧作说明;

    • 对其它 C/C++ 优化思路做简短说明。

这是应用与实现文档,我们不对卷积网络或 Winograd 算法作原理性的叙述与证明,也不讨论卷积网络的有效性。我们只考察卷积核大小为 \(3 \times 3\) 的情形。

import numpy as np
import numba as nb
import torch
import ctypes, itertools, time
import pandas as pd

torch.set_grad_enabled(False)
np.set_printoptions(6, suppress=True, linewidth=150)

备注

本文档使用的 CPU 是 Intel Xeon Gold 6154。本文档仅使用 8 核并行。

编译环境为 GNU C++ 10.2.0。尽管 Intel C++ (icpc) 也可以编译,但不是很建议。

torch.set_num_threads(8)

文档执行指南

本文档使用 ctypes 链接 C 语言的编译库。若读者需要执行该 Jupyter 笔记本,请不要仅下载该 .ipynb 文件——该文档还需要使用编译后的 C/C++ 代码。读者可以移步到 gitee: ajz34/winograd6x3 下载源代码与 Jupyter 笔记本,并在支持 AVX-512 的机器上使用 cmake 编译。

作为程序实现效率的参比,我们还实现了 Intel oneDNN 下的卷积网络。因此在 cmake 中,我们还引入了 Intel oneAPI 的路径。需要编译 C/C++ 程序的读者可能需要更改该路径。

警告

文档作者当前工作 (计算化学理论的双杂化泛函分支) 与该文档所使用的应用或算法都毫无干系。电子积分、格点积分分支或许会使用到相似的算法思想。文档除了卷积网络本身,大多数知识都是现学的,因此可能会在叙述中存在基本性错误。

但我希望能强调算法与架构在程序设计中的重要性,读者受众应可以是行外人 (因为作者也是外行 2333)。该系列文档的算法绝非是最高效;它只是程序思路相对简单,效率尚可。

对于 Python 语言而言,由于其语言本身的特性,无法达到令人满意的效率。但即使如此,借用合适的库 (NumPy 或 Numba)、调用合适的函数,可以在一定程度上改进效率;甚至可以逼近 C/C++ 程序。这份文档的 Python 实现或许不那么惊艳,但足够表明合适的库与库函数是多么重要。

卷积神经网络简介与实现#

3x3 卷积神经网络定义#

我们先考虑一种特定的、较小型的情形。现在我们要处理一张图片,其参数为

  • 高度 (in height) IH \(H_\mathrm{in} = 14\)

  • 宽度 (in width) IW \(W_\mathrm{in} = 20\)

  • 通道数 (in channel) IC \(C_\mathrm{in} = 4\)。 高度、宽度是我们立即能理解的。通道对于普通的图片而言,可以是红、绿、蓝 (RGB 3 通道),或青、品红、黄、黑 (CMYK 4 通道)、或黑白 (灰度 1 通道)。但这里所提及的图片是广义上的图片,通道数可以是任意的。

对上述图片进行卷积操作时,需要通过所谓的“卷积核”。卷积核的作用相当于提取输入图像的特征。其参数为

  • 高方向 (kernel height) \(K_\mathrm{H} = 3\)、宽方向 (kernel width) \(K_\mathrm{W} = 3\);我们会说这类卷积核的大小是 \(3 \times 3\),且后续文档仅讨论这类卷积核;

  • 输入通道 (in channel) IC \(C_\mathrm{in} = 4\),这必须要与输入图片通道数相同;

  • 输出通道 (out channel) OC \(C_\mathrm{out} = 16\)。若熟悉 Photoshop,像图像的色调、亮度、梯度、轮廓等等近邻局域信息都可以是输出;当然,在神经网络中,这些都是将会被学习的隐含量,未必是直观的信息。

由此,输出图片的参数信息就是确定的了:

  • 高度 (out height) OH \(H_\mathrm{out} = H_\mathrm{in} - K_\mathrm{H} + 1 = H_\mathrm{in} - 2 = 12\)

  • 宽度 (out width) OW \(W_\mathrm{out} = W_\mathrm{in} - K_\mathrm{W} + 1 = W_\mathrm{in} - 2 = 18\)

  • 通道数 (out channel) OC \(C_\mathrm{out} = 16\) 随着卷积核参数固定。

很多时候,如果一次性处理多张图片,程序效率通常会高一些。我们在这里给出分批数量 \(N = 8\)

图片的储存方式在不同的程序中可能不同。我们所采用的对图片的存储方式是“NCHW”方式,即

  • 输入图片 image \(d_{i,c,x,y}\) 维度 \((N, C_\mathrm{in}, H_\mathrm{in}, W_\mathrm{in})\)

  • 输出图片 result \(Y_{i,k,x,y}\) 维度 \((N, C_\mathrm{out}, H_\mathrm{out}, W_\mathrm{out})\)

卷积核 filtr \(g_{k,c,u,v}\) 的维度在各种程序通常一致,即 \((C_\mathrm{out}, C_\mathrm{in}, K_\mathrm{H}, K_\mathrm{W})\)\((C_\mathrm{out}, C_\mathrm{in}, 3, 3)\)

N = 8
IH, IW = 14, 20
IC, OC = 4, 16
OH, OW = IH - 2, IW - 2
image  = np.random.random(( N, IC, IH, IW)).astype('f') * 255
filtr  = np.random.random((OC, IC,  3,  3)).astype('f')
result = np.zeros        (( N, OC, OH, OW)).astype('f')

为了简化后续的程序,我们会将输入图像与卷积核放在下述被折叠的代码中,以类 CNNInput 表示,并且给出各种维度信息。

Hide code cell content
class CNNInput:
    
    def __init__(self, image, filtr):
        self.image = image
        self.filtr = filtr
        # Dimensions that could be infered from input image and filter
        self.N, self.IC, self.IH, self.IW = image.shape
        self.OC = filtr.shape[0]
        self.OH, self.OW = self.IH - 2, self.IW - 2
        # Check sanity for filter dimension
        assert(len(filtr.shape) == 4)
        assert(self.IC == filtr.shape[1])
        assert(filtr.shape[2] == filtr.shape[3])
        
    @property
    def dim_result(self):
        """Return expected dimension of output image (as result)"""
        return self.N, self.OC, self.OH, self.OW

卷积网络过程图示、公式与极简 Python 实现#

卷积的过程相当于对每个输出通道 \(k\),将图像分块 \(\boldsymbol{d}\) 与卷积核 \(\boldsymbol{g}\) 作乘积;随后输出一个像素变小了一些的图像 \(\boldsymbol{Y}\)。若卷积核大小是 \(3 \times 3\),则输出图像相对于输入图像,在宽与高上都各少 2 个像素。

下述图片是基于分批数量 \(N = 1\) 绘制;不过我们的程序实现中 \(N = 8\)

cnn_direct

若从公式上来理解,则可以写为

\[ Y_{i,k,x,y} = \sum_{c}^{C_\mathrm{in}} \sum_{u}^{K_\mathrm{H}} \sum_{v}^{K_\mathrm{W}} d_{i,c,x+u,y+v} g_{k,c,u,v} \]

上式中,非求和角标有分批数量 \(i < N\)、输出通道 \(k < OC\)、输入图像像素数 \(x < OH\)\(y < OW\);被求和角标有输入通道 \(c < IC\)、以及卷积核 \(u < KH = 3\)\(v < KW = 3\)。依下述使用 NumPy 程序 conv_direct_python_7loops,利用对所有角标 \(i, k, x, y, c, u, v\)七重循环,就可以实现卷积过程。

def conv_direct_python_7loops(image, filtr):
    inp = CNNInput(image, filtr)
    result = np.zeros(inp.dim_result).astype('f')
    for i in range(inp.N):               # batch size
        for k in range(inp.OC):          # out channel
            for x in range(inp.OH):      # out height
                for y in range(inp.OW):  # out width
                    for c in range(inp.IC):     # input channel
                        for u in range(3):      # kernel width
                            for v in range(3):  # kernel height
                                result[i, k, x, y] += image[i, c, x+u, y+v] * filtr[k, c, u, v]
    return result
%%timeit -r 7 -n 1
result = conv_direct_python_7loops(image, filtr)
654 ms ± 1.39 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

但 Python 是一种很神奇的语言:它是以 C 作为底层实现的语言。若库函数使用得当,一般来说还是会有比较可观的效率,并且大大简化代码量。首先,注意到 Python 的 for 循环效率是恶名昭著的;它可以通过 itertools 进行改进。在我们测试的机器上,一般有 40 ms 的提升 (但也只有 40 ms)。更关键的是,使用 itertools 可以减少 Python 的强制缩进数,使代码看起来更加友好,行数更少。

def conv_direct_python_7loops(image, filtr):
    inp = CNNInput(image, filtr)
    result = np.zeros(inp.dim_result).astype('f')
    for i, k, x, y, c, u, v in itertools.product(
            range(inp.N), range(inp.OC), range(inp.OH), range(inp.OW),
            range(inp.IC), range(3), range(3)):
        result[i, k, x, y] += image[i, c, x+u, y+v] * filtr[k, c, u, v]
    return result
%%timeit -r 7 -n 1
result_ref = conv_direct_python_7loops(image, filtr)
611 ms ± 582 µs per loop (mean ± std. dev. of 7 runs, 1 loop each)

看起来这段代码毫无破绽,没有多余的循环、没有多余的浮点计算真的是这样吗?是也不是。原理上,这句话是对的;但我们马上就会发现,上面这段代码的效率是有多糟糕。

纯 Python 性能优化#

利用 NumPy 减少代码循环数#

我们知道 NumPy 是 Python 中专门对矩阵或张量进行优化的库。如果合理利用 NumPy,可以大大加速程序效率,同时降低代码复杂程度。

但图像卷积问题,麻烦就麻烦它与矩阵计算神似,却并不能直接化为矩阵或张量计算问题。但如果我们不考虑输出像素角标 \(x, y\) (我们把这两个角标放到右上方),那么它就化为了简单的张量数乘与求和运算:

\[ Y_{i,k}^{(x,y)} = \sum_{c}^{C_\mathrm{in}} \sum_{u}^{K_\mathrm{H}} \sum_{v}^{K_\mathrm{W}} d^{(x,y)}_{i,c,u,v} g_{k,c,u,v} \]

我们可以对 \(i, k, x, y\) 进行显式循环;而剩下的乘积与求和就交给 NumPy 完成。需要指出,整个程序的核心代码仅两行 (Python/NumPy 是真的方便 =ω=)。

def conv_direct_numpy_4loops(image, filtr):
    inp = CNNInput(image, filtr)
    result = np.empty(inp.dim_result).astype('f')
    for i, k, x, y in itertools.product(range(inp.N), range(inp.OC), range(inp.OH), range(inp.OW)):
        result[i, k, x, y] = (image[i, :, x:x+3, y:y+3] * filtr[k]).sum()
    return result
%%timeit -r 7 -n 7
result = conv_direct_numpy_4loops(image, filtr)
111 ms ± 288 µs per loop (mean ± std. dev. of 7 runs, 7 loops each)

利用 einsum 张量缩并提升性能#

我们看到了 6 倍左右的效率提升!但上述程序的效率非常低。

借助于强大且直观的 NumPy 张量缩并引擎 np.einsum,我们可以将 (右下角的) 角标缩并顺序写为字符串。计算过程仍然可以在两行之内高效并行地解决 (记得要将 optmize=True 的选项打开),并且清晰地展示了张量缩并的具体过程;但仍然需要对 \(x, y\) 作循环:

def conv_direct_numpy_einsum(image, filtr):
    inp = CNNInput(image, filtr)
    result = np.empty(inp.dim_result).astype('f')
    for x, y in itertools.product(range(inp.OH), range(inp.OW)):
        result[:, :, x, y] = np.einsum("icuv, kcuv -> ik", image[:, :, x:x+3, y:y+3], filtr, optimize=True)
    return result
%%timeit -r 7 -n 7
result = conv_direct_numpy_einsum(image, filtr)
13.6 ms ± 132 µs per loop (mean ± std. dev. of 7 runs, 7 loops each)

将问题化简为矩阵乘法#

我们又看到了 6 倍左右效率的提升!尽管 np.einsum 通常可以找到一条最佳的优化路径,但其实现可能是通过 np.tensordot 而非 np.matmul 给出。如果我们注意到卷积可以写成类似于矩阵乘法的形式:

\[ Y_{i,k}^{(x,y)} = \sum_{c}^{C_\mathrm{in}} \sum_{u}^{K_\mathrm{H}} \sum_{v}^{K_\mathrm{W}} d^{(x,y)}_{i,cuv} g_{k,cuv} \]

那么实际上,\(c, u, v\) 作为被缩并角标,可以用矩阵乘法处理。这仍然可以在两行代码中完成,但代码可能不是很直观:

def conv_direct_numpy_matmul(image, filtr):
    inp = CNNInput(image, filtr)
    result = np.empty(inp.dim_result).astype('f')
    for x, y in itertools.product(range(inp.OH), range(inp.OW)):
        result[:, :, x, y] = image[:, :, x:x+3, y:y+3].reshape(inp.N, -1) @ filtr.reshape(inp.OC, -1).T
    return result
%%timeit -r 7 -n 7
result = conv_direct_numpy_matmul(image, filtr)
1.04 ms ± 13.7 µs per loop (mean ± std. dev. of 7 runs, 7 loops each)

但依这个思路,还能有更快的代码。我们注意到卷积核 filtr \(g_{k,cuv}\) 一直被用到,但在迭代过程中始终需要经过转置并压平成为 \((g^\dagger)_{cuv,k}\)。我们可以提前分配额外的内存,卷积核转置trans_filtr。当前的问题太小,可能无法体现明显的效率优势。痛心疾首 (sic) 的是,由于转置多了一行代码,因此代码量增加了 50%

def conv_direct_numpy_matmul(image, filtr):
    inp = CNNInput(image, filtr)
    result = np.empty(inp.dim_result).astype('f')
    trans_filtr = filtr.reshape(inp.OC, -1).T.copy()
    for x, y in itertools.product(range(inp.OH), range(inp.OW)):
        result[:, :, x, y] = image[:, :, x:x+3, y:y+3].reshape(inp.N, -1) @ trans_filtr
    return result
%%timeit -r 7 -n 7
result = conv_direct_numpy_matmul(image, filtr)
930 µs ± 35.5 µs per loop (mean ± std. dev. of 7 runs, 7 loops each)

我们将程序从一开始的约 650 ms,经过 numpy 数乘与求和的引入到约 130 ms,再是 einsum 的引入到约 15 ms,最终依靠矩阵乘法提升到了 1 ms。这是约 600 倍的效率提升!但若只考虑并行的增益,那么效率提升最多也只有 8 倍 (因为我们目前只开了 8 核并行)。我们明确地看到,即使浮点计算数 FLOP 相同,若使用不同的算法或库函数,就会产生完全不同的效果。

七重循环的 Numba 实现#

我们最后指出,纯 Python 方式高效实现还有一种途径,即使用 Numba。Numba 通过 LLVM (应用于各种高效能编译器的技术),实现纯 Python 代码的编译;其执行效率与 C/C++ 代码类似。我们不妨尝试一下,如果抛开 Python 语言本身的包袱,这段代码能有多快?

@nb.jit("float32[:,:,:,::1](float32[:,:,:,::1], float32[:,:,:,::1])", nopython=True, parallel=True)
def conv_direct_numba_7loops(image, filtr):
    # === preparation ===
    N, IC, IH, IW = image.shape
    OC = filtr.shape[0]
    OH, OW = IH - 2, IW - 2
    result = np.zeros(( N, OC, OH, OW), dtype=np.float32)
    # === direct algorithm ===
    for i in nb.prange(N):           # batch size [parallelized]
        for k in range(OC):          # out channel
            for x in range(OH):      # out height
                for y in range(OW):  # out width
                    for c in range(IC):     # input channel
                        for u in range(3):      # kernel width
                            for v in range(3):  # kernel height
                                result[i, k, x, y] += image[i, c, x+u, y+v] * filtr[k, c, u, v]
    return result
%%timeit -r 7 -n 7
result_ref = conv_direct_numba_7loops(image, filtr)
361 µs ± 69.9 µs per loop (mean ± std. dev. of 7 runs, 7 loops each)

说来可能不信,Numba 的速度到了 400 µs 级别。它相比于 Python 的七重循环实现,在算法相同的情况下,效率提升了 1500 倍!如此大的效率提升,多少有其偶然性。总结来说,Python 难以做到但 Numba 或编译语言可以实现的是

  1. Numba 实现中,我们对分批数量 \(N\) 作简单并行。我们目前开了 8 核并行,因此效率提升也可能有 8 倍左右。

  2. Numba 的实现相当于 C/C++ 编译语言了。对于 x86-64 架构,许多浮点计算在比较激进的编译指令 (譬如 -O3, -march) 会自动“向量化”(vectorized) 地进行;譬如 AVX-512 指令集可以一次性对 16 个浮点数作加法或乘法。因此,效率最大可以提升 16 倍;但很多时候能达到 4 倍就已经很令人满意了。

  3. AVX 中还单独设“乘积累加”(Fused Multiply Add/Accumulate) 运算,即本来要分两次执行的加法与乘法运算放在一次执行。卷积问题完美契合这个运算,因此还能获得 2 倍额外加速。

  4. AVX (或其子集 SSE) 依靠向量化指令,对循环也能加速。

  5. 当前问题的输入图像、卷积核、输出图像都可以储存在 L2 cache 中。或许 Numba 对 cache 的处理要比 NumPy 好一些。

  6. 说不清楚的其它原因都归到 Python 的语言包袱吧 ≥ω≤

但同时也要说明,一旦卷积问题变大,图像、卷积核无法存入 L2 cache 时,这个算法会立即失败。在下面的测评环节就能看出端倪。

关于 AVX 指令集的一些操作,我们还会在第二篇文档中作展开。

库函数介绍与初步性能测评#

简化的实际问题:VGG16 conv(3.2)#

为了考察较为现实的网络效率,同时介绍各个库函数实现,我们先取 VGG16[2] 的一层网络 conv(3.2) 作为这一节的讨论对象。该网络还是有一点点挑战性的。其

  • 图像大小 \(H_\mathrm{in} = W_\mathrm{in} = 56\)

  • 输入、输出通道数 \(C_\mathrm{in} = C_\mathrm{out} = 256\)

  • 每批图像数量为 \(N = 8\)

image  = np.random.random((  8, 256, 56, 56)).astype('f') * 255
filtr  = np.random.random((256, 256,  3,  3)).astype('f')

效率测评量标:GFLOPs#

为了测评程序的效率,我们需要了解卷积过程的运算数量 (FLoating point OPeration, FLOP),以及当前程序的执行速度 (Giga-FLOP/sec 或 GFLOPs)。

计算 FLOP 的方式很简单。首先,这是一个简单张量缩并过程[3]。我们看到等式左边作为输出图像的 \(Y_{i,k,x,y}\),它有四个角标,其维度分别对应 \(N, C_\mathrm{out}, H_\mathrm{out}, W_\mathrm{out}\);而等式右边被求和的角标为 \(c, u, v\),分别对应 \(C_\mathrm{in}, K_\mathrm{H}=3, K_\mathrm{W}=3\)。同时,该式同时需要进行乘法与加法;假若算机的浮点乘法与加法运算效率相同[4],那么 FLOP 还需要乘以 2 (其 1 是一次加法、其 1 是一次乘法)。但需要注意的是,分批数量 \(N\) 与网络参数无关,因此要将其去除。最终的 FLOP 计算式为

\[ \mathrm{FLOP} = 2 C_\mathrm{out} H_\mathrm{out} W_\mathrm{out} C_\mathrm{in} K_\mathrm{H} K_\mathrm{W} = 18 C_\mathrm{out} H_\mathrm{out} W_\mathrm{out} C_\mathrm{in} \]
def get_FLOP(inp: CNNInput):
    return 18 * inp.OC * inp.OH * inp.OW * inp.IC

CNNInput.get_FLOP = get_FLOP
CNNInput(image, filtr).get_FLOP()
3439853568

即 VGG16 conv(3.2) 的 Giga-FLOP 或 GFLOP 数是 3.44。

我们可以使用下面折叠代码的函数 evaluate_GFLOPs,给出网络的参数、浮点运算数、以及当前实现下的程序效率。GFLOP 与执行时间都除去了分批数量 \(N\) 的贡献

Hide code cell content
def evaluate_GFLOPs(image, filtr, cnn, loops=10, preloops=3, flag_print=True):
    inp = CNNInput(image, filtr)
    flop = inp.get_FLOP()
    if flag_print:
        print("--- (N,IC,IH,IW,OC): ({:3d}, {:3d}, {:3d}, {:3d}, {:3d}); GFLOP    : {:9.3f}".format(
              inp.N, inp.IC, inp.IH, inp.IW, inp.OC, flop / 1000**3))
    tlist = []
    for _ in range(preloops):
        cnn(image, filtr)
    for _ in range(loops):
        t0 = time.time(); cnn(image, filtr); tlist.append(time.time() - t0)
    trun, tstd = np.sum(tlist), np.std(tlist)
    time_ms = trun / loops / inp.N * 1000
    time_std = tstd / inp.N * 1000
    gflops = flop / time_ms / 1000**2
    if flag_print:
        print("---    ms/run/batch: {:9.3f} ± {:<7.3f}      ; GFLOP/sec: {:9.3f}".format(time_ms, time_std, gflops))
    return flop, time_ms, gflops

譬如对于 NumPy 的矩阵乘法方案,其执行时间大约在 70 ms 左右,对应程序效率大约是 50 GFLOPs 或 GFLOP/sec。

evaluate_GFLOPs(image, filtr, conv_direct_numpy_matmul);
--- (N,IC,IH,IW,OC): (  8, 256,  56,  56, 256); GFLOP    :     3.440
---    ms/run/batch:    60.285 ± 1.836        ; GFLOP/sec:    57.060

真实环境的卷积效率 (1):Python 实现#

我们先看看我们的纯 Python 程序效率。

  • 即使使用了 NumPy 的四重循环方法,耗时还是太长,要跑 8 sec。VGG16 可是有 16 层啊,要每层都是这效率,那总耗时是 \(8 \times 16 = 128\) sec。用这种卷积神经网络做自动驾驶,那这就好比车开在路上,撞死了一个孩子之后要花两分钟才能反应过来。写出这种代码的人可以去枪毙两分钟。如果 Tezla 公司采用了这种代码,那这个公司不可能倒闭:它连能倒闭的资本都不会有 (手动狗头)。

evaluate_GFLOPs(image, filtr, conv_direct_numpy_4loops, loops=1, preloops=0);
--- (N,IC,IH,IW,OC): (  8, 256,  56,  56, 256); GFLOP    :     3.440
---    ms/run/batch:  6594.200 ± 0.000        ; GFLOP/sec:     0.522
  • 同样是七重循环,若用 Numba 进行编译加速会快许多,但仍然远远不达标:

evaluate_GFLOPs(image, filtr, conv_direct_numba_7loops, loops=3, preloops=1);
--- (N,IC,IH,IW,OC): (  8, 256,  56,  56, 256); GFLOP    :     3.440
---    ms/run/batch:   234.823 ± 0.250        ; GFLOP/sec:    14.649

如果 Tezla 电动车以 100 km/h (28 m/sec) 的速度开在路上,发现 150 m 前有一小孩横穿马路 (高速路行车距离一般是 100 m、故障标志需放在 150 m 开外)。一般汽车在 100 km/h 下的紧急制动距离是 42 m。如果 Tezla 看到前面小孩,需要 0.3 sec 反应过来,那么就因算法延迟额外地增加了 \(0.3 \times 28 = 8.4\) m 的制动距离。需要指出这才只是 VGG16 网络的一层;如果全部 16 层都是这个效率,那总制动距离就变成 \(8.4 \times 16 + 28 = 162.4 > 150\) m。拯救生命挑战 大失败!

  • 利用 np.einsum 的效率会有明显提升:

evaluate_GFLOPs(image, filtr, conv_direct_numpy_einsum);
--- (N,IC,IH,IW,OC): (  8, 256,  56,  56, 256); GFLOP    :     3.440
---    ms/run/batch:    82.997 ± 0.588        ; GFLOP/sec:    41.446
  • 使用矩阵乘法的效率会更高:

evaluate_GFLOPs(image, filtr, conv_direct_numpy_matmul);
--- (N,IC,IH,IW,OC): (  8, 256,  56,  56, 256); GFLOP    :     3.440
---    ms/run/batch:    57.045 ± 0.507        ; GFLOP/sec:    60.300

不过需要指出,即使用 NumPy 程序确实可以拯救小孩的生命,但因卷积网络导致的额外制动距离仍然有约 30 m。

真的是因为 Python,所以效率低下吗?不全然是。

  • 首先,简单的纯 Python 实现,效率到这里我想已经是极限了。我相信仍然有可以提升的空间,譬如在复杂算法下,使用 Numba 或许能在手动调整算法后进一步加速。但 Numba 对性能微调的要求较高,因此想要写出真正高效的程序,非常困难。事实上,我在写这份文档时尝试过 Numba 的实现,但并未成功。

  • 我们以前看到过,在小型问题中,Numba 的实现效率奇高;但为何一到 VGG16 conv(3.2) 就如此糟糕呢?

    • 这是因为一旦图像、卷积核变大后,就无法在 L2 或 L3 cache、而要在主内存 (Dynamic Random Access Memory) 中储存这些张量;而 DRAM 的索引效率是低得恐怖的。程序看起来一直在 800% CPU 高速运转,也没有浪费任何浮点运算,但实际上却一直在忙着从主内存索取数据。

    • 这是影响程序效率的最关键因素,但并非不可避免。通过缓存友好 (cache aware/friendly) 的编程方式,这种问题是可以避免的。我们会在以后的文档提及这个问题。

  • 这不意味着 Python 一无所是。事实上,上述 Python 效率低下的原因应该是代码并非完全缓存友好 (但涉及矩阵计算的部分是 NumPy 调用 MKL 库的,这部分效率有保证)。如果随便写一个 C/C++ 程序,那会跟这里的 Numba 实现一样,效率也不见得高。我们马上就会看到。

真实环境的卷积效率 (2):最简单的 C/C++ 实现#

这里作为一个 baseline,我们考察下述最简单的 C 程序 (driver.c 下的 naive_conv 函数)。它通过简单的七重循环实现,并且对于分批数 \(N\) 作简单并行 (与我们先前的 Numba 实现相同)。它抛弃了 Python 的所有包袱,并且优化选项几乎是最高的 (-O3 -march=native),或许因此其效率比算法完全一致的 Numba 效率要高。但即使如此,其实现效率远不如上述的 Python/NumPy。可见在这个问题中,缓存友好的编程方式是多么重要。

我们用 ctypes 调用该函数并考察其效率。

clib_winograd_f6x3 = np.ctypeslib.load_library("libwinograd_f6x3.so", ".")
def conv_direct_c_naive(image, filtr):
    inp = CNNInput(image, filtr)
    result = np.empty(inp.dim_result).astype('f')
    clib_winograd_f6x3.naive_conv(
        image.ctypes.data_as(ctypes.c_void_p),
        filtr.ctypes.data_as(ctypes.c_void_p),
        result.ctypes.data_as(ctypes.c_void_p),
        ctypes.c_int(inp.N), ctypes.c_int(inp.IC),
        ctypes.c_int(inp.IH), ctypes.c_int(inp.IW), ctypes.c_int(inp.OC))
    return result
evaluate_GFLOPs(image, filtr, conv_direct_c_naive, loops=3, preloops=1);
--- (N,IC,IH,IW,OC): (  8, 256,  56,  56, 256); GFLOP    :     3.440
---    ms/run/batch:   144.460 ± 0.148        ; GFLOP/sec:    23.812

真实环境的卷积效率 (3):库函数与我们的程序#

我们这里需要经常用到函数签名相同的 C 程序,因此先作下述程序定义。

def conv_c_API(clib):
    def inner(image, filtr):
        inp = CNNInput(image, filtr)
        result = np.empty(inp.dim_result).astype('f')
        clib.winconv(
            image.ctypes.data_as(ctypes.c_void_p),
            ctypes.c_int(inp.IH), ctypes.c_int(inp.IW), ctypes.c_int(inp.IC),
            filtr.ctypes.data_as(ctypes.c_void_p),
            ctypes.c_int(inp.OC), ctypes.c_int(inp.N),
            result.ctypes.data_as(ctypes.c_void_p))
        return result
    return inner
  • Intel oneDNN 是开源库;它需要通过 C/C++ 实现。下面是 Direct 卷积 (直接卷积,即上文提到的原理) 实现。

clib_direct_oneDNN = np.ctypeslib.load_library("libdnnl_direct.so", ".")
conv_direct_oneDNN = conv_c_API(clib_direct_oneDNN)
evaluate_GFLOPs(image, filtr, conv_direct_oneDNN, loops=100, preloops=10);
--- (N,IC,IH,IW,OC): (  8, 256,  56,  56, 256); GFLOP    :     3.440
---    ms/run/batch:     3.724 ± 0.158        ; GFLOP/sec:   923.752
  • Intel oneDNN 还提供了 Winograd 卷积实现。若不考虑机器精度损失,Winograd 卷积与 Direct 卷积的实现结果完全相同。但与上面提到的种种优化算法或方法有本质上的不同:Winograd 卷积是降低了实际运算 FLOP 的算法。该算法有其适用范围与限制,因此并不一定有明显的速度提升,甚至可能不升反降。Winograd 算法会是下两篇文档的主要议题。这里所用的 GFLOP/sec 是有效 GFLOP 即 Direct 卷积的浮点运算数,而非 Winograd 卷积实际的浮点运算数。Intel oneDNN 实现的 Winograd 应是 \(F(2, 3)\)\(F(4, 3)\)

clib_winograd_oneDNN = np.ctypeslib.load_library("libdnnl_winograd.so", ".")
conv_winograd_oneDNN = conv_c_API(clib_winograd_oneDNN)
evaluate_GFLOPs(image, filtr, conv_winograd_oneDNN, loops=100, preloops=10);
--- (N,IC,IH,IW,OC): (  8, 256,  56,  56, 256); GFLOP    :     3.440
---    ms/run/batch:     3.938 ± 0.049        ; GFLOP/sec:   873.397
  • 我们自己编写的初赛程序偶尔能比 Intel oneDNN 快一些。它是一个相对比较简单的,实现了 Winograd 算法的程序。我们实现的 Winograd 是 \(F(6, 3)\)

clib_winograd_f6x3 = np.ctypeslib.load_library("libwinograd_f6x3.so", ".")
conv_winograd_f6x3 = conv_c_API(clib_winograd_f6x3)
evaluate_GFLOPs(image, filtr, conv_winograd_f6x3, loops=100, preloops=10);
--- (N,IC,IH,IW,OC): (  8, 256,  56,  56, 256); GFLOP    :     3.440
---    ms/run/batch:     3.708 ± 0.167        ; GFLOP/sec:   927.607
  • PyTorch 作为最流行的机器学习框架之一,也提供了非常简单直观的底层卷积函数。PyTorch 会依据实际情形选择算法,可能是 Winograd 也可能是 Direct。注意在效率测评过程中,必须要将反向传播过程关闭 (torch.set_grad_enabled)。我们可以同时用下述函数给出 CPU 与 GPU 效率。需要说明的是,对于 PyTorch-GPU 情形,GPU 显卡内存与 DRAM 的通讯时间很大,不可忽略,如果所有计算都在 GPU 中进行,则 GFLOP/sec 会高到离谱。因此下面的代码会自 CPU 读取输入图像。

def conv_pytorch(device, filtr):
    IC, OC = filtr.shape[:2]
    torch_layer = torch.nn.Conv2d(IC, OC, 3, device=device)
    torch_layer.load_state_dict({"weight": torch.tensor(filtr), "bias": torch.zeros(OC)})
    def inner_func(image, filtr):
        result = torch_layer(torch.tensor(image, device=device)).cpu()
        return result
    return inner_func
evaluate_GFLOPs(image, filtr, conv_pytorch("cpu", filtr), loops=100);
--- (N,IC,IH,IW,OC): (  8, 256,  56,  56, 256); GFLOP    :     3.440
---    ms/run/batch:     2.677 ± 0.039        ; GFLOP/sec:  1284.805
# execuate this code if gpu is available
# evaluate_GFLOPs(image, filtr, conv_pytorch("cuda", filtr), loops=10);

总地来说,我们可以看出,对于当前的问题而言,任何一个实用的 C/C++ 程序实现,都比 NumPy 实现能快上 15 倍。这才能完美地完成 拯救生命大挑战

不过囿于一些我自己也不了解的技术问题,使用 NumPy/ctypes 调用 C 程序,有时无法完全达到纯 C 程序的效率。这对实际应用可能影响不大,但对测评结果会有影响。因此,我们不在这里作更详尽的测评。在第三篇文档中,我们会完全讨论 C 代码,而不使用 Python。

我们都知道,若单论 NumPy 处理矩阵计算,其实效率与库函数实现没有多大差别。到底什么影响了卷积的实现效率呢?我们会在以后的文档中,对初赛工作作说明,从而可以一窥高效库函数的实现中,开发者可能会考虑到的问题。


卷积神经网络推理 (2):Winograd 卷积的纯 Python 实现#

公开时间:2022-01-03

备注

该工作是第五届 Ubiquant Challenge 量化新星挑战赛|并行程序设计竞赛的初赛入围相关工作。该工作在【天之孔】队长强宜澄的牵头下完成。

这份文档是 Winograd 算法的介绍与说明。原题是使用 Winograd 算法高效实现 \(3 \times 3\) 卷积核神经网络的推理过程;我们将在之后的文档再对原题进行讨论。

警告

文档作者当前工作 (计算化学理论的双杂化泛函分支) 与该文档所使用的应用或算法都毫无干系。文档除了卷积网络本身,大多数知识都是现学的,因此可能会在叙述中存在基本性错误。

import numpy as np
import itertools

这是应用与实现文档,我们不对卷积网络或 Winograd 算法作原理性的叙述与证明,也不讨论卷积网络的有效性。我们只考察卷积核大小为 \(3 \times 3\) 的情形。

背景#

警告

下述叙述存在主观和不可考证的内容。

卷积神经网络 (CNN) 算法一炮成名应是在 AlexNet[1] 赢得 ImageNet 2012 (ILSVRC2012) 时。在随后的几年中,CNN 算法不仅受到广泛应用与改进;其计算效率的改进也受到重视

卷积相当于一种对图像的滤波过程,通常的加速方法是利用复空间的快速 Fourier 变换 (Fast Fourier Transformation, FFT)。卷积概念本身也与 Fourier 变换有非常直接的关联。若对物理有简单的了解,则会知道动量与坐标互为 Fourier 变换关系;而动量在自由粒子下与频率成正比,相比于坐标而言是更为全局、本征的描述子。卷积相当于某种程度上提取图像的某些全局的“频率”信息;若从这个角度来理解,卷积应当要作用于完整的一份图像

我们可以设想,如果要通道数为 \(C_\mathrm{in}\)、大小为 \(H_\mathrm{in} \times W_\mathrm{in}\) 的图像作全局滤波,滤波所产生的输出通道数是 \(C_\mathrm{out}\);那么滤波参数就会是 \((C_\mathrm{out}, C_\mathrm{in}, H_\mathrm{in}, W_\mathrm{in})\) 大小的张量。这是会是相当夸张的张量:以 VGG16[2]为例,它处理的是通道数为 \(3\)\(224 \times 224\) 图像 \(d_{cuv} \in \mathbb{R}^{3 \times 224 \times 224}\);其输出到多层感知 (MLP) 全连接层的维度是 4096,因此滤波参数的大小是 (以 32 位 float 储存)

\[ g_{kcuv} \in \mathbb{R}^{4096 \times 3 \times 224 \times 224} = \mathbb{R}^{0.57 \ \mathrm{Giga}} \Rightarrow 2.30 \ \mathrm{GB} \]

这种全局卷积的计算量本身其实还好,甚至可能通过 FFT 加速,但参数量实在太大。同时,这种做法就是简单的线性变换,无法利用深度网络的特性——深度网络与线性变换的最大差别在于激活函数的使用。

现在常用与流行的 CNN 通常都解决了这些问题。以 VGG16 举例来说。其构造的两个最大初衷是

  • 如果不考虑卷积层之间的激活层,那么它使用更少的参数 (500 MB 左右) 来模拟全图像的滤波过程;但代价是层数上的增加、以及计算量 FLOP 的提升 (从 1.23 GFLOP 提升到 39.02 GFLOP)。由于 GPU 显存还是比较珍贵的资源,大多数中低端显卡可用的显存大小在 3.5 GB 或以下:如果参数量就到 2.3 GB,再加上反向导数,显存就炸了。因此即使有不小的代价,但参数大小的减小对卷积问题还是很关键的。

  • 但层数的增加不见得是坏事。如果中间引入激活层,则可以给网络带来更大的非线性;结合 VGG16 本来就不少的参数,这种非线性性质可以得到较大程度的发挥。再怎么说,大家通常更看重拟合能力;不少实际问题 (或竞赛) 对 CNN 的精度要求很高,为此牺牲一些可以接受的 FLOP 是不会心疼的。

如何对全图像的滤波过程作更少参数的拆分,大概是一门学问。它有那么些像通过张量分解以提升张量缩并运算速度。

基于这些背景,VGG16 采用了多次使用 \(3 \times 3\) 小卷积核并结合池化 (Pooling) 的策略,在其 16 层网络里,一层一层地将图像的信息全局化。由此有效地提取图像全局信息,同时减少内存压力并引入一些非线性。这也是小卷积核的由来 (不过小卷积核不是 VGG16 第一个提出的,但它应是第一个仅使用 \(3 \times 3\) 卷积核的网络)。其卷积核滤波参数大小是

\[ g_{kcuv} \in \mathbb{R}^{C_\mathrm{out} \times C_\mathrm{in} \times 3 \times 3} \]

但它带来的一个额外的问题是,由于卷积核太小,因此无法通过 FFT 方法加速

在 2015-2016 年,Lavin 与 Gray 重新发现了 Winograd 在 1980 年代所提出的小型滤波的加速方法[3]。在该算法下,若只考虑实际 FLOP 数而不考虑内存通讯耗时,则可以有 2.25-9 倍的效率提升。这会是极其惊人的效率提升;不过囿于其算法的一些副作用、以及卷积问题本身所必须实现的内存通讯量,大多数实际卷积层效率提升在 1-2 倍左右,少量达到 4 倍。该算法现在通常被称为 Winograd 算法。

在这篇文档中,我们会对 Winograd 卷积过程作叙述与分析。我们会有简单的 Python 代码作说明,但由于其作为脚本语言本身的特性 (无法通过编译过程优化代码效率),Winograd 卷积无法在 Python 下展现其效率;相反地,其效率会及其严重地落后于 Direct 卷积。我们会在后一篇文档阐释 C/C++/SIMD 实现的 Winograd 卷积 (不够极限的) 提速实现。

问题大小定义与 Direct 卷积回顾#

维度大小定义#

在这篇文档中,我们通篇使用下述小型问题:

  • 图像参数

    • 高度 (in height) IH \(H_\mathrm{in} = 14\)

    • 宽度 (in width) IW \(W_\mathrm{in} = 20\)

    • 通道数 (in channel) IC \(C_\mathrm{in} = 4\)

  • 卷积核参数

    • 核大小 \(K_\mathrm{H} = K_\mathrm{W} = K = 3\)

    • 输出通道 (out channel) OC \(C_\mathrm{out} = 16\)

  • 分批数量 \(N = 8\)

N = 8
IH, IW = 14, 20
IC, OC = 4, 16
OH, OW = IH - 2, IW - 2

重要的维度信息有

  • 输入图片 image \(d_{i,c,x,y}\) 维度 \((N, C_\mathrm{in}, H_\mathrm{in}, W_\mathrm{in})\)

  • 卷积核 filtr \(g_{k,c,u,v}\) 维度 \((C_\mathrm{out}, C_\mathrm{in}, 3, 3)\)

  • 输出图片 result \(Y_{i,k,x,y}\) 维度 \((N, C_\mathrm{out}, H_\mathrm{out}, W_\mathrm{out})\)

image  = np.random.random(( N, IC, IH, IW)).astype('f') * 255
filtr  = np.random.random((OC, IC,  3,  3)).astype('f')
result = np.zeros        (( N, OC, OH, OW)).astype('f')

Direct 卷积回顾#

首先我们回顾到卷积计算可以写为

\[ Y_{i,k,x,y} = \sum_{c}^{C_\mathrm{in}} \sum_{u}^{K} \sum_{v}^{K} g_{k,c,u,v} d_{i,c,x+u,y+v} \]

我们这份文档中也会利用 np.einsum 函数,因此这里同时回顾使用张量缩并方式下给出的卷积代码:

result_ref = np.empty((N, OC, OH, OW)).astype('f')
for x, y in itertools.product(range(OH), range(OW)):
    result_ref[:, :, x, y] = np.einsum("kcuv, icuv -> ik", filtr, image[:, :, x:x+3, y:y+3], optimize=True)

我们也会利用矩阵记号。若将所有张量视为 \(u, v\) 的矩阵 (即下述等式右边的记号都是矩阵),则还可以写为矩阵点积:

\[ Y_{i,k,x,y} = \sum_{c}^{C_\mathrm{in}} g_{k,c} \odot d_{i,c}^{(x,y)} \]
result_ref = np.empty((N, OC, OH, OW)).astype('f')
for i, k, x, y in itertools.product(range(N), range(OC), range(OH), range(OW)):
    result_ref[i, k, x, y] = (image[i, :, x:x+3, y:y+3] * filtr[k]).sum()

Winograd 算法原理实现#

这里我们完全不考虑 Winograd 算法的推导与正确性,也不考虑其效率。

Winograd 算法可以看作是将小卷积核的滤波,进行一定程度的放大,使得一次性可以输出的图像像素数增多,并减少大量浮点乘法运算。这种放大记作 \(F(M, K)\) 滤波[4]

  • 我们问题中的小卷积核大小始终是 \(K \times K = 3 \times 3\)

  • 通过变换,卷积核会放大到 \(\mu \times \mu\);其中 \(\mu = M + K - 1\)

  • 单步卷积输入图像大小也是 \(\mu \times \mu\)

  • 单步卷积输出图像大小是 \(M \times M\)

事实上,如果 \(M = 1\),Winograd 算法会退化到我们熟知的 Direct 卷积。

在这篇文档中,我们会以 \(M = 6\) 的情形,即 \(F(M, K) = F(6, 3)\) 作示例。在这种情况下,\(\mu = 8\) 即卷积核会滤波放大到 \(8 \times 8\) 大小。

cnn_winograd

输入图像变换#

不像 Direct 卷积可以一步计算到位,Winograd 算法需要四步实现。其中的第一步是输入图像变换,即图示中左上方绿色背景块中所示部分。对于输入图像

  • 首先切割出大小为 \((N, C_\mathrm{in}, \mu, \mu)\) 的子图像 \(d^{(x,y)}_{i,c,t,w} = d_{i,c,x+t,y+w}\)

  • 随后对其作矩阵乘法变换,得到大小为 \((N, C_\mathrm{in}, \mu, \mu)\) 的变换后的图像 \(V_{i,c,r,s}^{(x,y)}\)

    \[ V_{i,c,r,s}^{(x,y)} = \sum_t^\mu \sum_s^\mu B_{t,r} d^{(x,y)}_{i,c,t,w} B_{w,s} \]

    如果将上式的各张量看作关于下标 \(t,w,r,s\) 的矩阵,则上式可以写作

    \[ V_{i,c}^{(x,y)} = B^\dagger d^{(x,y)}_{i,c} B \]

    其中,\(B_{t,r} \in \mathbb{R}^{\mu \times \mu}\) 是变换矩阵,它随 \(F(M, K)\)\(M, K\) 的取值不同而不同。譬如对于 \(F(6,3)\),其定义如下 (注意下述代码中使用了转置 attribute np.ndarray.T)

B = np.array([
    [ 1 ,   0  , -21/4 ,    0   ,  21/4 ,    0   , -1 , 0 ],
    [ 0 ,   1  ,    1  , -17/4  , -17/4 ,    1   ,  1 , 0 ],
    [ 0 ,  -1  ,    1  ,  17/4  , -17/4 ,   -1   ,  1 , 0 ],
    [ 0 ,  1/2 ,   1/4 ,  -5/2  ,  -5/4 ,    2   ,  1 , 0 ],
    [ 0 , -1/2 ,   1/4 ,   5/2  ,  -5/4 ,   -2   ,  1 , 0 ],
    [ 0 ,   2  ,    4  ,  -5/2  ,   -5  ,   1/2  ,  1 , 0 ],
    [ 0 ,  -2  ,    4  ,   5/2  ,   -5  ,  -1/2  ,  1 , 0 ],
    [ 0 ,  -1  ,    0  ,  21/4  ,    0  , -21/4  ,  0 , 1 ],
]).T.astype('f')

现在我们将变换后的图像写出。但需要注意的是,与 Direct 情形不同,我们不需要对每个 \(x = 0, 1, \cdots, H_\mathrm{in} - 2\) 作卷积计算;而是对 \(x = 0, M, 2M, \cdots\)\(x = 0, 6, 12 \cdots\) 作计算。我们会用 \(\tilde x = x / M\) 作替代。对于 \(\tilde y = y / M\) 也作相同处理。

  • 高方向上需作的卷积处理次数 TH \(\tilde H = H_\mathrm{out} / M = 2\)

  • 宽方向上需作的卷积处理次数 TW \(\tilde W = W_\mathrm{out} / M = 3\)

TH, TW = OH // 6, OW // 6

那么变换后的图像 V \(V_{i,c,r,s}^{(\tilde x, \tilde y)}\) 则可以写作维度为 \((\tilde H, \tilde W, N, C_\mathrm{in}, \mu, \mu)\) 大小的张量 (对应角标是 \(\tilde x, \tilde y, i, c, r, s\))。我们首先按下式来计算:

\[ V_{i,c}^{(\tilde x, \tilde y)} = B^\dagger d^{(\tilde x, \tilde y)}_{i,c} B \]
V = np.empty((TH, TW, N, IC, 8, 8)).astype('f')
for x_, y_, i, c in itertools.product(range(TH), range(TW), range(N), range(IC)):
    x, y = 6 * x_, 6 * y_
    V[x_, y_, i, c] = B.T @ image[i, c, x:x+8, y:y+8] @ B

如果用 np.einsum 作张量缩并,则也可以写为

\[ V_{i,c,r,s}^{(\tilde x, \tilde y)} = \sum_t^\mu \sum_s^\mu B_{tr} d^{(\tilde x, \tilde y)}_{i,c,t,w} B_{ws} \]
V = np.empty((TH, TW, N, IC, 8, 8)).astype('f')
for x_, y_ in itertools.product(range(TH), range(TW)):
    x, y = 6 * x_, 6 * y_
    V[x_, y_] = np.einsum("tr, ictw, ws -> icrs", B, image[:, :, x:x+8, y:y+8], B, optimize=True)

卷积核变换#

维度为 \((C_\mathrm{out}, C_\mathrm{in}, K, K)\) 的卷积核 \(g_{k,c,u,v}\) (相当于 \(K \times K\),在我们的问题下是 \(3 \times 3\) 的卷积核) 在 Winograd 算法下,需要滤波增大到 \((C_\mathrm{out}, C_\mathrm{in}, \mu, \mu)\) 维度 (相当于 \(\mu \times \mu\),在我们的问题下是 \(8 \times 8\) 的放大滤波)。其计算过程如下:

\[ U_{k,c,r,s} = \sum_u^K \sum_v^K G_{r,u} g_{k,c,u,v} G_{s,v} \]

与刚才一样,变换矩阵 \(G_{r,u} \in \mathbb{R}^{\mu \times K}\) 随着 \(F(M, K)\)\(M, K\) 取值不同而不同。对于我们的问题而言,\(K = 3\), \(\mu = 8\);因此卷积核相当于被放大了 7.1 倍。

G = np.array([
    [   1   ,    0   ,   0   ],
    [ -2/9  ,  -2/9  , -2/9  ],
    [ -2/9  ,   2/9  , -2/9  ],
    [  1/90 ,   1/45 ,  2/45 ],
    [  1/90 ,  -1/45 ,  2/45 ],
    [ 32/45 ,  16/45 ,  8/45 ],
    [ 32/45 , -16/45 ,  8/45 ],
    [   0   ,    0   ,   1   ],
]).astype('f')
U = np.einsum("ru, kcuv, sv -> kcrs", G, filtr, G, optimize=True)

我们也可以用矩阵乘法来给出该过程 (即将各张量看作关于 \(u, v, r, s\) 的矩阵):

\[ U_{k,c} = G g_{k,c} G^\dagger \]
U = np.empty((OC, IC, 8, 8)).astype('f')
for k, c in itertools.product(range(OC), range(IC)):
    U[k, c] = G @ filtr[k, c] @ G.T

数乘#

第三步是将变换后的图像与卷积核作数乘,并对输入通道作求和缩并:

\[ M_{i,k,r,s}^{(\tilde x, \tilde y)} = \sum_c^{C_\mathrm{in}} U_{k,c,r,s} V_{i,c,r,s}^{(\tilde x, \tilde y)} \]

这一步比较适合用 np.einsum 来解决:

M = np.einsum("kcrs, xyicrs -> xyikrs", U, V, optimize=True)

这一步也会写成如下的矩阵形式:

\[ M_{i,k}^{(\tilde x, \tilde y)} = \sum_c^{C_\mathrm{in}} U_{k,c} \odot V_{i,c}^{(\tilde x, \tilde y)} \]
M = np.zeros((TH, TW, N, OC, 8, 8)).astype('f')
for x_, y_, i, k, c in itertools.product(range(TH), range(TW), range(N), range(OC), range(IC)):
    M[x_, y_, i, k] += U[k, c] * V[x_, y_, i, c]

输出图像变换#

最后一步是进行输出图像变换:

\[ Y_{i,k,x+a,y+b} = Y_{i,k,a,b}^{(\tilde x, \tilde y)} = \sum_r^\mu \sum_s^\mu A_{r,a} M^{(\tilde x, \tilde y)}_{i,k,r,s} A_{s,b} \]

我们注意到,输出图像应当是 \((N, C_\mathrm{out}, H_\mathrm{out}, W_\mathrm{out})\) 的四维张量 \(Y_{i,k,x,y}\),而不适合写成六维张量 \(Y_{i,k,a,b}^{(\tilde x, \tilde y)}\) 的形式。这一步用到的变换矩阵是 \(A_{r,a} \in \mathbb{R}^{\mu \times M}\)

A = np.array([
    [ 1 , 1 ,  1 ,  1 ,   1 ,  1   ,   1   , 0 ],
    [ 0 , 1 , -1 ,  2 ,  -2 , 1/2  , -1/2  , 0 ],
    [ 0 , 1 ,  1 ,  4 ,   4 , 1/4  ,  1/4  , 0 ],
    [ 0 , 1 , -1 ,  8 ,  -8 , 1/8  , -1/8  , 0 ],
    [ 0 , 1 ,  1 , 16 ,  16 , 1/16 ,  1/16 , 0 ],
    [ 0 , 1 , -1 , 32 , -32 , 1/32 , -1/32 , 1 ],
]).T.astype('f')
result = np.empty((N, OC, OH, OW)).astype('f')
for x_, y_ in itertools.product(range(TH), range(TW)):
    x, y = 6 * x_, 6 * y_
    result[:, :, x:x+6, y:y+6] = np.einsum("ra, ikrs, sb -> ikab", A, M[x_, y_], A, optimize=True)
np.allclose(result, result_ref)
True

如果写为关于下标 \(a, b, r, s\) 的矩阵乘法,则

\[ Y_{i,k}^{(\tilde x, \tilde y)} = A^\dagger M^{(\tilde x, \tilde y)}_{i,k} A \]
result = np.empty((N, OC, OH, OW)).astype('f')
for x_, y_, i, k in itertools.product(range(TH), range(TW), range(N), range(OC)):
    x, y = 6 * x_, 6 * y_
    result[i, k, x:x+6, y:y+6] = A.T @ M[x_, y_, i, k] @ A
np.allclose(result, result_ref)
True

至此,我们就给出了 Winograd 算法的实现过程。

总表达式#

在 Lavin 2016 原始文献的公式 (8),给出了以矩阵乘法为表示的总表达式。以这篇文档的记号重述为

\[\begin{split} \begin{align*} Y^{(\tilde x, \tilde y)}_{i,k} &= A^\dagger M^{(\tilde x, \tilde y)}_{i,k} A = A^\dagger \left[ \sum_c^{C_\mathrm{in}} U_{k,c} \odot V_{i,c}^{(\tilde x, \tilde y)} \right] A \\ &= A^\dagger \left[ \sum_c^{C_\mathrm{in}} \big( G g_{k,c} G^\dagger \big) \odot \big( B^\dagger d^{(x,y)}_{i,c} B \big) \right] A \end{align*} \end{split}\]

因此,尽管 Winograd 算法比起 Direct 算法还是要复杂不少,但若提前对 \(A, B, G\) 变换矩阵有所定义,且不考虑效率,那么核心的代码其实也就 2-3 行

result = np.zeros((N, OC, OH, OW)).astype('f')
for x_, y_, i, k, c in itertools.product(range(TH), range(TW), range(N), range(OC), range(IC)):
    x, y = 6 * x_, 6 * y_
    result[i, k, x:x+6, y:y+6] += A.T @ ((G @ filtr[k, c] @ G.T) * (B.T @ image[i, c, x:x+8, y:y+8] @ B)) @ A
np.allclose(result, result_ref)
True

Winograd 算法讨论#

在这一部分中,我们会简单讨论 FLOP 数问题。

我们首先回顾到,对于 Direct 算法而言:

\[ Y_{i,k,x,y} = \sum_{c}^{C_\mathrm{in}} \sum_{u}^{K_\mathrm{H}} \sum_{v}^{K_\mathrm{W}} d_{i,c,x+u,y+v} g_{k,c,u,v} \]

或者用更为适合分析 FLOP 的公式:

\[ Y_{i,k}^{(x,y)} = \sum_{c}^{C_\mathrm{in}} \sum_{u}^{K_\mathrm{H}} \sum_{v}^{K_\mathrm{W}} d^{(x,y)}_{i,c,u,v} g_{k,c,u,v} \]

其 FLOP 在前一篇文档中已经有所分析:

\[ \mathrm{FLOP} (\text{direct}) = 2 C_\mathrm{out} H_\mathrm{out} W_\mathrm{out} C_\mathrm{in} K_\mathrm{H} K_\mathrm{W} = 18 C_\mathrm{out} H_\mathrm{out} W_\mathrm{out} C_\mathrm{in} \]

对于我们当前的问题,\(C_\mathrm{out} = 16\), \(C_\mathrm{in} = 4\), \(H_\mathrm{out} = 18\), \(W_\mathrm{out} = 12\);因此,

\[ \mathrm{FLOP} (\text{direct}) = 18 \times 16 \times 18 \times 12 \times 4 = 248832 \]

变换过程 FLOP:以输入图像为例#

我们首先回顾变换过程的计算表达式:

\[ V_{i,c,r,s}^{(\tilde x, \tilde y)} = \sum_t^\mu \sum_s^\mu B_{tr} d^{(\tilde x, \tilde y)}_{i,c,t,w} B_{ws} \]

它是两步矩阵乘法所构成的,且每一步矩阵乘法的 FLOP 数一致。其中一步矩阵乘法是

\[ \sum_t^\mu B_{tr} d^{(\tilde x, \tilde y)}_{i,c,t,w} \]

这一步的 FLOP 计算可以通过将所有角标对应的维度相乘 (\(\tilde x, \tilde y, c, t, r, w\),但除去分批数量角标 \(i\)),并考虑到加法与乘法而要额外乘以 2:

\[ \text{FLOP} = 2 \tilde H \tilde W C_\mathrm{in} \mu^3 = 2 \times \left\lfloor \frac{18}{6} \right\rfloor \times \left\lfloor \frac{12}{6} \right\rfloor \times 4 \times 8^3 = 24576 \]

考虑到两步矩阵乘法过程,因此输入图像转换就需要经过约 50k 的 FLOP 运算。它已经是 Direct 卷积总计算量的 20%,已是个不小的数值了。

但实际上,我们并不真的需要真的进行矩阵乘法。我们现在考虑图像变换问题的子问题,即矩阵乘法 \(T = B^\dagger D\) (其中 \(B, D \in \mathbb{R}^{\mu \times \mu}\)):

\[ T_{rw} = \sum_t^\mu B_{tr} D_{tw} \]

如果要依照矩阵乘法的算法进行计算,则其 FLOP 数是 \(2 \mu^3 = 2 \times 8^3 = 1024\),或者是 128 次长度为 \(\mu = 8\) 的向量运算。但有意思的是,如果我们细致地分析矩阵 \(B\)

\[\begin{split} B^\dagger = \begin{pmatrix} 1 & 0 & -5.25 & 0 & 5.25 & 0 & -1 & 0 \\ 0 & 1 & 1 & -4.25 & -4.25 & 1 & 1 & 0 \\ 0 & -1 & 1 & 4.25 & -4.25 & -1 & 1 & 0 \\ 0 & 0.5 & 0.25 & -2.5 & -1.25 & 2 & 1 & 0 \\ 0 & -0.5 & 0.25 & 2.5 & -1.25 & -2 & 1 & 0 \\ 0 & 2 & 4 & -2.5 & -5 & 0.5 & 1 & 0 \\ 0 & -2 & 4 & 2.5 & -5 & -0.5 & 1 & 0 \\ 0 & -1 & 0 & 5.25 & 0 & -5.25 & 0 & 1 \end{pmatrix} \end{split}\]

就能发现该矩阵实际上是有特征的。

  • 首先考虑到 \(T_0 = (B^\dagger D)_0 = D_0 - 5.25 \times (D_2 - D_4) - D_6\) (我们采用零索引 0-index),它的计算量是 3 次向量加法与 1 次向量乘法;每个向量长度是 \(\mu = 8\),因此这一步的 FLOP 是 32。对于 \(T_7\) 也有同样的结论。

  • 随后对于 \(T_1, T_2\) 而言,

    \[\begin{split} \begin{align*} T_1 &= (D_2 - 4.25 \times D_4 + D_6) + (D_1 - 4.25 \times D_3 + D_5) \\ T_2 &= (D_2 - 4.25 \times D_4 + D_6) - (D_1 - 4.25 \times D_3 + D_5) \end{align*} \end{split}\]

    我们注意到如果可以预留两个寄存器,分别储存

    \[\begin{split} \begin{align*} s_0 &= D_2 - 4.25 \times D_4 + D_6 \\ s_1 &= D_1 - 4.25 \times D_3 + D_5 \end{align*} \end{split}\]

    那么作为结果的向量则是 \(T_1 = s_0 + s_1\), \(T_2 = s_0 - s_1\)。生成 \(s_0, s_1\) 时需要 4 次向量加法与 2 次向量乘法;生成 \(T_1, T_2\) 需要 2 次额外的向量加法。每个向量长度是 \(\mu = 8\),因此这一步的 FLOP 是 64。

  • 对于 \(T_3, T_4, T_5, T_6\) 而言,其计算方式与 \(T_1, T_2\) 类似;但由于浮点数值不太相同,因此额外需要引入 3 次向量乘法,因此生成 \(T_3, T_4\)\(T_5, T_6\) 分别需要的 FLOP 数是 88。

因此总地来说,\(T = B^\dagger D\) 需要的 FLOP 数是 \(32 + 64 + 88 + 88 + 32 = 304\),或者 38 次长度为 \(\mu = 8\) 的向量运算。这要明显比普通的矩阵乘法的 1024 次要少很多。除此之外,如果我们允许 FMA (Fused Multiply Addition/Accumulate) 与 SIMD (Single Instruction Multiple Data),那么实际运算数可以再少一些。

我们不妨考察下述 C++ 代码:

// input.cpp
#include <immintrin.h>
#define Intrinsic __m256

void transform_BtD_6x3(const Intrinsic D[8], Intrinsic BtD[8]) {
    Intrinsic s0, s1;
    BtD[0] = D[0] + 5.25f * (D[4] - D[2]) - D[6];
    BtD[7] = D[7] - D[1] + 5.25f * (D[3] - D[5]);
    s0 = D[1] - 4.25f * D[3] + D[5];
    s1 = D[2] - 4.25f * D[4] + D[6];
    BtD[1] = s0 + s1;
    BtD[2] = s1 - s0;
    s0 = 0.5f * D[1] - 2.5f * D[3] + 2.f * D[5];
    s1 = 0.25f * D[2] - 1.25f * D[4] + D[6];
    BtD[3] = s0 + s1;
    BtD[4] = s1 - s0;
    s0 = D[1] + D[1] - 2.5f * D[3] + 0.5f * D[5];
    s1 = 4.f * D[2] - 5.f * D[4] + D[6];
    BtD[5] = s0 + s1;
    BtD[6] = s1 - s0;
}

我们通过 gcc 生成汇编代码:

$ g++ input.cpp -S -march=native -O3

可以寻找到汇编代码中,如果不考虑向量移动指令 vmovaps (尽管向量移动经常才是耗时步,但不要在意太多细节 >.<)

  • vsubps, vaddps 出现 15 次,用于向量加法与减法;

  • vfmadd___ps 出现 10 次,用于向量乘法累加;

  • vmulps 出现 3 次,用于向量乘法;

因此,允许 FMA 与 SIMD 下,\(T = B^\dagger D\) 的实际浮点运算数是 28 次向量运算,且每次向量运算消耗的浮点运算数是 1 而非 \(\mu = 8\)。除此之外,在第二、三次生成 s1 中,如果利用加法交换律,是可以进一步减少两次向量乘法而变为向量乘法累加的,因此可以减少到 26 次向量运算。这比起普通的乘法需要 \(2 \mu^2 = 128\) 次向量运算要少许多运算量 (大约只有 20%)。不仅如此,普通乘法的汇编实现还需要较多的 boardcast, shuffle 等比较耗时的操作;但依据 \(B\) 矩阵的特征而作的实现则基本不需要这些。

因此,总地来说,利用 \(B\) 矩阵的特征作输入图像变换,可以提速到 20% 甚至更快。在这种情形下,计算耗时仅有 Direct 卷积耗时的 4%。因此,如果图像或卷积核变换实现地高效,那么这些变换相对于总计算耗时来说,会次要一些。

这里只是定性的叙述。我们会在下一篇文档中,关于图像与卷积核变换的效率提升作更为详细的描述。

数乘 FLOP:最耗时步#

我们回顾到数乘步骤是

\[ M_{i,k,r,s}^{(\tilde x, \tilde y)} = \sum_c^{C_\mathrm{in}} U_{k,c,r,s} V_{i,c,r,s}^{(\tilde x, \tilde y)} \]

其 FLOP 数仍然是将所有角标对应的维度相乘 (\(\tilde x, \tilde y, k, c, r, s\),但除去分批数量角标 \(i\)) 并乘以 2:

\[ \text{FLOP} = 2 \tilde H \tilde W C_\mathrm{out} C_\mathrm{in} \mu^2 = 2 \times \left\lfloor \frac{18}{6} \right\rfloor \times \left\lfloor \frac{12}{6} \right\rfloor \times 16 \times 4 \times 8^2 = 49152 \]

这大约是 Direct 卷积 FLOP 数的 20%。从这里就能看出 Winograd 算法相对于 Direct 算法的效率提升的主要来源了。综合图像与卷积核变换的消耗,Winograd \(F(6,3)\) 的 FLOP 数可以达到 Direct 算法的 30%-40% 左右;这是非常惊人的提升了。

我们可以在此回顾 Winograd 算法与 Direct 算法两者的图像表示。我们看到输入图像中,对于 Direct 算法而言,图像上的大多数像素都被遍历了 9 次;但 Winograd \(F(6,3)\) 算法中,其中不少像素只被遍历了 1 次;只有少量的像素被遍历 2 或 4 次。这就是 Winograd 算法通过放大滤波而减少数乘计算量,从而减少总计算量的直观解释。

但为了放大滤波大小 \(\mu\),Winograd 算法要求事先与事后要对图像和卷积核作变换;这是代价。由于变换的成本随着滤波大小 \(\mu\) 呈平方级增长,但数乘最多只能降低 9 倍 (每个像素必然要被遍历一遍),因此滤波大小不可能无限地放大。一般程序的实现中,滤波大小 \(\mu\) 通常取到 4 或 6。我们在这份工作中使用的滤波大小是 \(\mu = 8\)。在下一篇文档中,我们会指出,显然 \(\mu = 8\) 没有达到理论最大加速;但若考虑到计算机缓存大小的因素,\(\mu = 8\) 应当基本到极限了。

读者不妨通过展开下述两个折叠块,重新直观地检视与比对 Direct 算法与 Winograd 算法。


卷积神经网络推理 (3):基于 L2 缓存优化的 Winograd 卷积的 C++ 实现#

公开时间:2022-09-04

备注

该工作是第五届 Ubiquant Challenge 量化新星挑战赛|并行程序设计竞赛的初赛入围工作,有一定的补充和删减。该工作在【天之孔】队长强宜澄的牵头下完成。

这份文档是使用 Winograd 算法高效实现 \(3 \times 3\) 卷积核神经网络的推理过程,并会作补充讨论。程序与初赛提交版本不同,对于程序可读性与规范性作改进,但算法思路完全一致。

【天之孔】

  1. Fate/Grand Order (作为 Fate/EXTRA CCC 衍生作品) 瞑生院祈荒 宝具。在人从苦界解脱并落入天之孔时,瞑生院可以感受到至高的愉悦。

  2. 天坑。代指天坑专业,如本队队员所处的化学专业、高分子专业。

危险

初赛部分的程序与大部分文档是由我完成的,本篇文档也只介绍这部分初赛工作,且绝非是最高效实现。这些也都整理到 gitee: ajz34/winograd6x3 中。

文档作者并没有计算机或高性能计算的专业知识。不能保证文档内容正确性或术语严格。

在并行程序设计决赛中,我们小组 (第二名) 在 Winograd 题中所使用的代码,是由强哥完成 (紧抱大腿 ≥ω≤)。尽管实现的仍然是 Winograd \(F(6, 3)\)、图像与卷积核变换代码与初赛相同,但除此之外的算法逻辑与程序完全不同、且更为高效。核心的改进点应是在指令集级别,参考 openBlas 对 DGEMM 等函数的多级缓存优化、micro-kernel 优化、计算过程隐藏读写延迟,以及适应性更广的边界情形处理等等。

赛题 baseline 请参考 gitee: benjie-miao/winograd-baseline。决赛获得冠军队伍的工作,请参考 github: Robslhc/ubiquant-winograd

在上一篇文档中,我们已经介绍了 Winograd 算法的具体过程与实现;但对 Winograd 算法的强大之处,之前只作定性上的讨论。

在这一篇文档,我们会讨论 Winograd 算法的实现,并且具体地表明 Winograd \(F(6,3)\) 算法相对于 Direct 算法的效率提升比例。我们可能会发现,合理地利用缓存 (cache) 和并行,对程序效率的提升是非常可观的;它甚至可能超过 Winograd 算法本身的效率提升。同时,我们也会尽可能利用 AVX 指令集,尽可能优化编译过程。最后我们程序的实现效率在大并行的情况下,在相当短的代码量下 (不使用汇编或大量宏代码共约 400 行),VGG16 平均效率会高于 oneDNN 库函数实现

尽管本文档并非是最高效率的实现,但我希望能借这样的机会,以非专业的角度,表述我在学习高性能计算问题时的一些步伐与心得。也希望能对同样非专业的同行们以一些启发。

警告

请注意,本文档使用 cling-xeus 引擎的 Jupyter Notebook。该引擎通常可以正确地编译并执行 C++ 代码,但无法在效率上与开足优化的 g++ 相比。因此,本文并不对 Jupyter Notebook 的 code block 进行耗时比较;而是使用 g++ 编译所得程序实现比较。

// NOTE: cling-xeus jupyter automatically includes common headers
//       such as vector, algorithms, chrono, ...
#include <iostream>
#include <chrono>
#include <immintrin.h>
#include <xmmintrin.h>

// use an appropriate omp path for your own 
#include "/share/Pub/zyzhu/miniconda3/lib/gcc/x86_64-conda-linux-gnu/9.3.0/include/omp.h"

// override default libgomp to avoid link failure in xcpp
#pragma cling load("libiomp5")

using namespace std;
omp_set_num_threads(8);

一些方便的函数定义如下:

  • allclose 函数用于验证两个向量的每个元素是否相等:

/// Check if vector a[s] and b[s] are close by checking every element
bool allclose(float * a, float * b, size_t s,
              float rtol=1e-4, float atol=1e-4) {
    for (size_t i = 0; i < s; ++i)
        if (abs(a[i] - b[i]) > (atol + rtol * abs(b[i]))) return false;
    return true;
}
  • ceildiv 函数用于给出天花板整数除:

template<typename T>
inline T ceildiv(T a, T b) {
    return (a + b - 1) / b;
}
  • product 函数给出数组的连乘积:

template<typename T>
inline T product(const std::vector<T>& v) {
    return std::accumulate(v.cbegin(), v.cend(), 1, std::multiplies<T>());
}
  • rand_vec 函数会生成随机数向量,随机数区间为 [0, 10):

template<typename T>
void rand_vec(T* v, size_t n) {
    for (size_t i = 0; i < n; ++i)
        v[i] = (T(rand()) / T(RAND_MAX)) * 10;
}
  • Range 类用于储存迭代序号信息 (通过成员变量 start, end 给出迭代的起始、终点位置,成员函数 size 给出迭代次数):

struct Range {
    int start;
    int end;
    Range(): start(-1), end(-1) {}
    Range(int start, int end): start(start), end(end) {}
    inline int size() const { return end - start; }
};

示例问题:经过更改的 VGG16 conv(3.2)#

我们在该文档的大部分时候,都会使用下述 VGG16 conv(3.2) 卷积层作为演示示例。只有在文档最后的测评阶段,我们才会使用完整的 VGG16 网络 [1]

  • 输入图像的高度 IH \(H_\mathrm{in} = 56\),宽度 IW \(W_\mathrm{in} = 56\)

  • 输入通道数 IC \(C_\mathrm{in} = 256\),输出通道数 OC \(C_\mathrm{out} = 256\)

  • 每批图像数量 N \(N = 8\)

导出量是

  • 输出图像的高度 OH \(H_\mathrm{out} = 54\),宽度 OW \(W_\mathrm{out} = 54\)

  • Winograd 算法需要在高方向上处理 TH \(\tilde H = \lceil H_\mathrm{out} / 6 \rceil = 9\) 次,宽方向也是 TW \(\tilde W = 9\)。当前情形不需要考虑无法整除时的边界情况。

关键的输入、输出张量是

  • 输入图像 image \(d_{i,c,x,y}\),维度 \((N, C_\mathrm{in}, H_\mathrm{in}, W_\mathrm{in})\)

  • 卷积核 filtr \(g_{k,c,u,v}\),维度 \((C_\mathrm{out}, C_\mathrm{in}, 3, 3)\)

  • 输出图像 result \(Y_{i,k,x,y}\),维度 \((N, C_\mathrm{out}, H_\mathrm{out}, W_\mathrm{out})\)

其中,输入图像与卷积核是输入量、输出图像是输出量。

size_t IH = 56, IW = 56, IC = 256, OC = 256, N = 8;
size_t OH = IH - 2, OW = IW - 2;
size_t TH = ceildiv<size_t>(OH, 6), TW = ceildiv<size_t>(OW, 6);

vector<size_t> dim_image  {  N, IC, IH, IW };
vector<size_t> dim_filtr  { OC, IC,  3,  3 };
vector<size_t> dim_result {  N, OC, OH, OW };

size_t size_image  = product<size_t>(dim_image);
size_t size_filtr  = product<size_t>(dim_filtr);
size_t size_result = product<size_t>(dim_result);

在 C++ 程序中,我们有意不使用便利的库 (譬如 Eigen),也不使用 std::vector 储存矩阵或张量。我们简单地使用 float *

  • 使用 float * 的好处是,它就是简单的指针;如果程序要开放以 C 语言为函数的接口,那么就省去了从 std::vector<float>float * 的转换过程。

  • 在控制向量的对齐时,使用 float * 会非常方便。向量的对齐 (align) 通常在存读数据时有一定优势。

  • float * 的明显缺点是,其使用不是很安全,并且在高维张量索引上比较麻烦。

float * image, * filtr, * result;
image  = (float *) aligned_alloc(64, size_image  * sizeof(float));
filtr  = (float *) aligned_alloc(64, size_filtr  * sizeof(float));
result = (float *) aligned_alloc(64, size_result * sizeof(float));

我们使用 (伪) 随机数初始化:

srand(0);
rand_vec(image, size_image);
rand_vec(filtr, size_filtr);

Direct 卷积的平凡算法#

我们曾经在第一篇文档的 极简 Python 实现 中给出 Direct 卷积的平凡算法的公式与实现,并在 最简单 C++ 实现 中展示了平凡算法的 (糟糕的) 效率。

这里运行代码的目的是生成作为参考的结果。

float * result_ref;
result_ref = (float *) aligned_alloc(64, size_result * sizeof(float));
for (int i = 0; i < size_result; ++i) result_ref[i] = 0;
auto start = chrono::steady_clock::now();

for (size_t i = 0; i < N; ++i)
for (size_t k = 0; k < OC; ++k)
for (size_t x = 0; x < OH; ++x)
for (size_t y = 0; y < OW; ++y)
for (size_t c = 0; c < IC; ++c)
for (size_t u = 0; u < 3; ++u)
for (size_t v = 0; v < 3; ++v)
    result_ref[((i * OC + k) * OH +   x) * OW +   y] += \
    image     [((i * IC + c) * IH + x+u) * IW + y+v] * \
    filtr     [((k * IC + c) *  3 +   u) *  3 +   v];

auto end = chrono::steady_clock::now();
chrono::duration<double> elapsed_seconds = end - start;
cout << "elapsed time: " << elapsed_seconds.count() * 1000 << " msec";
elapsed time: 71711.4 msec

Winograd \(F(6, 3)\) 优化策略概述#

Winograd \(F(6, 3)\) 算法回顾#

首先我们需要回顾 Winograd \(F(6, 3)\) 算法。该算法滤波前的卷积核大小是 \(3\),滤波放大后的卷积核大小是 \(\mu = 6+2 = 8\)。下面通篇中,用斜体标注的矩阵的角标是完整的。该算法在最简单的拆分下,可以看作是

  1. 输入图像变换 (角标索引 \(i, c, \tilde x, \tilde y\),其中 \(\tilde x\) 取值是 \(\{0, 6, 12, \cdots, H_\mathrm{in} - \mu = 50\}\)\(\tilde y\) 取值是 \(\{0, 6, 12, \cdots, W_\mathrm{in} - \mu = 50\}\);输入图像张量的另一种表示 \(d^{(\tilde x, \tilde y)}_{i,c,t,w} = d_{i,c,\tilde x+t,\tilde y+w}\))

    \[ V_{i,c,r,s}^{(\tilde x, \tilde y)} = \sum_t^\mu \sum_s^\mu B_{tr} d^{(\tilde x, \tilde y)}_{i,c,t,w} B_{ws} \;\; \text{or} \;\; \mathrm{V}_{i,c}^{(\tilde x, \tilde y)} = \mathrm{B}^\dagger \mathrm{d}^{(\tilde x, \tilde y)}_{i,c} \mathrm{B} \]
  2. 卷积核变换 (角标索引 \(k, c\))

    \[ U_{k,c,r,s} = \sum_u^K \sum_v^K G_{r,u} g_{k,c,u,v} G_{s,v} \;\; \mathrm{or} \;\; \mathrm{U}_{k,c} = \mathrm{G} \mathrm{g}_{k,c} \mathrm{G}^\dagger \]
  3. 数乘 (角标索引 \(i, k, \tilde x, \tilde y, r, s\))

    \[ M_{i,k,r,s}^{(\tilde x, \tilde y)} = \sum_c^{C_\mathrm{in}} U_{k,c,r,s} V_{i,c,r,s}^{(\tilde x, \tilde y)} \;\; \mathrm{or} \;\; \mathrm{M}_{i,k}^{(\tilde x, \tilde y)} = \sum_c^{C_\mathrm{in}} \mathrm{U}_{k,c} \odot \mathrm{V}_{i,c}^{(\tilde x, \tilde y)} \]
  4. 输出图像变换

    \[ Y_{i,k,\tilde x+a, \tilde y+b} = Y_{i,k,a,b}^{(\tilde x, \tilde y)} = \sum_r^\mu \sum_s^\mu A_{r,a} M^{(\tilde x, \tilde y)}_{i,k,r,s} A_{s,b} \;\; \mathrm{or} \;\; \mathrm{Y}_{i,k}^{(\tilde x, \tilde y)} = \mathrm{A}^\dagger \mathrm{M}^{(\tilde x, \tilde y)}_{i,k} \mathrm{A} \]

优化策略#

对于整个卷积过程,或 Winograd 算法中,浮点计算数 FLOPs 最大的步骤一般应当是数乘步骤 (这应当是需要验证的,但这里我们假设如此)。因此,第三步数乘的优化尤为关键

计算效率的基础优化大致分为两类情况:对浮点运算数的优化 (节省 CPU 运算时间)、以及对内存通信的优化 (节省 CPU 高级缓存、缓存到内存的通信时间)。对于 Winograd \(F(6,3)\) 算法而言,数乘运算次数是确定的。如果希望改进数乘算法,那么就需要使用更大的 Winograd 滤波;这当然是一种策略,但我们马上也会认识到这种做法的局限性。我们着重于内存通信的优化

我们注意到,如果对待运算的张量不加以内存大小限制,那么内存通信就必须要在频率较低的 L3 缓存、甚至主内存空间完成。

  • 对于滤波后的卷积核 \(U_{k, c, r, s}\),其维度是 \((C_\mathrm{out}, C_\mathrm{in}, \mu, \mu) = (256, 256, 8, 8)\),因此其占用的内存空间是 16 MB,或 (一个浮点数为 32 bit 或 4 Byte)

    \[ 256 \times 256 \times 8 \times 8 \times 4 \, \text{Byte} = 16 \, \text{MB} \]
  • 对于变换后的输入图像 \(V_{i,c,r,s}^{(\tilde x, \tilde y)}\),其维度是 \((N, C_\mathrm{in}, \mu, \mu, \tilde H, \tilde W) = (8, 256, 8, 8, 9, 9)\),占用的内存空间是 40.5 MB。

我们用于测试算法的设备是 Intel Xeon Gold 6150 (4 sockets),共 36 物理内核 (cores), 72 线程 (threads)。在这个设备上,我们程序能有效进行的并发数是物理内核数 36,而非线程数。一些细节参数是 (带宽由 Intel Advisor 给出)

缓存或内存

缓存大小

带宽

L1

32 kB / core

483 GB / sec·core

L2

1024 kB / core

228 GB / sec·core

L3

24.75 MB / 18 cores

213 GB / sec

DRAM

101 GB / sec

需要指出,L2 缓存的带宽是每个物理内核的速度,因此相比较 L3 带宽的效能提升是相当巨大的。同时 L2 缓存的大小也较大。而 L1 缓存的空间太小,要最大化利用 L1 缓存的难度比较大。因此,对内存通信的优化问题中,基于 L2 缓存优化是第一个、也是最重要的优化策略。

注意到变换后的卷积核 \(U_{k, c, r, s}\) 的占用空间相对较小、使用次数较多。因此我们可以考虑对该卷积核,依 L2 缓存大小,进行分批次计算

具体来说,令 \(\tilde C, \tilde K\) 分别是单次分批处理时,变换后卷积矩阵 \(\mathrm{U}\) 的输入、输出通道大小。在我们的程序实现中,经过一些试错性尝试,认为效率比较高的做法是,每批次输入通道数是 \(\tilde C = 32\),输出通道数 \(\tilde K = 64\)。在这种情形下,每批次数乘计算的 \(\mathrm{U}\) 矩阵的维度是 \((\tilde C, \tilde K, \mu, \mu) = (32, 84, 8, 8)\),即 512 kB;该大小确实地控制在了 L2 缓存大小 1024 kB / core 以下。

为了较严格地用公式表达,我们定义矩阵 \(\mathrm{U}_{k,c}^{(\tilde k, \tilde c)} = \mathrm{U}_{\tilde k + k, \tilde c + c}\)。这里 \(\tilde k, \tilde c\) 的取值分别是 \(\{0, \tilde K, 2 \tilde K, \cdots, C_\mathrm{out} - \tilde K \}\)\(\{0, \tilde C, 2 \tilde C, \cdots, C_\mathrm{out} - \tilde C \}\),表示分批的批次;\(k \in [0, \tilde K), c \in [0, \tilde C)\) 则表示在每一个批次中卷积核矩阵的角标。

由此,我们可以比较清晰地写出我们所使用的基于 L2 缓存优化的 Winograd 算法。

winograd L2 batched algorithms

上述的算法过程看起来比最初提到的算法要臃肿。原始的算法中,输入、输出图像都只需要读取一次即可。但当前的算法中,输入图像会读取 \(C_\mathrm{out} / \tilde K = 4\) 次,而输出图像的内存则要经过 \(C_\mathrm{in} / \tilde C = 8\) 次写入。这看起来似乎是巨大的资源浪费;但为了高效地实现最耗时的数乘步骤,其余步骤中内存通信量的牺牲是值得的。

上面算法伪代码中,特意留出了第 4 行与第 8 行,表明单次用于数乘运算的变换后卷积核矩阵 \(\mathrm{U}^{(\tilde k, \tilde c)}_{k, c}\) 与变换后输入图像矩阵 \(\mathrm{V}^{(\tilde x, \tilde y, \tilde c)}_{i, c}\) 的内存占用大小。如前所述,\(\mathrm{U}\) 的内存占用是 512 kB,小于 L2 缓存。我们还会发现,对于 \(\mathrm{V}\),其维度是 \((\tilde C, \mu, \mu) \rightarrow (32, 8, 8)\) 即 8 kB。这个大小也小于 L1 缓存的 32 kB。因此作为基于 L2 缓存优化的副产物,数乘运算也对 L1 缓存一定程度上友好。

Winograd \(F(6,3)\) 单步计算程序与复杂度分析#

滤波变换:一般总结#

我们简单再回顾一下上面伪代码算法中出现的滤波变换:

  • 计算卷积核变换:\(\mathrm{U}^{(\tilde k, \tilde c)}_{k,c} {}^\dagger = \mathrm{G} (\mathrm{G} \mathrm{g}^{(\tilde k, \tilde c)}_{k,c})^\dagger\)

  • 计算输入图像变换:\(\mathrm{V}_{i,c}^{(\tilde x, \tilde y, \tilde c)} {}^\dagger = \mathrm{B}^\dagger (\mathrm{B}^\dagger \mathrm{d}^{(\tilde x, \tilde y, \tilde c)}_{i,c})^\dagger\)

  • 计算输出图像变换:\(\mathrm{Y}_{i,k}^{(\tilde x, \tilde y)} \mathrel{+}= \mathrm{A}^\dagger (\mathrm{A}^\dagger \mathrm{M}^{(\tilde x, \tilde y, \tilde c)}_{i,k} {}^\dagger)^\dagger\)

这三种变换有共同的特征:1) 一个数值确定的矩阵 (\(\mathrm{G}, \mathrm{B}^\dagger, \mathrm{A}^\dagger\)) 与数值可变的矩阵乘积;2) 上述结果转置;3) 上述数值确定的矩阵 (\(\mathrm{G}, \mathrm{B}^\dagger, \mathrm{A}^\dagger\)) 再次进行乘积。

那么在后续的实现中,就要考虑到 1) 矩阵转置的高效实现;上述涉及到的矩阵维度都不超过 \((\mu, \mu)\)\((8, 8)\) 维度,因此我们也只着重考察 8x8 转置问题。2) 与数值确定的矩阵 (\(\mathrm{G}, \mathrm{B}^\dagger, \mathrm{A}^\dagger\)) 的乘积可以使用手写的、特化的程序,而不需要使用通用的矩阵乘法 (譬如 Level 3 Blas 的 SGEMM 等等),从而最大程度上节约实际所需的浮点运算数。

在后续的文段中,为了方便,我们会统一将数值可变的矩阵记为 \(\mathrm{D}\)

SIMD 与 Intrinsic 指令#

现在的 CPU 通常支持 SIMD (Single Instruction, Multiple Data) 机制 (一种向量化编程的模式)。对于 Skylake 系列的 CPU (支持 AVX512 指令集),如果要进行普通的单个浮点数加法 \(b + c\) 或乘法 \(b \cdot c\),那么需要一个汇编指令 addssmulss。而执行向量长度为 16 的向量数乘与加法 \(\boldsymbol{a} \mathrel{+}= \boldsymbol{b} \odot \boldsymbol{c}\) 单浮点数运算 (Fused-Multiple-Add, FMA),也只需要一个汇编指令 vfmadd***ps 即可。长度为 16 的 FMA 指令需要处理 16 次乘法与 16 次加法,即总共是 32 次加与乘法。而在 Skylake 系列上,FMA (32 次加与乘法)、单次加法、单次乘法单个指令的延迟 (latency) 与吞吐量 (throughput) 是完全一致的。因而若能合理地利用 SIMD 指令,运算的速度最快可以提升 32 倍!

在这份文档中,为了在一次指令下完成更多的计算任务,我们的所有计算过程基于 __m512 编写。__m512 是一种基于向量化 (Vectorization) 的类型;使用其的目的是尽量在单个指令下完成多个数据的计算;一个 __m512 向量可以储存 16 个单浮点数。在 C++ 本身的语言特性中,没有对其的规范;但对于大多数基于 x86 的 CPU,可以使用固有指令 (Intrinsic),在 C 或 C++ 的高级语言级别进行编程。Intel Intrinsics Guide 非常完整地罗列了可用的 Intrinsics。

需要指出,尽管一般来说,一行 Intrinsic 对应一行 Assembly (最底层的汇编指令);但 Intrinsic 应当只是给编译器推荐合适的 Assembly,而并不是要求编译器必须使用我们所希望的 Assembly。因而若要清楚地确定当前的计算机是如何实现一段计算过程,那么最好要从 Assembly 来看。同时,像 -O3, --march=native 等编译指令事实上也会对一般的程序 (不使用 Intrinsic 编程的程序) 进行向量化优化;因而对于相对简单的问题,只要代码写得足够清晰高效,不需要 Intrinsic 一样可以达到很高的效率。

在当前的问题中,出现的 Assembly 会有 (下表的 Latency 与 Throughput 数据取自于 Intel 所提供的 Skylake 系列;编译时加入 -S 选项可以生成汇编文件 *.s 以详细查看汇编代码)

Instruction

指令目的

Latency

Throughput

vmovaps, vmovups

内存移动

5~8

0.5~1

vaddps, vsubps, vmulps

加、减、乘运算

4

0.5

vfmadd***ps, vfmsub***ps

FMA 运算

4

0.5

vunpcklps, vunpckhps, vshufps

向量混合

1

1

vpermi2ps, vpermt2ps

向量重排

3

1

上述表格给予我们至少两个信息 (以 Skylake 为前提):

  1. FMA 运算与加、减、乘法运算的耗时和延迟是一致的;因此在可以用 FMA 运算的地方,要尽量使用。这可以最大提升 2 倍的运算力。

  2. 内存移动的消耗通常比运算要更大。要尽可能减少内存移动。

__m512 下 8x8 转置的实现#

对于使用 __m512 向量类型,我们会发现一个问题:一个 __m512 向量可以储存的 float 类型数是 16;它不能直接用于 8x8 的转置。

在这里,由于当前问题所需要进行的矩阵转置数量很大,我们所使用的解决方案是,使用 8 个 __m512 向量,一次性处理两个矩阵转置问题。

下述函数 mm_transpose_8x8 的输入 row__m512 类型的两个横向并排待转置的矩阵,输出 tr 是两个横向并排的结果矩阵。需要注意,输入的指令集向量 row 的数据会被更改。

/// +---+---+     +---+---+
/// |   |   | --> | T | T |
/// +---+---+     +---+---+
inline void mm_transpose_8x8(__m512 row[8], __m512 tr[8]) {
    const __m512i i0x20 = _mm512_set_epi32(033, 032, 031, 030, 013, 012, 011, 010, 023, 022, 021, 020, 003, 002, 001, 000);
    const __m512i i0x31 = _mm512_set_epi32(037, 036, 035, 034, 017, 016, 015, 014, 027, 026, 025, 024, 007, 006, 005, 004);
    tr[0] = _mm512_unpacklo_ps(row[0], row[1]);
    tr[1] = _mm512_unpackhi_ps(row[0], row[1]);
    tr[2] = _mm512_unpacklo_ps(row[2], row[3]);
    tr[3] = _mm512_unpackhi_ps(row[2], row[3]);
    tr[4] = _mm512_unpacklo_ps(row[4], row[5]);
    tr[5] = _mm512_unpackhi_ps(row[4], row[5]);
    tr[6] = _mm512_unpacklo_ps(row[6], row[7]);
    tr[7] = _mm512_unpackhi_ps(row[6], row[7]);
    row[0] = _mm512_shuffle_ps(tr[0], tr[2], _MM_SHUFFLE(1, 0, 1, 0));
    row[1] = _mm512_shuffle_ps(tr[0], tr[2], _MM_SHUFFLE(3, 2, 3, 2));
    row[2] = _mm512_shuffle_ps(tr[1], tr[3], _MM_SHUFFLE(1, 0, 1, 0));
    row[3] = _mm512_shuffle_ps(tr[1], tr[3], _MM_SHUFFLE(3, 2, 3, 2));
    row[4] = _mm512_shuffle_ps(tr[4], tr[6], _MM_SHUFFLE(1, 0, 1, 0));
    row[5] = _mm512_shuffle_ps(tr[4], tr[6], _MM_SHUFFLE(3, 2, 3, 2));
    row[6] = _mm512_shuffle_ps(tr[5], tr[7], _MM_SHUFFLE(1, 0, 1, 0));
    row[7] = _mm512_shuffle_ps(tr[5], tr[7], _MM_SHUFFLE(3, 2, 3, 2));
    tr[0] = _mm512_permutex2var_ps(row[0], i0x20, row[4]);
    tr[1] = _mm512_permutex2var_ps(row[1], i0x20, row[5]);
    tr[2] = _mm512_permutex2var_ps(row[2], i0x20, row[6]);
    tr[3] = _mm512_permutex2var_ps(row[3], i0x20, row[7]);
    tr[4] = _mm512_permutex2var_ps(row[0], i0x31, row[4]);
    tr[5] = _mm512_permutex2var_ps(row[1], i0x31, row[5]);
    tr[6] = _mm512_permutex2var_ps(row[2], i0x31, row[6]);
    tr[7] = _mm512_permutex2var_ps(row[3], i0x31, row[7]);
}

矩阵转置的演示效果如下所示。

// Initialize intrinsics
float a[128], b[16]; __m512 t[8], r[8];
for (size_t i = 0; i < 64; ++i) {
    a[i / 8 * 16 + i % 8] = i;
    a[i / 8 * 16 + i % 8 + 8] = i + 64;
}
for (size_t i = 0; i < 8; ++i) t[i] = _mm512_loadu_ps(&a[i * 16]);

printf("Original Matrix:\n");
for (size_t i = 0; i < 8; ++i) {
    _mm512_store_ps(&b[0], t[i]);
    for (size_t j = 0; j < 16; ++j) printf("%4.0f", b[j]);
    printf("\n");
}

// Transform 8x8 for intrinsics (t -> r)
mm_transpose_8x8(&t[0], &r[0]);
printf("\nTransposed Matrix (8x8):\n");
for (size_t i = 0; i < 8; ++i) {
    _mm512_store_ps(&b[0], r[i]);
    for (size_t j = 0; j < 16; ++j) printf("%4.0f", b[j]);
    printf("\n");
}
Original Matrix:
   0   1   2   3   4   5   6   7  64  65  66  67  68  69  70  71
   8   9  10  11  12  13  14  15  72  73  74  75  76  77  78  79
  16  17  18  19  20  21  22  23  80  81  82  83  84  85  86  87
  24  25  26  27  28  29  30  31  88  89  90  91  92  93  94  95
  32  33  34  35  36  37  38  39  96  97  98  99 100 101 102 103
  40  41  42  43  44  45  46  47 104 105 106 107 108 109 110 111
  48  49  50  51  52  53  54  55 112 113 114 115 116 117 118 119
  56  57  58  59  60  61  62  63 120 121 122 123 124 125 126 127

Transposed Matrix (8x8):
   0   8  16  24  32  40  48  56  64  72  80  88  96 104 112 120
   1   9  17  25  33  41  49  57  65  73  81  89  97 105 113 121
   2  10  18  26  34  42  50  58  66  74  82  90  98 106 114 122
   3  11  19  27  35  43  51  59  67  75  83  91  99 107 115 123
   4  12  20  28  36  44  52  60  68  76  84  92 100 108 116 124
   5  13  21  29  37  45  53  61  69  77  85  93 101 109 117 125
   6  14  22  30  38  46  54  62  70  78  86  94 102 110 118 126
   7  15  23  31  39  47  55  63  71  79  87  95 103 111 119 127

将连续内存空间转换到可计算的指令集上#

这里还遇到一个问题。譬如对于这种情形:一个矩阵有 8x8 个元素,我们希望对其行向量进行加减操作。但对于 __m512 类型的向量,每个向量都可以放 16 个浮点数。因此连续内存下,两个 8x8 个元素的矩阵,储存方式其实是放在两个 4x16 的向量中。这样的存储方式会给计算带来些许麻烦。

为了使计算更为方便,我们可以将 2 个连续内存下的 8x8 矩阵进行重排,使得第一个矩阵在 8 个 __m512 的左侧、第二个矩阵在右侧。这样,向量的加法计算就可以在两个并排的 8x8 矩阵下容易完成了。

具体的做法是,对于连续内存到计算空间下的转换定义为 mm_transpose_8x16_row2col

//   +-------+-------+     +-------+-------+
//   +       1       +     +       +       +
//   +-------+-------+ --> +   1   +   2   +
//   +       2       +     +       +       +
//   +-------+-------+     +-------+-------+
inline void mm_transpose_8x16_row2col(__m512 row[8], __m512 tr[8]) {
    const __m512i ihi = _mm512_set_epi32(027, 026, 025, 024, 023, 022, 021, 020, 007, 006, 005, 004, 003, 002, 001, 000);
    const __m512i ilo = _mm512_set_epi32(037, 036, 035, 034, 033, 032, 031, 030, 017, 016, 015, 014, 013, 012, 011, 010);
    tr[0] = _mm512_permutex2var_ps(row[0], ihi, row[4]);
    tr[1] = _mm512_permutex2var_ps(row[0], ilo, row[4]);
    tr[2] = _mm512_permutex2var_ps(row[1], ihi, row[5]);
    tr[3] = _mm512_permutex2var_ps(row[1], ilo, row[5]);
    tr[4] = _mm512_permutex2var_ps(row[2], ihi, row[6]);
    tr[5] = _mm512_permutex2var_ps(row[2], ilo, row[6]);
    tr[6] = _mm512_permutex2var_ps(row[3], ihi, row[7]);
    tr[7] = _mm512_permutex2var_ps(row[3], ilo, row[7]);
}

其使用效果是

// Initialize intrinsics
float a[128], b[16]; __m512 t[8], r[8];
for (size_t i = 0; i < 128; ++i) a[i] = i;
for (size_t i = 0; i < 8; ++i) t[i] = _mm512_loadu_ps(&a[i * 16]);

printf("Original Matrix:\n");
for (size_t i = 0; i < 8; ++i) {
    _mm512_store_ps(&b[0], t[i]);
    for (size_t j = 0; j < 16; ++j) printf("%4.0f", b[j]);
    printf("\n");
}

// Transform 8x16 row->col
mm_transpose_8x16_row2col(&t[0], &r[0]);
printf("\nTransposed Matrix (8x16, row->col):\n");
for (size_t i = 0; i < 8; ++i) {
    _mm512_store_ps(&b[0], r[i]);
    for (size_t j = 0; j < 16; ++j) printf("%4.0f", b[j]);
    printf("\n");
}
Original Matrix:
   0   1   2   3   4   5   6   7   8   9  10  11  12  13  14  15
  16  17  18  19  20  21  22  23  24  25  26  27  28  29  30  31
  32  33  34  35  36  37  38  39  40  41  42  43  44  45  46  47
  48  49  50  51  52  53  54  55  56  57  58  59  60  61  62  63
  64  65  66  67  68  69  70  71  72  73  74  75  76  77  78  79
  80  81  82  83  84  85  86  87  88  89  90  91  92  93  94  95
  96  97  98  99 100 101 102 103 104 105 106 107 108 109 110 111
 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127

Transposed Matrix (8x16, row->col):
   0   1   2   3   4   5   6   7  64  65  66  67  68  69  70  71
   8   9  10  11  12  13  14  15  72  73  74  75  76  77  78  79
  16  17  18  19  20  21  22  23  80  81  82  83  84  85  86  87
  24  25  26  27  28  29  30  31  88  89  90  91  92  93  94  95
  32  33  34  35  36  37  38  39  96  97  98  99 100 101 102 103
  40  41  42  43  44  45  46  47 104 105 106 107 108 109 110 111
  48  49  50  51  52  53  54  55 112 113 114 115 116 117 118 119
  56  57  58  59  60  61  62  63 120 121 122 123 124 125 126 127

如果希望从计算空间转换到连续内存,则反其道而行之。函数定义为 mm_transpose_8x16_col2row

//   +-------+-------+     +-------+-------+
//   +       +       +     +       1       +
//   +   1   +   2   + --> +-------+-------+
//   +       +       +     +       2       +
//   +-------+-------+     +-------+-------+
inline void mm_transpose_8x16_col2row(__m512 row[8], __m512 tr[8]) {
    const __m512i ihi = _mm512_set_epi32(027, 026, 025, 024, 023, 022, 021, 020, 007, 006, 005, 004, 003, 002, 001, 000);
    const __m512i ilo = _mm512_set_epi32(037, 036, 035, 034, 033, 032, 031, 030, 017, 016, 015, 014, 013, 012, 011, 010);
    tr[0] = _mm512_permutex2var_ps(row[0], ihi, row[1]);
    tr[1] = _mm512_permutex2var_ps(row[2], ihi, row[3]);
    tr[2] = _mm512_permutex2var_ps(row[4], ihi, row[5]);
    tr[3] = _mm512_permutex2var_ps(row[6], ihi, row[7]);
    tr[4] = _mm512_permutex2var_ps(row[0], ilo, row[1]);
    tr[5] = _mm512_permutex2var_ps(row[2], ilo, row[3]);
    tr[6] = _mm512_permutex2var_ps(row[4], ilo, row[5]);
    tr[7] = _mm512_permutex2var_ps(row[6], ilo, row[7]);
}

其使用效果是

// Transform 8x16 row->col
mm_transpose_8x16_col2row(&r[0], &t[0]);
printf("\nTransposed Matrix (8x16, col->row):\n");
for (size_t i = 0; i < 8; ++i) {
    _mm512_store_ps(&b[0], t[i]);
    for (size_t j = 0; j < 16; ++j) printf("%4.0f", b[j]);
    printf("\n");
}
Transposed Matrix (8x16, col->row):
   0   1   2   3   4   5   6   7   8   9  10  11  12  13  14  15
  16  17  18  19  20  21  22  23  24  25  26  27  28  29  30  31
  32  33  34  35  36  37  38  39  40  41  42  43  44  45  46  47
  48  49  50  51  52  53  54  55  56  57  58  59  60  61  62  63
  64  65  66  67  68  69  70  71  72  73  74  75  76  77  78  79
  80  81  82  83  84  85  86  87  88  89  90  91  92  93  94  95
  96  97  98  99 100 101 102 103 104 105 106 107 108 109 110 111
 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127

输入图像变换#

输入图像变换过程为

\[ V^\dagger = B^\dagger (B^\dagger D)^\dagger \]

这个过程中的关键步骤是 \(T = B^\dagger D\)。尽管矩阵乘法的实现非常简单,但会产生大量不必要的浮点运算。因此,这类矩阵乘法运算要手动将实现。

若记向量 \(T_i, D_i\) 分别是矩阵 \(T, D\) 的第 \(i\) 行,那么

\[\begin{split} \begin{align*} T_0 &= D_0 + 5.25 \times (D_4 - D_2) - D_6 \\ T_1 &= \big( D_2 - 4.25 \times D_4 + D_6 \big) + \big( D_1 - 4.25 \times D_3 + D_5 \big) \\ T_2 &= \big( D_2 - 4.25 \times D_4 + D_6 \big) - \big( D_1 - 4.25 \times D_3 + D_5 \big) \\ T_3 &= \big( 0.25 \times D_2 - 1.25 \times D_4 + D_6 \big) + \big( 0.5 \times D_1 - 2.5 \times D_3 + 2 \times D_5 \big) \\ T_4 &= \big( 0.25 \times D_2 - 1.25 \times D_4 + D_6 \big) - \big( 0.5 \times D_1 - 2.5 \times D_3 + 2 \times D_5 \big) \\ T_5 &= \big( 4 \times D_2 - 5 \times D_4 + D_6 \big) + \big( 2 \times D_1 - 2.5 \times D_3 + 0.5 \times D_5 \big) \\ T_6 &= \big( 4 \times D_2 - 5 \times D_4 + D_6 \big) - \big( 2 \times D_1 - 2.5 \times D_3 + 0.5 \times D_5 \big) \\ T_7 &= D_7 + 5.25 \times (D_3 - D_5) - D_1 \end{align*} \end{split}\]

程序的实现为 transform_BtD_6x3。其运算是通过输入 8 行指令向量 D、输出 8 行指令向量 BtD 实现的。对于 GCC 编译器,其当前版本对指令集的支持程度可能好于 ICC 编译器;对于指令集加、减、乘法和 FMA 运算,不需要使用 Intrinsic (譬如 _mm512_fmadd_ps 这类又长又难于看懂的指令函数),编译器可以正确地编译出高效的 SIMD 汇编。

inline void transform_BtD_6x3(const __m512 D[8], __m512 BtD[8]) {
    __m512 s0, s1;
    BtD[0] = D[0] + 5.25f * (D[4] - D[2]) - D[6];
    s0 = D[1] - 4.25f * D[3] + D[5];
    s1 = D[2] - 4.25f * D[4] + D[6];
    BtD[1] = s0 + s1;
    BtD[2] = s1 - s0;
    s0 = 0.5f * D[1] - 2.5f * D[3] + 2.f * D[5];
    s1 = D[6] + 0.25f * D[2] - 1.25f * D[4];
    BtD[3] = s0 + s1;
    BtD[4] = s1 - s0;
    s0 = 2.f * D[1] - 2.5f * D[3] + 0.5f * D[5];
    s1 = D[6] + 4.f * D[2] - 5.f * D[4];
    BtD[5] = s0 + s1;
    BtD[6] = s1 - s0;
    BtD[7] = D[7] - D[1] + 5.25f * (D[3] - D[5]);
}

在实际的程序实现中,我们需要一次性对两个输入图像矩阵进行变换。这两个矩阵未必需要在相连的内存空间中;但输出时最好需要在相连的内存实现。

下述程序 perform_image_transform 将两个输入图像 8x8 矩阵 im1, im2 代入,输出连续内存空间的 8x16 的指令集 v 的函数。该函数同时需要传入图像矩阵的首行维度 IW \(W_\mathrm{in}\)

inline void perform_image_transform(const float im1[], const float im2[], __m512 v[], int IW) {
    __m512 zmm_a[8], zmm_b[8];
    // Initialize __m512 of two input images
    for (int i = 0; i < 8; ++i) {
        zmm_b[i] = _mm512_loadu_ps(&im1[i * IW]);
        zmm_b[i] = _mm512_insertf32x8(zmm_b[i], _mm256_loadu_ps(&im2[i * IW]), 1);
    }
    // Perform B.T @ D
    transform_BtD_6x3(zmm_b, zmm_a);
    // Perform (B.T @ D).T
    mm_transpose_8x8(zmm_a, zmm_b);
    // Perform V.T = B.T @ (B.T @ D).T
    transform_BtD_6x3(zmm_b, zmm_a);
    // Consequential memory for two transformed images
    mm_transpose_8x16_col2row(zmm_a, v);
}

卷积核变换#

卷积核变换过程为

\[ U^\dagger = G (G D)^\dagger \]

这个过程中关键的步骤是 \(T = GD\)。该过程涉及到的输入 \(D\) 或者 \((GD)^\dagger\) 是 3 行的矩阵,但输出是 8 行的矩阵。

\[\begin{split} \begin{align*} T_0 &= D_0 \\ T_1 &= -2/9 \times (D_0 + D_2) - 2/9 \times D_1 \\ T_2 &= -2/9 \times (D_0 + D_2) + 2/9 \times D_1 \\ T_3 &= (1/90 \times D_0 + 2/45 \times D_2) + 1/45 \times D_1 \\ T_4 &= (1/90 \times D_0 + 2/45 \times D_2) - 1/45 \times D_1 \\ T_5 &= (32/45 \times D_0 + 8/45 \times D_2) + 16/45 \times D_1 \\ T_6 &= (32/45 \times D_0 + 8/45 \times D_2) - 16/45 \times D_1 \\ T_7 &= D_2 \end{align*} \end{split}\]

程序的实现为 transform_GD_6x3。其运算是通过输入 3 行指令集向量 D、输出 8 行指令集向量 GD 实现的。

inline void transform_GD_6x3(const __m512 D[3], __m512 GD[8]) {
    __m512 s0, s1;
    GD[0] = D[0];
    GD[7] = D[2];
    s0 = -2.f/9.f * (D[0] + D[2]);
    s1 = -2.f/9.f * D[1];
    GD[1] = s0 + s1;
    GD[2] = s0 - s1;
    s0 = 1.f/90.f * D[0] + 2.f/45.f * D[2];
    s1 = 1.f/45.f * D[1];
    GD[3] = s0 + s1;
    GD[4] = s0 - s1;
    s0 = 32.f/45.f * D[0] + 8.f/45.f * D[2];
    s1 = 16.f/45.f * D[1];
    GD[5] = s0 + s1;
    GD[6] = s0 - s1;
}

下述程序 perform_filter_transform 将两个卷积核 3x3 矩阵 f[0:9], f[9:18] 代入 (这里要求两个卷积核是内存连续的),输出连续内存空间的 8x16 的指令集 zmm_u 的函数。

inline void perform_filter_transform(const float f[], __m512 zmm_u[]) {
    // Initialize __m512 of two input filters (convolution kernels)
    __m512 zmm_a[8], zmm_b[8];
    for (int i = 0; i < 3; ++i) {
        zmm_b[i] = _mm512_loadu_ps(&f[3 * i]);
        zmm_b[i] = _mm512_insertf32x8(zmm_b[i], _mm256_loadu_ps(&f[9 + 3 * i]), 1);
    }
    // Perform G @ D
    transform_GD_6x3(zmm_b, zmm_a);
    // Perform (G @ D).T
    mm_transpose_8x8(zmm_a, zmm_b);
    // Perform U.T = G @ (G @ D).T
    transform_GD_6x3(zmm_b, zmm_a);
    // Consequential memory for two transformed filters
    mm_transpose_8x16_col2row(zmm_a, zmm_u);
}

输出图像变换#

输出图像变换过程比较类似于输入图像变换:

\[ Y = A^\dagger (A^\dagger D)^\dagger \]

这个过程中关键的步骤是 \(T = A^\dagger D\)。该过程涉及到的输入 \(D\) 或者 \((GD)^\dagger\) 是 8 行的矩阵,输出是 6 行的矩阵。

\[\begin{split} \begin{align*} T_0 &= (D_1 + D_2) + (D_3 + D_4) + (D_5 + D_6) + D_0 \\ T_2 &= (D_1 + D_2) + 4 \times (D_3 + D_4) + \frac{1}{4} \times (D_5 + D_6) \\ T_4 &= (D_1 + D_2) + 16 \times (D_3 + D_4) + \frac{1}{16} \times (D_5 + D_6) \\ T_1 &= (D_1 - D_2) + 2 \times (D_3 - D_4) + \frac{1}{2} \times (D_5 - D_6) \\ T_3 &= (D_1 - D_2) + 8 \times (D_3 - D_4) + \frac{1}{8} (D_5 - D_6) \\ T_5 &= (D_1 - D_2) + 32 \times (D_3 - D_4) + \frac{1}{32} (D_5 - D_6) + D_7 \end{align*} \end{split}\]

程序的实现为 transform_AtD_6x3。其运算是通过输入 8 行指令集向量 D、输出 6 行指令集向量 AtD 实现的。

inline void transform_AtD_6x3(const __m512 D[8], __m512 AtD[6]) {
    __m512 s0, s1, s2;
    s0 = D[1] + D[2];
    s1 = D[3] + D[4];
    s2 = D[5] + D[6];
    AtD[0] = s0 + s1 + s2 + D[0];
    AtD[2] = s0 + 4.f * s1 + 0.25f * s2;
    AtD[4] = s0 + 16.f * s1 + 0.0625f * s2;
    s0 = D[1] - D[2];
    s1 = D[3] - D[4];
    s2 = D[5] - D[6];
    AtD[1] = s0 + 2.f * s1 + 0.5f * s2;
    AtD[3] = s0 + 8.f * s1 + 0.125f * s2;
    AtD[5] = s0 + 32.f * s1 + 0.03125f * s2 + D[7];
}

下述程序 perform_store_transform 将两个横向并列的矩阵 \(M^\dagger\) 通过向量 zmm_m 代入,随后将图像输出到两个图像的指针 r1, r2 上。需要注意,输出的图像是 6x6 矩阵,因此不能简单地直接使用 _mm256_storeu_ps 将 8 个浮点数写入内存 (会将两个无效数据误写入可能存放有意义数据的位置上),而是要使用带遮罩的向量存储方式。

inline void perform_store_transform(__m512 zmm_m[8], float *r1, float *r2, int OW) {
    __m512 zmm_a[8], zmm_b[8];
    unsigned char mask = 63;
    mm_transpose_8x16_row2col(zmm_m, zmm_b);
    transform_AtD_6x3(zmm_b, zmm_a);
    mm_transpose_8x8(zmm_a, zmm_b);
    transform_AtD_6x3(zmm_b, zmm_a);
    for (int i = 0; i < 6; ++i) {
        _mm256_mask_storeu_ps(&r1[i * OW], mask, _mm512_extractf32x8_ps(zmm_a[i], 0) + _mm256_loadu_ps(&r1[i * OW]));
        _mm256_mask_storeu_ps(&r2[i * OW], mask, _mm512_extractf32x8_ps(zmm_a[i], 1) + _mm256_loadu_ps(&r2[i * OW]));
    }
}

数乘#

我们先回顾到数乘的公式表达是

\[ \mathrm{M}_{i,k}^{(\tilde x, \tilde y, \tilde c)} {}^\dagger = \sum_c^{\tilde C} \mathrm{U}^{(\tilde k, \tilde c)}_{k,c} {}^\dagger \odot \mathrm{V}_{i,c}^{(\tilde x, \tilde y, \tilde c)} {}^\dagger \]

回顾到之前的伪代码,其中的 \(i, \tilde x, \tilde y, \tilde k, \tilde c\) 是已经确定的数值;那么上述角标复杂的表达式可以简化为

\[ \mathrm{M}_{k} {}^\dagger = \sum_c^{\tilde C} \mathrm{U}_{k,c} {}^\dagger \odot \mathrm{V}_{c} {}^\dagger \]

该过程还需要角标 \(k\) 的参与。下述的程序 perform_mult 中,

  • u1, u2 分别是两个 \(k\) 取值下的 \(\mathrm{U}_{k,c}^\dagger\) 或者写为张量元素的形式 \(U_{k,c,s,r}\) (维度 \((c,s,r) \rightarrow (\tilde C, \mu, \mu)\));

  • v 就是 \(\mathrm{V}_{c}^\dagger\) 张量,维度与 u1u2 相同;

  • sizeIC 是输入通道数的分割大小 \(\tilde C\)

  • zmm_m 是两个 \(k\) 取值下的 \(\mathrm{M}_{k} {}^\dagger\),维度是 \((2, \mu, \mu)\)\((2, 8, 8)\);这两个 8x8 矩阵分别储存在两个 4x16 的指令集向量中。

执行 perform_mult 程序时,还需要对 \(k\) 进行循环。

inline void perform_mult(const __m512 u1[], const __m512 u2[], const __m512 v[], __m512 zmm_m[8], int sizeIC) {
    for (int i = 0; i < 8; ++i)
        zmm_m[i] = _mm512_setzero_ps();
    for (int ci = 0; ci < sizeIC; ++ci) {
        for (int i = 0; i < 4; ++i) {
            zmm_m[i] += u1[0] * v[0];
            zmm_m[i + 4] += u2[0] * v[0];
            ++u1; ++u2; ++v;
        }
    }
}

Winograd \(F(6,3)\) 总程序#

有了所有单步程序后,我们可以回归到伪代码的运算过程了。由于整体的 Winograd 伪代码难以再分割出子过程了,因此下面需要用一段完整的代码实现最后统合的一步了。

对程序作一些补充说明:

  • 变量 hintOC, hintIC 分别是输出通道数的分割大小 \(\tilde K\) 与输入通道数分割大小 \(\tilde C\) 的初始设定值。这可以作为程序的常值超参数进行调整,用于确定哪一种分割会使程序运行地更快。但之所以说是“初始设定值”,是因为有可能会遇到输出通道数为 96、输入通道数为 3 等特殊情况。具体分割时需要考虑到边界情况。

  • 所有子程序都需要通过强制 inline 嵌入到 winconv 主程序中;否则效率可能会受到严重影响。

  • 程序最一开始需要对输出图像置零;这一步在 \(C_\mathrm{in}\) 较小时是相对耗时的。可以通过并行置零的方式提升效率,实测会比 memset 等做法要快很多。

  • 变量 startOC, startIC 分别是 \(\tilde k, \tilde c\);变量 x_, y_ 分别是 \(\tilde x, \tilde y\)

  • 下述程序的 // 注释中的 line 是指上面伪代码的行号。

  • 下述程序尚没有对 \(C_\mathrm{in}\) 为奇数、\(H_\mathrm{in}, W_\mathrm{in}\) 模 6 不余 2 的边界情况作实现。关于这些边界情况,请参考实际程序 winograd_f6x3.cpp 的做法。

constexpr int hintOC = 64;
constexpr int hintIC = 32;
void winconv(const float *__restrict__ image, const int IH,
             const int IW, const int IC, const float *__restrict__ filter,
             const int OC, const int N, float *__restrict__ result) {
    const int OH = IH - 2;
    const int OW = IW - 2;
    const int TH = ceildiv(OH, 6);
    const int TW = ceildiv(OW, 6);

    const int sliceOC = min(OC, hintOC);
    const int sliceIC = min(IC, hintIC);

    const int size_result = N * OC * OH * OW;
    
    // line 1: zero initialize output image Y
#pragma omp parallel
#pragma omp for simd aligned(result) schedule(static)
    for (int i = 0; i < size_result; ++i) result[i] = 0;
    
    // line 2: for tilde(k), tilde(c)
    for (int startOC = 0; startOC < OC; startOC += sliceOC) {
        Range rOC(startOC, min(startOC + sliceOC, OC));
        for (int startIC = 0; startIC < IC; startIC += sliceIC) {
            Range rIC(startIC, min(startIC + sliceIC, IC));

// line 3: declare parallel for every CPU cores
#pragma omp parallel default(shared)
            {
                // line 4, 8: allocate U in L2-cache, V in L1-cache
                __m512 V[rIC.size() * 4];
                __m512 U[rOC.size() * rIC.size() * 4];
                
                // line 5: compute U, transformation of convolutional kernel
                for (int k = rOC.start; k < rOC.end; ++k) {
                    for (int c = rIC.start; c < rIC.end; c += 2) {
                        int ki = k - rOC.start, ci = c - rIC.start;
                        const float *f = &filter[(k * IC + c) * 9];
                        __m512 *u = &U[(ki * rIC.size() + ci) * 4];
                        perform_filter_transform(f, u);
                    }
                }

// line 6: embrassingly parallel following for loop
//         adding `collapse` directive if N is not comparable to available number of CPU cores
#pragma omp for schedule(static)
                // line 7: for i, tilde(x), tilde(y)
                for (int i = 0; i < N; ++i) {
                    for (int x_ = 0; x_ < TH; ++x_) {
                        for (int y_ = 0; y_ < TW; ++y_) {
                            int x = x_ * 6, y = y_ * 6;
                            
                            // line 9: compute V, transformation of input image
                            for (int c = rIC.start; c < rIC.end; c += 2) {
                                int ci = c - rIC.start;
                                const float *im1 = &image[((i * IC + c) * IH + x) * IW + y];
                                const float *im2 = &im1[IW * IH];
                                __m512 *zmm_v = &V[ci * 4];
                                perform_image_transform(im1, im2, zmm_v, IW);
                            }
                            
                            // line 10: compute M, perform multiplication
                            // line 11: compute Y, transformation of output image
                            for (int k = rOC.start; k < rOC.end; k += 2) {
                                int ki = k - rOC.start;
                                __m512 *zmm_u1 = &U[ki * rIC.size() * 4], *zmm_u2 = &zmm_u1[rIC.size() * 4];
                                __m512 *zmm_v = V;
                                __m512 zmm_m[8];
                                perform_mult(zmm_u1, zmm_u2, zmm_v, zmm_m, rIC.size());
                                float *r1 = &result[((i * OC + k) * OH + x) * OW + y];
                                float *r2 = &r1[OH * OW];
                                perform_store_transform(zmm_m, r1, r2, OW);
                            }
                        }
                    }
                }
            }
        }
    }
}

我们可以用下述程序运行是否正确,并考察效率。尽管当前的 Jupyter xeus-cling 引擎未必能达到很高效率,但我们可以看见程序运行时间从 Naive Direct 的大约 70 sec 锐减到大约 100 ms,效率提升可以达到约 700 倍。要注意到若只考虑 Winograd 算法相对 Direct 算法的算术运算数的减少量,对于 Winograd \(F(6,3)\) 也不可能超过 5 倍。这确实能体现基于 L2 缓存的程序效率优化的重要意义了。

auto start = chrono::steady_clock::now();

winconv(image, IH, IW, IC, filtr, OC, N, result);

auto end = chrono::steady_clock::now();
chrono::duration<double> elapsed_seconds = end - start;
cout << "elapsed time: " << elapsed_seconds.count() * 1000 << " msec";
elapsed time: 108.482 msec
allclose(result, result_ref, size_result)
true

实机测试#

在这一节中,我们会对我们的程序进行效率测评。

测评用设备与参数

  • CPU 为 Intel Xeon Gold 6154 (x4);

    • 物理内核 (core) 数 72,NUMA 节点数 4;

    • L1d 32 kB / core, L1i 32 kB / core, L2 1024 kB / core;

    • L3 24.75 MB;

  • GCC 10.2.0;

  • 编译选项 -fopenmp -O3 -march=native

关于具体的编译过程,参考 CMakeLists.txt。编译所用的程序为 run_winograd_f6x3

运行的网络为完整的 16 层 VGG16 网络;网络参数的定义文件在 vgg16.conf。其中 Batch 大小 \(N = 64\)。以 10 次连续计算的平均时间记为运行时间。可能出于测评的体量较小、内存对齐、预热效应等可能的影响,效率测评结果会有一定波动。

算法的效率以平均 GFLOPS 给出。GFLOPS 可以看作是每秒执行的算术次数;计算方式是通过 Direct Convolution 确定,而非是当前的 Winograd 算法。

参数 \(\tilde K, \tilde C\) 的选择#

我们先前说道,输出通道分批数 \(\tilde K\)、输入通道分批数 \(\tilde C\) 设置的大小应当要使矩阵 \(\mathrm{U}^{(\tilde k, \tilde c)}_{k, c}\) (维度 \((\tilde K, \tilde C, \mu, \mu)\)) 契合 L2 缓存大小。下述表格就是调整 \(\tilde K\) (hintOC) 与 \(\tilde C\) (hintIC) 时所给出的 GFLOPS;每列最高效率的数值将加粗。计算在 64 核并行下完成。下述表格的数据是五次平行运行后取的最大值。

\(\tilde C \text{\\} \tilde K\)

8

16

24

32

48

64

96

128

8

1596

2465

2891

3251

3394

3603

3712

3477

16

2305

3413

3844

4210

4521

4674

4686

4766

24

2594

3762

4171

4678

4727

5236

5171

4327

32

2574

3756

4270

4734

5094

5389

4774

2824

48

2600

3595

4306

4557

4747

4365

2615

1894

64

2674

3703

4280

4266

3980

2676

1942

-

96

2682

3731

3978

3887

2574

1961

-

-

128

2732

3711

3419

2514

1857

-

-

-

从上表中,我们发现 \(\tilde K = 64\), \(\tilde C = 32\) 确实是最佳情况,算法效率达到 5389 GFLOPS。

但这就遇到另一个问题:为何不能是 \(\tilde K = 32\), \(\tilde C = 64\) 呢?在这种情况下,每批次 \(\mathrm{U}^{(\tilde k, \tilde c)}_{k, c}\) 大小仍然是 512 kB 并小于 L2 缓存大小。这其中有几种无法断言的可能性:

  • 一般来说,高速缓存最好能最大化地利用。但或许每批次 \(\mathrm{U}^{(\tilde k, \tilde c)}_{k, c}\) 不能占用太大的缓存大小。因为图片输入、输出、转换等过程都需要 L2 缓存介入;如果其它繁杂的数据流被限制住了,那么就算与 \(\mathrm{U}^{(\tilde k, \tilde c)}_{k, c}\) 计算密集型任务算得再快,还是得经常等其它数据通过小水管载入到高速缓存中。所以尽管 L2 缓存是 1024 kB / core,但占用 512 kB / core 是比较合适的。这可能解释了为何 \(\tilde K = 96\), \(\tilde C = 32\) 效率相对较低的原因。

  • 我们先前也提及,在 \(i, \tilde x, \tilde y, \tilde c\) 角标确定的情况下,每批次 \(\mathrm{V}^{(\tilde x, \tilde y, \tilde c)}_{i, c}\) 的内存占用是 \((\tilde C, \mu, \mu)\) 大小;当 \(\tilde C = 32\) 时,可以使得 \(\mathrm{V}^{(\tilde x, \tilde y, \tilde c)}_{i, c}\) 的内存占用为 8 kB,小于 L1d 缓存的 32 kB。基于许多 \(\tilde K\) 的取值下 \(\tilde C\) 都在 32 附近有较高效率,我尝试推测:如果 \(\tilde C\) 再大一些,就可能因为 L1d 缓存不能容纳其它计算的需求,而对效率有显著影响。

与 Intel DNNL 的效率比较#

我们也将程序与 Intel DNNL 进行横向对比。使用 64 core 并行;网络结构相同的层会对 GFLOPS 取平均。Intel DNNL 是指 oneAPI 2022.2 的机器学习程序库。对比情况如下 (网络详情也可以参考 [2] Table 3)。效率以 GFLOPS 为单位呈现。

Layer

Depth

\((C_\mathrm{in} \times H_\mathrm{in} \times W_\mathrm{in})\)

\(C_\mathrm{out}\)

DNNL Direct

DNNL Winograd

My Winograd

conv(1.1)

1

3 x 224 x 224

64

160

352

828

conv(1.2)

1

64 x 224 x 224

64

2423

4572

5332

conv(2.1)

1

64 x 112 x 112

128

3160

4458

5384

conv(2.2)

1

128 x 112 x 112

128

4676

6344

5836

conv(3.1)

1

128 x 56 x 56

256

5312

5183

5686

conv(3.2)

3

256 x 56 x 56

256

6524

4283

6565

conv(4.1)

1

256 x 28 x 28

512

5933

3399

5769

conv(4.2)

4

512 x 28 x 28

512

7264

5466

5329

conv(5)

5

512 x 14 x 14

512

5112

2588

4578

VGG16

4420

4250

5497

可以见到,我们的实现并非在每个网络上都超过了 DNNL。

  • 可能是受限于 Winograd 算法本身,在网络的最后三四层,即图像很小、卷积核较大的情况,使用直接卷积计算有可能效率更高。

  • 我们也存在 conv(2.2) 效率没有超过 DNNL Winograd 的效率。

  • DNNL Winograd 的实现可能基于 \(F(2,3)\)\(F(4,3)\);我们上述的实现是 \(F(6,3)\)

  • DNNL 的库文件很大;将程序载入内存的时间也考虑在了测评过程中。以及 DNNL 使用了 JIT 技术,有可能 JIT 处理时间也有一定影响。

我们也指出,该上述的代码思路并非是决赛阶段所使用的思路。Winograd \(F(6, 3)\) 有办法可能更快。

并行效率#

下图呈现了我们程序在不同核数下的并行效率。可以认为并行效率相对可观。

也必须指出,上面的程序是对 Batch 数 \(N\) 作简单并行的;如果 Batch 数并不是很友好的数字 (譬如 80),或者 Batch 数为 64 却被要求在 48 核机器上并行,那么该程序的并行效率应当不会很好。

Parallel Efficiency

Roofline 图#

Intel Advisor (advixe-cl) 可以绘制 Roofline 图。Roofline 图是可以直观分析程序片段的内存负载量与计算指令调用数量。它同时还可以交互式地给出程序片段的运行时间、以及联结程序与汇编代码。可以是相当强大的代码效率分析工具。

下图是对 64 core 并行的 Winograd VGG16 程序运行过程所绘制的 Roofline 图。目前我还未完全了解其中的功能;但通过与下述 Roofline 图的简单交互,应当可以确定,

  • 黄色点是数乘运算点,运行耗时约 129 sec;该程序所调用的内存通讯的带宽接近 L2 带宽,确实符合我们程序设计的预期。

  • 两个绿色点分别是输入图像变换与卷积核变换,分别耗时 36 sec 与 33 sec。

  • 红色点是输出图像变换与内存写入过程,耗时约 154 sec。事实上,我们编写的程序的最耗时步骤,对于整个 VGG16 网络而言其实是输出图像部分。这也是 Winograd 算法的特点:尽管可以降低数乘部分运算的次数,但其余部分的转换与读写量的增加制约了效率。

roofline

后记与致谢#

我曾经一直都只是高性能计算的使用者;计算化学目前毫无疑问地消耗着着大量的计算资源。尽管也会进行程序编写,但实际上至多是调库玩家;先前并不了解高性能计算的逻辑,甚至不了解高速缓存和汇编代码为何物。

感谢赛方九坤投资、以及队长强宜澄。因为这场竞赛,我从无到有、确实地一窥高性能计算的一角,也有不少收获。对于提升与改进程序效率,除了低标度 (Lower-Scaling/Complexity) 算法外,确实地可以多一个视角来看待。也再次感谢赛方提供的自主饮食、奖励和周边 0w0;以及强哥大腿,他的努力促成了我们小队在决赛阶段获得第二名的佳绩,我也能沾光 =w=。希望强哥后面的求学之路能顺利。

正因为计算化学耗费的资源量巨大;由同时在我狭隘的目之所及的化学出身从业者内 (我认为 Southern Methodist University 的 Devin Matthews 应是这类从业者的范例),较少有对底层计算机架构有深入理解和强编程能力 (就像 Winograd 算法这类写得真高效的话,譬如冠军代码或我们小队队长在决赛时所用的代码,会又 hard 又 lengthy 又 dirty >.< 能力确实需要很强);因此,高性能计算在计算化学中的应用前景和提升空间或许是可预见的。化学问题经常会遇到大量且复杂的张量缩并;受制于自己的眼界,要如何具体地借助高性能计算提升计算化学算法,与我而言还需要一些思考。

写这篇文档的时候已经博六了,毕业课题还没搞定。确实可以慢慢想哈哈 >.<

赛题中还包含了关于 HDF5 的读存效率问题。在我先前的项目中,确实遇到需要与磁盘进行大量交互的问题 (将电子积分或激发张量存入硬盘的二阶梯度算法实现)。对 HDF5 赛题的学习,也确实料及了关于如何合理测评基于硬盘的算法的效率的方式,以及一定条件下合理地利用 chunk 提升硬盘读写的效率的思路。

事实上参赛时段的前后,因为一些外因,我多少有些负面情绪;这次比赛确实给予我以一些信心;也要感谢各同学老师的宽容。以及感谢课题组闲置计算资源的支持。

还有许多问题值得进取,还有许多技术可以玩味。以后如果有时间,还蛮想在类 GPU 上多些了解。

对基于 CPU 的高性能计算初步学习,LAFF 系列课程 的第三个课程 LAFF-On Programming for High Performance 相信是不错的初步材料。这个学习材料是在决赛后发现的。它介绍了比较基础的指令集级别的矩阵乘法 DGEMM 性能优化思路与实现。


简单理解 Grover 算法求解数独问题#

创建时间:2023-02-19

危险

本文档作者有学习、但并非量子计算相关专业工作者。本文档可能在可观描述上存在不合规范或过时的情况、且主观描述有错误的可能性。

备注

文档有较多折叠代码。这些折叠代码可能用于展示图片、定义数独问题、或是比较琐碎的用于经典计算机上的处理程序。

数独是老少皆宜的益智游戏。在 【(日本) 东北大学加龄医学研究所 川岛隆太博士监修 大人的脑部锻炼】 中指出,数独

  • 可能 有助于前额叶的活性化;

  • 可能 有助于提高记忆力。

你们有见过这么长名字的游戏么 hhh =ω= (尽管上边不是简中正式游戏名称)

原来有了博士的头衔就可以光明正大地监修游戏了呀。做游戏也是康庄大道。冲!

其规则很简单:在下述 \(9 \times 9\) 的格子中填入数字,使得

  • 任意行格子数字不重复、

  • 任意列格子数字不重复、

  • 任意宫格子 (粗线画出的 \(3 \times 3\) 的大宫格内) 数字不重复。

原则上,量子的 Grover 算法可以平方倍地加速 数独问题的求解[1]。在这篇文档中,我们将会了解数独问题在 Grover 算法下的实现过程。

Hide code cell source
import matplotlib as mpl
from matplotlib import pyplot as plt
from matplotlib_inline.backend_inline import set_matplotlib_formats

import sudokutools
from sudokutools.sudoku import Sudoku
import sudokutools.sudoku
import sudokutools.solve
import sudokutools.generate

import qiskit
from qiskit import (
    QuantumCircuit, QuantumRegister, ClassicalRegister,
    Aer, transpile, assemble)
from qiskit.quantum_info import Statevector

import sys
import numpy as np
import itertools

set_matplotlib_formats("svg")
Hide code cell source
mpl.rcParams['font.sans-serif'] = "FreeSans"
mpl.rcParams["mathtext.fontset"] = "stix"
mpl.rcParams['axes.spines.left'] = False
mpl.rcParams['axes.spines.right'] = False
mpl.rcParams['axes.spines.top'] = False
mpl.rcParams['axes.spines.bottom'] = False
Hide code cell source
def plot_puzzle_3x3(puzzle, ax=None, original=None):
    if ax is None:
        fig, ax = plt.subplots(figsize=(5, 5))
    ax.set_xlim(-0.05, 9.05)
    ax.set_ylim(-9.05, 0.05)
    ax.set_aspect(1)
    ax.set_xticks([])
    ax.set_yticks([])

    sudokutools.solve.init_candidates(puzzle)

    d3 = 1/3
    cand_shift = {
        1: (-d3, d3), 2: (0, d3), 3: (d3, d3),
        4: (-d3, 0), 5: (0, 0), 6: (d3, 0),
        7: (-d3, -d3), 8: (0, -d3), 9: (d3, -d3),
    }

    for i in range(0, 10):
        linewidth = 1.75 if i % 3 == 0 else 0.75
        ax.plot([i, i], [0, -9], color="black", linewidth=linewidth)
        ax.plot([0, 9], [-i, -i], color="black", linewidth=linewidth)
    for i in range(9):
        for j in range(9):
            val = puzzle.get_number(j, i)
            color = "black"
            if original is not None:
                if val != original.get_number(j, i):
                    color = "C0"
            if val != 0:
                ax.text(
                    i + 0.5, -j - 0.5, val,
                    ha="center", va="center", size=24, color=color)
            else:
                cand = puzzle.get_candidates(j, i)
                for k in cand:
                    ax.text(
                        i + 0.5 + cand_shift[k][0], -j - 0.5 + cand_shift[k][1], k,
                        ha="center", va="center", size=10, color="gray")
Hide code cell source
def plot_puzzle_2x2(puzzle, ax=None, original=None):
    if ax is None:
        fig, ax = plt.subplots(figsize=(2.5, 2.5))
    ax.set_xlim(-0.05, 4.05)
    ax.set_ylim(-4.05, 0.05)
    ax.set_aspect(1)
    ax.set_xticks([])
    ax.set_yticks([])

    sudokutools.solve.init_candidates(puzzle)

    cand_shift = {
        1: (-0.25, 0.25),
        2: (0.25, 0.25),
        3: (-0.25, -0.25),
        4: (0.25, -0.25)
    }

    for i in range(0, 5):
        linewidth = 1.75 if i % 2 == 0 else 0.75
        ax.plot([i, i], [0, -4], color="black", linewidth=linewidth)
        ax.plot([0, 4], [-i, -i], color="black", linewidth=linewidth)
    for i in range(4):
        for j in range(4):
            val = puzzle.get_number(j, i)
            color = "black"
            if original is not None:
                if val != original.get_number(j, i):
                    color = "C0"
            if val != 0:
                ax.text(
                    i + 0.5, -j - 0.5, val,
                    ha="center", va="center", size=24, color=color)
            else:
                cand = puzzle.get_candidates(j, i)
                for k in cand:
                    ax.text(
                        i + 0.5 + cand_shift[k][0], -j - 0.5 + cand_shift[k][1], k,
                        ha="center", va="center", size=13, color="gray")

下面是一个 (其实不太) 常见的数独的问题与答案[2] (你可以试着在各种平台[3]或软件[4]试一下解决这个数独 hhh 我反正弃疗了):

Hide code cell source
puzzle_3x3_example = sudokutools.sudoku.Sudoku.decode("""
8........
..36.....
.7..9.2..
.5...7...
....457..
...1...3.
..1....68
..85...1.
.9....4..""".replace(".", "0"))
solution_3x3_example = next(sudokutools.solve.dlx(puzzle_3x3_example))
fig, axs = plt.subplots(ncols=2, figsize=(11, 5))
plot_puzzle_3x3(puzzle_3x3_example, axs[0])
plot_puzzle_3x3(solution_3x3_example, axs[1], original=puzzle_3x3_example)
axs[0].set_xlabel("Puzzle", fontsize=18)
axs[1].set_xlabel("Solution", fontsize=18)
fig.tight_layout()
_images/27bbd9981a5463c6b08b6dfba0a9a7ff5a235db4955b2646157983bde079918c.svg

我们将借用作为 Python 的数独库 sudokutools 的一些很简单的功能。文档主体的量子线路与绘制由 Qiskit 完成。

符号定义#

对于阶为 \((n, m)\) 的方形数独,其数独阵的横向、纵向格子数为 \(n \times m\),总格子数为 \(n^2 \times m^2\)。若 \(m = n\),那么我们简称 \((n, m)\) 阶数独为 \(n\) 阶数独。需要留意,在我们的定义中,随着阶数 \(n\) 的增加,数独方阵是 \(n^4\) 大小的。

对于一个数独谜题,有待解决的空缺格子数定义为 \(n_\mathrm{empty}\)

我们平时见到最多的数独是 \(n = 3\) 阶数独,其布局是 \(9 \times 9\) 的方阵。而在这篇文档中,我们主要将讨论较为简单的情形,\(n = 2\) 阶数独,即布局是 \(4 \times 4\) 的方阵。我们可以允许数独是多解的 (一般的解数独软件和解法分析中,是不允许多解情况存在的)。

大部分时候,我们将会讨论下述两个数独问题。

Hide code cell source
puzzle1 = sudokutools.sudoku.Sudoku.decode("1234340023404123")
puzzle2 = sudokutools.sudoku.Sudoku.decode("0034341200434321")
sudokutools.solve.init_candidates(puzzle1)
sudokutools.solve.init_candidates(puzzle2)

fig, axs = plt.subplots(ncols=2, figsize=(5.5, 2.7))
plot_puzzle_2x2(puzzle1, axs[0]); plot_puzzle_2x2(puzzle2, axs[1])
axs[0].set_xlabel("Puzzle (1)", fontsize=18); axs[1].set_xlabel("Puzzle (2)", fontsize=18);
_images/8e368c3ec052fed4fcfa839e8afe55cf406cae6cd71d8827ac68766e564cc86f.svg

经典计算机上的预处理:遍历约束条件#

不管是经典算法还是量子算法,为解决数独问题,首先要做的事情是如何验证结果的正确性。在数学上,数独可以看作是约束满足问题 (Constraint Satisfaction Problem)。这类问题可以通过给定所有的约束条件后,进行 (暴力) 搜索得到解。

以 Puzzle (1) 问题为例,首先我们规定记号 \(V_x\) 为尚未被填入的格子的编号:

Hide code cell source
fig, ax = plt.subplots(figsize=(3.5, 2.3))
plot_puzzle_2x2(puzzle1, ax)
ax.set_xlim(-0.05, 5)
ax.set_ylim(-4.05, 0.05)
for (i, j) in [(1, 2), (1, 3), (2, 3)]:
    ax.fill_between([j, j+1], [-i, -i], [-i-1, -i-1], color="C0", alpha=0.1)
ax.arrow(4.5, -1.5, -2, 0, width=0.05, color="C1", length_includes_head=True)
ax.text(4.5, -1.5, "$V_0$", ha="left", va="center", size=20, color="C1")
ax.arrow(4.5, -2, -1, 0.2, width=0.05, color="C1", length_includes_head=True)
ax.text(4.5, -2, "$V_1$", ha="left", va="center", size=20, color="C1")
ax.arrow(4.5, -2.5, -1, 0, width=0.05, color="C1", length_includes_head=True)
ax.text(4.5, -2.5, "$V_2$", ha="left", va="center", size=20, color="C1")
fig.tight_layout()
_images/6f8d0ea081ab0af50ee7ab40437e0d4f6acb2c9210dbda3e6b13a5bc1cc75ea8.svg

我们回顾过,数独的所有限制条件就是行、列、宫的格子分别互不相等。对于上述 Puzzle (1) 的 \(V_i \ (i = 0, 1, 2)\),所需要满足的条件有两类:

  • 与已知格子的关系。

    譬如对于 \(V_0\),其分析如下图所示。由于行不能取值 3、4,列不能取值 3、4、2,宫不能取值 3、4。因此总地来看,\(V_0\) 不可取数值为 2、3、4,可取数值为 1。

    事实上,每个格子的可取数值,已经在图中以灰色数字标识了。在量子计算的 Grover 算法设计上,我们更倾向于说一个格子的不可取值数是多少。对于 Puzzle (1),列举如下:

    • \(V_0 \not \in \{2, 3, 4\}\)

    • \(V_1 \not \in \{3, 4\}\)

    • \(V_2 \not \in \{2, 3, 4\}\)

Hide code cell source
fig, ax = plt.subplots(figsize=(3.5, 2.6))
for (i, j) in [(1, 0), (1, 1), (1, 2), (0, 2), (0, 3), (2, 2), (3, 2)]:
    ax.fill_between([j, j+1], [-i, -i], [-i-1, -i-1], color="C3", alpha=0.1)
for (i, j) in [(1, 3)]:
    ax.fill_between([j, j+1], [-i, -i], [-i-1, -i-1], color="C0", alpha=0.1)
plot_puzzle_2x2(puzzle1, ax)
ax.set_xlim(-1, 5)
ax.arrow(4.5, -1.5, -2, 0.0, width=0.05, color="C1", length_includes_head=True)
ax.text(4.5, -1.5, "$V_0$", ha="left", va="center", size=20, color="C1")
ax.set_xlabel("$V_0$ can't be determined values in red blocks,\n"
              "i.e., can't be 2, 3 or 4", fontsize=14)
fig.tight_layout()
_images/34fe76b53b20064034d8bdebd178b2d4805004801b675e5c276cfd081a9d28a9.svg
  • 与未知格子的关系。

    譬如对于 \(V_0\),它与 \(V_1\) 处在同一行或宫内;因此 \(V_0\)\(V_1\) 的数值不能相等。但 \(V_0\)\(V_2\) 不处于相同的行、列或宫,是可以相等的。

    对于 Puzzle (1),列举如下:

    • \(V_0 \neq V_1\)\(V_1 \neq V_2\)

我们现在需要做的就是要用程序的语言,将这些情况写出来。

  • get_empty_grids 给出当前的空格行列位置,并对每个空格 \(V_x\) 标记 \(x\) 的数值作为 dict 的 key。需要注意,由于 Python 是 0-index 的语言,因此我们通常说的“第一行”在这里是第 0 行;

  • get_cond_init_number 给出每个 \(V_x\) 所不能取的数值;

  • get_cond_edges 给出未知格子 \(V_x\)\(V_y \ (y > x)\) 之间,不能取相同值的情况。

这部分代码的定义在本文档中被折叠。对于 Puzzle (1),其作用效果如下述代码块所示。

Hide code cell source
def get_empty_grids(puzzle):
    empty_grids = dict()
    n = 0
    for row, col in puzzle:
        if puzzle.get_number(row, col) == 0:
            empty_grids[n] = (row, col)
            n += 1
    return empty_grids

def get_cond_init_number(puzzle, max_number=-1):
    empty_grids = get_empty_grids(puzzle)
    cond_init_number = dict()
    for n, (row, col) in empty_grids.items():
        cond_init_number[n] = list(set(puzzle.numbers).difference(puzzle.get_candidates(row, col)))
    if max_number != -1:
        for n in cond_init_number:
            cond_init_number[n] += list(range(max(puzzle.numbers) + 1, max_number + 1))
    return cond_init_number

def get_cond_edges(puzzle):
    empty_grids = get_empty_grids(puzzle)
    empty_grids_list = sorted(empty_grids.items(), key=lambda v: v[0])
    cond_edges = dict()
    for n1, (row1, col1) in empty_grids_list:
        for n2, (row2, col2) in empty_grids_list[n1+1:]:
            if row1 == row2 or col1 == col2 or (row2, col2) in puzzle.box_of(row1, col1):
                if n1 not in cond_edges:
                    cond_edges[n1] = [n2]
                else:
                    cond_edges[n1].append(n2)
    return cond_edges
get_empty_grids(puzzle1)
{0: (1, 2), 1: (1, 3), 2: (2, 3)}
get_cond_init_number(puzzle1)
{0: [2, 3, 4], 1: [3, 4], 2: [2, 3, 4]}
get_cond_edges(puzzle1)
{0: [1], 1: [2]}

Grover 算法简要回顾#

Grover 算法已经有许多文档作了细致、甚至包括含代码的说明了[5][6][7][8]。这里最为推荐的材料是 Qiskit 的介绍[5]。我就只对关键的问题作一些说明、以及明确这篇文档的符号定义。

算法回顾 (1):适用的情况与 Oracle 算符#

Grover 算法的常见用途是 搜索问题的解。假设搜索问题可以定义为一个函数 \(f(x)\),其中 \(x\) 是待搜索的对象,且

\[\begin{split} f(x) = \left\{ \begin{matrix} 0 & (x \text{ is not solution}) \\ 1 & (x \text{ is solution}) \end{matrix} \right. \end{split}\]

\(f(x)\) 可以由较低的代价在量子计算机上实现,那么 Grover 算法可以用于这类问题。

以当前的数独问题 Puzzle (1) 为例,我们令 \(x = (V_0, V_1, V_2)\)。如果数独方阵上的空格满足约束条件,那么 \(f(x) = 1\);否则 \(f(x)\) 为零。

图像上的理解可以表现如下。当 \(x = (V_0, V_1, V_2)\) 的取值是问题的解,即 \(x = (1, 2, 1)\) 时,那么 \(f(x) = 1\)

Hide code cell source
fig, ax = plt.subplots(figsize=(5, 2.3))
plot_puzzle_2x2(Sudoku.decode("1234341223414123"), ax, original=puzzle1)
ax.set_xlim(-0.05, 5)
ax.set_ylim(-4.05, 0.05)
for (i, j) in [(1, 2), (1, 3), (2, 3)]:
    ax.fill_between([j, j+1], [-i, -i], [-i-1, -i-1], color="C0", alpha=0.1)
ax.arrow(4.5, -1.5, -2, 0, width=0.05, color="C1", length_includes_head=True)
ax.text(4.5, -1.5, "$V_0$", ha="left", va="center", size=20, color="C1")
ax.arrow(4.5, -2, -1, 0.2, width=0.05, color="C1", length_includes_head=True)
ax.text(4.5, -2, "$V_1$", ha="left", va="center", size=20, color="C1")
ax.arrow(4.5, -2.5, -1, 0, width=0.05, color="C1", length_includes_head=True)
ax.text(4.5, -2.5, "$V_2$", ha="left", va="center", size=20, color="C1")
ax.text(-0.8, -2, "$f(V_0, V_1, V_2)$\n$f(1, 2, 1)$",
        ha="right", va="center", size=24, color="black")
ax.text(-0.15, -2, "$=$",
        ha="right", va="center", size=24, color="black")
ax.text(5, -2, "$= 1$",
        ha="left", va="center", size=24, color="black")
fig.tight_layout()
_images/3a226054358285dfc1a74c93636945c7ea92b82572a25f1c543e5bffcc4717a3.svg

但若我们没有给出正确的解时,那么函数 \(f(x)\) 的值就是零了:

Hide code cell source
fig, ax = plt.subplots(figsize=(5, 2.3))
plot_puzzle_2x2(Sudoku.decode("1234341323444123"), ax, puzzle1)
ax.set_xlim(-0.05, 5)
ax.set_ylim(-4.05, 0.05)
for (i, j) in [(1, 2), (1, 3), (2, 3)]:
    ax.fill_between([j, j+1], [-i, -i], [-i-1, -i-1], color="C0", alpha=0.1)
ax.arrow(4.5, -1.5, -2, 0, width=0.05, color="C1", length_includes_head=True)
ax.text(4.5, -1.5, "$V_0$", ha="left", va="center", size=20, color="C1")
ax.arrow(4.5, -2, -1, 0.2, width=0.05, color="C1", length_includes_head=True)
ax.text(4.5, -2, "$V_1$", ha="left", va="center", size=20, color="C1")
ax.arrow(4.5, -2.5, -1, 0, width=0.05, color="C1", length_includes_head=True)
ax.text(4.5, -2.5, "$V_2$", ha="left", va="center", size=20, color="C1")
for i, j, val in [(1, 3, 3), (2, 3, 4)]:
    ax.text(j + 0.5, -i - 0.5, val,
            ha="center", va="center", size=24, color="C3", fontweight="bold")
ax.text(-0.8, -2, "$f(V_0, V_1, V_2)$\n$f(1, 3, 4)$",
        ha="right", va="center", size=24, color="black")
ax.text(-0.15, -2, "$=$",
        ha="right", va="center", size=24, color="black")
ax.text(5, -2, "$= 0$",
        ha="left", va="center", size=24, color="black")
fig.tight_layout()
_images/8373b5d3ace37107dc43dfcf0d1dc176723d84b812d2a8b0f5efc3e3915189ed.svg

因此,数独问题是可以通过一个抽象的函数 \(f(x)\) 来定义的;剩下的问题就是这样的问题要如何套用到量子计算中。为此,我们必须要至少解决两个问题:

  • 如何编码 \(x\) 到量子比特 (Qubit,简称 QB 丘比) 中,成为态 \(| x \rangle\)

  • 如何编码函数 \(f(x)\) 成为量子门路;我们希望能编码出算符 \(O\) (该算符也称为 Oracle),使得 \(O | x \rangle = (-1)^{f(x)} | x \rangle\)

    • 该问题即如何构造可以在量子门路中实现的算符 \(O\),使得当 \(| x \rangle\) 是问题的解时,\(O | x \rangle = - | x \rangle\);否则若不是问题的解,则 \(O | x \rangle = | x \rangle\)

一旦这两个问题可以解决,那么 Grover 算法就可以套用到这一类问题中了。我们将在后文讨论,如何用程序实现上述两个问题,而将数独问题系统地转化为量子计算机可解问题。

算法回顾 (2):均衡叠加态、解态与非解态#

这里需要一些基础数学上的回顾。我们大致参照 QCQI 教材的描述[8]

对于均衡叠加态 \(| \psi \rangle\),可以将其拆分为非正确解态 \(| \alpha \rangle\) 与正确解态 \(| \beta \rangle\) 的线性组合:

\[ | \psi \rangle = \cos \frac{\theta}{2} | \alpha \rangle + \sin \frac{\theta}{2} | \beta \rangle \label{eq.1} \tag{1} \]

如果令所有的可能取值情况数为 \(N\) (Puzzle (1) 总共是 \(4^3 = 64\) 种、Puzzle (2) 总共是 \(4^5 = 1024\) 种);问题的解数量为 \(M\) (Puzzle (1) 解数量为 1、Puzzle (2) 解数量为 2),那么依据归一化条件,容易推知

\[ | \psi \rangle = \sqrt{\frac{N - M}{N}} | \alpha \rangle + \sqrt{\frac{M}{N}} | \beta \rangle \label{eq.2} \tag{2} \]

联立 eq (1),得到波函数的参数 \(\theta = 2 \arcsin \sqrt{M / N}\)

算法回顾 (3):Grover 算符与其表示矩阵#

Grover 算法可以看作是一种对正确解态 \(| \beta \rangle\) 的增幅器。

定义相移增幅算符 \(U_s\) 与 Grover 算符 \(G\)

\[\begin{split} \begin{gather*} U_s = 2 | \psi \rangle \langle \psi | - I \\ G = U_s O \end{gather*} \end{split}\]

\(| \alpha \rangle, | \beta \rangle\) 为基,我们可以表明,Grover 算符 \(G\) 的矩阵表示是

\[\begin{split} G = \begin{pmatrix} \cos \theta & - \sin \theta \\ \sin \theta & \cos \theta \end{pmatrix} \end{split}\]

这事实上就是对 \(| \alpha \rangle, | \beta \rangle\) 构成的向量的一种旋转作用。

多次使用这种旋转作用,就可以对正确解的态 \(| \beta \rangle\) 进行增幅放大。具体来说,如果给定下述量子态 (\(\gamma\) 是任意角度)

\[ | \varphi \rangle = \cos \gamma | \alpha \rangle + \sin \gamma | \beta \rangle \]

那么将 Grover 算符 \(G\) 作用于该态,并根据和差化积公式,可以得到

\[\begin{split} \begin{align*} G | \varphi \rangle &= \big( \cos \theta \cos \gamma - \sin \theta \sin \gamma \big) | \alpha \rangle + \big( \sin \theta \cos \gamma + \cos \theta \sin \gamma \big) | \beta \rangle \\ &= \cos (\gamma + \theta) | \alpha \rangle + \sin (\gamma + \theta) | \beta \rangle \end{align*} \end{split}\]

以此类推,可以立即得到

\[ G^k | \varphi \rangle = \cos (\gamma + k \theta) | \alpha \rangle + \sin (\gamma + k \theta) | \beta \rangle \]

因此直观地来说,每次作用 Grover 算符 \(G\) 就增加 \(| \beta \rangle\) 出现在量子态中的概率。如果能选取一个合适的 \(k\),使得 \(\gamma + k \theta \simeq \pi / 2\),那么此时 \(G^k | \varphi \rangle \simeq | \beta \rangle\)。这也就是所谓 Grover 算符 \(G\) 是正确解态的增幅放大器的意思了。

特别地,如果我们取 \(| \varphi \rangle = | \psi \rangle\) (之所以这样取量子态,是因为 \(| \psi \rangle\) 很容易由量子门路构造),那么

\[ G^k | \psi \rangle = \cos \left( \frac{2 k + 1}{2} \theta \right) | \alpha \rangle + \sin \left( \frac{2 k + 1}{2} \theta \right) | \beta \rangle \]

回顾到 \(\theta = 2 \arcsin \sqrt{M / N}\) (假设 \(M \ll N\)),其中 \(N\), \(M\) 分别是可能的取值数、与解个数。当 Grover 算符作用次数是下述值时,

\[ k = \frac{\pi}{4 \arcsin \sqrt{M / N}} - \frac{1}{2} \sim \frac{\pi}{4} \sqrt{\frac{N}{M}} + o(\sqrt{M/N}) \]

我们就能近乎以 100% 的概率得到正确的解态 \(| \beta \rangle\) 了。

构造数独问题的量子线路#

对待定的格子的编码#

以 Puzzle (1) 为例。这里考虑的是对于 \(x = (V_0, V_1, V_2)\),如何将 \(x\) 编码到量子比特上。

首先需要确认对每个格子需要的量子比特数量。对于 Puzzle (1),一共允许的数字是 \(1, 2, 3, 4\) 共 4 个,因此编码一个数字所需要的量子比特数 \(n_\mathrm{qubit}\)

\[ n_\mathrm{qubit} = \lceil \log_2 (n^2) \rceil = 2 \]
def get_nqubit(puzzle):
    """
    Get number of qubits to encode a number in a Sudoku puzzle.
    puzzle: Sudoku puzzle instance
    """
    size = puzzle.size
    return int(np.ceil(np.log(size[0] * size[1]) / np.log(2)))
nqubit = get_nqubit(puzzle1)
nqubit
2

编码时,每个数字先减去 1 (因为通常的数独都是从 1 数起的),随后进行二进制编码:

数字

编码

数字

编码

1

0

5

100

2

1

6

101

3

10

7

110

4

11

8

111

def encode_sudoku(num):
    """
    Encode a number with Sudoku numbering convention (starts from 1).
    Encoded binary represents num-1 in usual case.
    """
    return bin(num - 1)[2:]
encode_sudoku(7)
'110'

确定对单个数字的编码后,就可以考虑在量子门路中,写入编码多个数字、或测量多个数字的具体做法了。在 Puzzle (1) 中,对于每个数字需要 2 个编码、一共有 3 个待定格子,因此总共需要 6 个量子比特。这 6 个比特,依照待定格子,分配到待注册的工作比特 (work qubit) 的列表称为 qb_w_list;依照比特分配的列表称为 qb_w_all

qb_w_list = [QuantumRegister(nqubit, name="w" + str(n)) for n in get_empty_grids(puzzle1).keys()]
qb_w_all = list(itertools.chain(*[qb_w_reg for qb_w_reg in qb_w_list]))

用类似的方法,我们定义 6 个传统比特 (注册这些比特的列表为 cb_list,依每个传统比特分配的列表为 cb_all);它们用于将对 6 个量子比特进行测量:

cb_list = [ClassicalRegister(nqubit, name="cbit" + str(n)) for n in get_empty_grids(puzzle1).keys()]
cb_all = list(itertools.chain(*[cb_reg for cb_reg in cb_list]))

编码、量子线路、测量、解码#

编码约定俗成

在这篇文档中,我们对二进制的编码使用 big-endian。在量子线路与输出中,我们遵循从上到下、从右到左的数字位数增大的约定俗成。但这个顺序与 Qiskit 的默认顺序经常是相反的。

在量子计算机上,编码数字的方式是对一部分量子比特施加以 \(X\) gate。这样的量子线路可以用下述函数 apply_encode_xgate 实现:

def apply_encode_xgate(qcirc, qb_reg, encode):
    """
    Apply X gates to quantum circuit by encoded binaries.
    qcirc: Quantum circuit instance
    qb_reg: Qubit register (or list of Qubits)
    encode: Encoded binary string
    """
    for i, val in enumerate(encode[::-1]):
        if val != "1": continue
        qcirc.x(qb_reg[i])
    return qcirc

譬如对于三个数字数独的三个数字 (3, 2, 4),将这三个数字编码 (编码结果是 10 01 11),并实现到量子线路并加以测量,可以写为

qcirc = QuantumCircuit()
qcirc.add_register(*qb_w_list, *cb_list)
for qb_w_reg, num in zip(qb_w_list, [3, 2, 4]):
    apply_encode_xgate(qcirc, qb_w_reg, encode_sudoku(num))
qcirc.measure(qb_w_all, cb_all)
qcirc.draw(scale=0.7)
_images/f0e75e5420a9371d074773d48b3410d277d5e773be2b3ce673125e8eb6a30497.svg

对这样的线路,在量子计算模拟机上有两种做法。一种方法与实体量子计算机一样,就是对每个量子比特进行结果非 1 即 0 的测量。如果测量结果有多种可能,就需要重复进行多次测量,才能得到有统计意义的结果。

def get_result_prob_by_shots(qcirc, shots=100):
    simulator = Aer.get_backend("aer_simulator")
    qcirc_transpiled = transpile(qcirc, simulator)
    qobj = assemble(qcirc_transpiled)
    result = simulator.run(qobj, shots=shots).result()
    res_dict = result.get_counts()
    res_dict = {key[::-1].replace(" ", ""): val / shots for key, val in res_dict.items()}
    return res_dict

另一种“测量”方式是直接取线路执行到最后的量子态 (Statevector)。通过这种方法可以直接得到完整的统计结果,但它本质上并非是物理可实现的测量过程,因此只有理论上的价值。

def get_result_prob_by_statevector(qcirc, qb_list=None, tol=1e-3):
    """
    Get probabilities of measured results for given qubits by analyzing statevector
    qcirc: Quantum circuit
    qb_list: Qubits to be measured, default: all qubits to be measured 
    tol: Threshold of probabilities to represent result
    """
    if qb_list is None:
        qb_list = qcirc.qubits
    n_qb = len(qcirc.qubits)
    qcirc = qcirc.copy()
    qcirc.remove_final_measurements()
    idx_qb_list = [qcirc.qubits.index(qb) for qb in qb_list]
    stat = np.asarray(Statevector(qcirc)).reshape([2] * n_qb)
    stat = stat.transpose(*range(0, n_qb, -1))
    stat = stat.transpose(idx_qb_list + list(set(range(n_qb)).difference(idx_qb_list)))
    stat = (stat * stat.conj()).real.sum(axis=tuple(range(len(idx_qb_list), n_qb)))
    loc = np.where(stat > tol)
    val = stat[loc]
    hist = {"".join([str(i) for i in l]): v for l, v in zip(np.asarray(loc).T, val)}
    return hist
get_result_prob_by_statevector(qcirc)
{'011011': 1.0}

我们也需要一个解码程序,能正确地解读出测得传统比特的结果。譬如对于上述线路,我们就应该测得 (3, 2, 4) 的结果:

def decode_cbits_sudoku(code, nqubits):
    """
    Decode result of measured classical bits with Sudoku numbering convention
    code: Measured result of classical bits
    nqubits: Number of qubits to encode a number in a Sudoku puzzle.
    """
    code = code.replace(" ", "")
    assert len(code) % nqubits == 0
    res = []
    for idx in range(0, len(code), nqubits):
        res.append(int(code[idx:idx+nqubits][::-1], base=2) + 1)
    return res
decode_cbits_sudoku('011011', 2)
[3, 2, 4]

Oracle 门路的实现:原理补充#

回顾到 Oracle 门路的作用是:对于非解 \(| \alpha \rangle\) 与正确解 \(| \beta \rangle\),当 Oracle 门路作用在这两者时,正确解的相位会变为 -1,而非解则不会有任何改变。

但改变相位这件事本身,对于数独问题而言,不见得是容易实现的。但如果实现控制门路容易在数独问题中实现;在控制门路下只要有一个额外的工作比特,相位变化就可以容易地实现。

现在我们向量子态加一个 辅助比特 \(| q \rangle\),并称 \(| \alpha \rangle\)\(| \beta \rangle\)工作比特 的态。现在我们说,如果存在一种特殊的控制门路 \(\text{CTRL-}X\),它在正确解时触发:

\[\begin{split} \begin{align*} | \alpha \rangle | q \rangle &\xrightarrow{\text{CTRL-}X} | \alpha \rangle | q \rangle \\ | \beta \rangle | q \rangle &\xrightarrow{\text{CTRL-}X} | \beta \rangle X | q \rangle \end{align*} \end{split}\]

特别地,如果我们将 \(| q \rangle\) 初始化为 \(|-\rangle = \frac{1}{\sqrt{2}} \big( | 0 \rangle - | 1 \rangle \big)\);对 \(| \alpha \rangle\) 而言没有什么区别,但因为 \(X | - \rangle = - | - \rangle\)\(| \beta \rangle\) 的情况就变得很有意思了:

\[ | \beta \rangle | - \rangle \xrightarrow{\text{CTRL-}X} | \beta \rangle \big( - | - \rangle \big) = \big( - | \beta \rangle \big) | - \rangle \]

我们本来好像是通过工作比特的量子态是否是解,来控制辅助比特的相位;但事情也可以反过来说,辅助比特并没有发生什么改变,相位改变了的其实是工作比特!

更具体一些地看,对于 \(| \psi \rangle = | \alpha \rangle + | \beta \rangle\) 而言,当它被 \(\text{CTRL-}X\) 作用后的效果是

\[ | \psi \rangle \xrightarrow{\text{CTRL-}X} | \alpha \rangle | - \rangle + | \beta \rangle \big( - | - \rangle \big) = \big( | \alpha \rangle - | \beta \rangle \big) | - \rangle \]

等号左边,从控制门路的视角来看,是 \(| \beta \rangle\) 所贡献的那部分将辅助比特的相位翻转过来。而等号右边,从工作比特的情况来看,它恰好实现了一个 Oracle 的相位变化。这样就巧妙地把 Oracle 相位变化的问题转化为控制门路的实现了。

数独问题的 Oracle 门路 \(O\) 实现#

这里我们的主要目标是将两个数独的条件,用控制门路的方式实现:待定格子与一些已知数字不相同、一些待定格子间的数值不相同。

首先是待定格与已知数字不同。函数 apply_cond_init_number 的作用是,当一些工作量子比特与已知数字相同时,就作用 \(X\) 门路在辅助比特上。

def apply_cond_init_number(qcirc, qb_w_reg, qb_c, num):
    """
    Ctrl-X gate on control qubit, if working qubits represents the given number (in Sudoku convention)
    qcirc: Quantum circuit
    qb_w_reg: Working qubit registers
    qb_c: Control qubit
    num: Number to be Ctrl-X (in Sudoku convention)
    """
    num_neg = 2**len(qb_w_reg) - num + 1
    code = encode_sudoku(num_neg)
    apply_encode_xgate(qcirc, qb_w_reg, code)
    qcirc.mcx(qb_w_reg, qb_c)
    apply_encode_xgate(qcirc, qb_w_reg, code)
    return qcirc

举例而言,对于两比特的情况,如果工作比特 (下图的 \(w_0, w_1\)) 值为 3 (对应编码为 10) 时,辅助比特的值更改;否则不会发生变化。

qcirc = QuantumCircuit()
qb_w_reg = QuantumRegister(2, name="w")
qb_c_reg = QuantumRegister(1, name="c")
qcirc.add_register(qb_w_reg, qb_c_reg)
apply_cond_init_number(qcirc, qb_w_reg, qb_c_reg, 3)
qcirc.draw(scale=0.7)
_images/135e351ad758784ddbb4daf1f91d576897a0f522bfe8e535dd8a933ab2d6abd9.svg

其次是待定格与其它待定格不同。函数 apply_cond_edges 的作用是,当两组工作量子比特所表示的值相等时,就作用 \(X\) 门路在辅助比特上。其构造思路是,利用 XNOR 运算的可逆性,将第二组量子比特作为临时的辅助比特,储存了它是否与第一组量子比特的数值相等的信息;当受控 \(X\) 门作用到辅助比特后,再将第二组量子比特的数值还原回来。

def apply_cond_edges(qcirc, qb_w_reg1, qb_w_reg2, qb_c):
    """
    Ctrl-X gate on control qubit, if working qubits reg1 and reg2 represents the same value
    qcirc: Quantum circuit
    qb_w_reg1, qb_w_reg2: Working qubit registers
    qb_c: Control qubit
    """
    # change reg2 to XNOR(reg1, reg2)
    qcirc.x(qb_w_reg1)
    for qb_1, qb_2 in zip(list(qb_w_reg1), list(qb_w_reg2)):
        qcirc.cx(qb_1, qb_2)
    # qcirc.x(qb_w_reg1)
    qcirc.mcx(qb_w_reg2, qb_c)
    # uncompute XNOR
    # qcirc.x(qb_w_reg1)
    for qb_1, qb_2 in zip(list(qb_w_reg1), list(qb_w_reg2)):
        qcirc.cx(qb_1, qb_2)
    qcirc.x(qb_w_reg1)
    return qcirc

对于两组两个比特之间的数值比较,其线路就如下图所示。

qcirc = QuantumCircuit()
qb_w_reg1 = QuantumRegister(2, name="w1")
qb_w_reg2 = QuantumRegister(2, name="w2")
qb_c_reg = QuantumRegister(1, name="c")
qcirc.add_register(qb_w_reg1, qb_w_reg2, qb_c_reg)
apply_cond_edges(qcirc, qb_w_reg1, qb_w_reg2, qb_c_reg)
qcirc.draw(scale=0.7)
_images/133a548863f4e8b1ec23d57de3e862738a89288a0aeade4b2aa8200422f35963.svg

有了这些准备,我们可以对整个 Oracle 门路进行实现。需要注意,Oracle 门路的辅助比特分为三类:

  1. 处理与已知数字不同的情况 (程序中记为 \(ci\))

  2. 处理待定格子相互不同的情况 (程序中记为 \(ce\))

  3. 用于控制相位 (程序中记为 \(out\))

对于当前的 Sudoku 问题,所有的量子线路中所有量子比特都会参与到 Oracle 门路中。这里的 generate_qubit_registers 是输入数独问题,给出分配好的线路所需量子比特;apply_constrains_sudoku 则是将所有数独的限制条件,通过受控门路应用到量子线路中。

def generate_qubit_registers(puzzle):
    """
    Generate all qubit registers required to solve Sudoku puzzle by Grover algorithms.
    puzzle: Sudoku puzzle instance
    """
    nqubit = get_nqubit(puzzle)
    empty_grids = get_empty_grids(puzzle)
    cond_init_number = get_cond_init_number(puzzle, max_number=2**nqubit)
    cond_edges = get_cond_edges(puzzle)
    qb_w_list = [QuantumRegister(nqubit, name="w" + str(n)) for n in empty_grids.keys()]
    qb_c_init = QuantumRegister(sum([len(i) for i in cond_init_number.values()]), name="ci")
    qb_c_edge = QuantumRegister(sum([len(i) for i in cond_edges.values()]), name="ce")
    qb_out = QuantumRegister(1, name="out")
    return qb_w_list, qb_c_init, qb_c_edge, qb_out
def apply_constrains_sudoku(puzzle, to_gate=True):
    """
    Apply constrains as controled gates for Sudoku puzzle.
    These controled gates combined with multi-ctrl-x gate to qb_out, will give a complete Oracle.
    puzzle: Sudoku puzzle instance
    to_gate: Whether transform Oracle to gate (for visualization)
    """
    nqubit = get_nqubit(puzzle)
    empty_grids = get_empty_grids(puzzle)
    cond_init_number = get_cond_init_number(puzzle, max_number=2**nqubit)
    cond_edges = get_cond_edges(puzzle)
    
    qb_w_list, qb_c_init, qb_c_edge, qb_out = generate_qubit_registers(puzzle)
    
    qcirc = QuantumCircuit()
    qcirc.add_register(*qb_w_list, qb_c_init, qb_c_edge)
    
    n_init = 0
    for n_reg, num_list in cond_init_number.items():
        for num in num_list:
            apply_cond_init_number(qcirc, qb_w_list[n_reg], qb_c_init[n_init], num)
            n_init += 1
    n_edge = 0
    for n_reg1, n_reg2_list in cond_edges.items():
        for n_reg2 in n_reg2_list:
            apply_cond_edges(qcirc, qb_w_list[n_reg1], qb_w_list[n_reg2], qb_c_edge[n_edge])
            n_edge += 1
    if to_gate:
        gate = qcirc.to_gate()
        gate.name = "Ctrl"
        return gate
    else:
        return qcirc

以 Puzzle (1),将数独的所有限制条件用于量子线路的效果如下所示。下图需要展开代码结果方能显示。

qcirc = apply_constrains_sudoku(puzzle1, to_gate=False)
qcirc.draw(scale=0.5)
_images/c2c25af46448b74b4e18420d103f0fdc97de78a1ed8e72c76628bfe8cedb6925.svg

但需要注意的是,这并不是完整的 Oracle 门路。要使其成为 Oracle,还需要将所有限制条件总地作用于控制相位的比特 \(out\) 中。同时,其余的工作比特 \(ci\), \(ce\) 需要复归原位;因此还需要将方才的控制线路再作用一次。原则上,再作用一次应该是要取算符的共轭转置;但由于我们使用的无外乎是实空间下的对称的算符,因此这个控制线路的共轭转置就等于它本身。

若将上面的线路记为 \(\text{CTRL}\),那么完整的 Oracle 实际上如下述代码的执行结果所示。

qb_w_list, qb_c_init, qb_c_edge, qb_out = generate_qubit_registers(puzzle1)
qb_w_all = list(itertools.chain(*[qb_w_reg for qb_w_reg in qb_w_list]))
qcirc = QuantumCircuit()
qcirc.add_register(*qb_w_list, qb_c_init, qb_c_edge, qb_out)
ctrl_gate = apply_constrains_sudoku(puzzle1, to_gate=True)

qcirc.append(ctrl_gate, qb_w_all + list(qb_c_init) + list(qb_c_edge))
qcirc.x(list(qb_c_init) + list(qb_c_edge))
qcirc.mcx(list(qb_c_init) + list(qb_c_edge), qb_out)
qcirc.x(list(qb_c_init) + list(qb_c_edge))
qcirc.append(ctrl_gate, qb_w_all + list(qb_c_init) + list(qb_c_edge))

qcirc.draw(scale=0.5)
_images/3899b3556ee88c3e946a9ab222ffe54a51383ec54492f5448f0fda489caf4d96.svg

相移增幅算符 \(U_s\)#

相移增幅算符 \(U_s = 2 | \psi \rangle \langle \psi | - I\) 是作用于工作比特 \(w\) 上的算符。它可以写为下述表达式:

\[ U_s = H^{\otimes r} X^{\otimes r} (MCZ) X^{\otimes r} H^{\otimes r} \]

其中,上式的 \(r\) 为量子比特数。

这里的函数 generate_diffuser 就直接参考 Qiskit 文档 的实现了。这里的实现代码就折叠起来了。

Hide code cell source
def generate_diffuser(nqubits, to_gate=True):
    """
    Code from Qiskit document.
    https://qiskit.org/textbook/ch-algorithms/grover.html#3.1-Qiskit-Implementation-
    """
    qc = QuantumCircuit(nqubits)
    # Apply transformation |s> -> |00..0> (H-gates)
    for qubit in range(nqubits):
        qc.h(qubit)
    # Apply transformation |00..0> -> |11..1> (X-gates)
    for qubit in range(nqubits):
        qc.x(qubit)
    # Do multi-controlled-Z gate
    qc.h(nqubits-1)
    qc.mct(list(range(nqubits-1)), nqubits-1)  # multi-controlled-toffoli
    qc.h(nqubits-1)
    # Apply transformation |11..1> -> |00..0>
    for qubit in range(nqubits):
        qc.x(qubit)
    # Apply transformation |00..0> -> |s>
    for qubit in range(nqubits):
        qc.h(qubit)
    # We will return the diffuser as a gate
    if to_gate:
        U_s = qc.to_gate()
        U_s.name = "$U_s$"
        return U_s
    else:
        return qc

完整的数独线路#

我们现在已经有了 Grover 算法最关键的两个门路:Oracle \(O\) 与相移增幅算符 \(U_s\) 了。在构建最终的数独线路时,我们还需要注意到

  • 需要将工作比特初始化成 \(| \psi \rangle = \frac{1}{\sqrt{2^r}} \sum_x^{2^r-1} | x \rangle\) 即均衡叠加态;因此需要先在所有工作比特上加 \(H\) 门路。

  • 需要将用于控制相位的辅助比特初始化为 \(| - \rangle = \frac{1}{\sqrt{2}} \big( | 0 \rangle - | 1 \rangle \big)\),因此需要先引入 \(HX\) 两个门路。

  • 最后需要将结果测量到传统比特上。

  • Oracle 的迭代次数需要用户在 iteration 参量中手动指定。

下述的函数 generate_qcirc_sudoku 就是最终生成数独问题的量子线路。

def generate_qcirc_sudoku(puzzle, iteration):
    """
    Generate quantum circuit for Sudoku problem
    puzzle: Sudoku puzzle instance
    iteration: Number of Oracles applied in circuit
    """
    qcirc = QuantumCircuit()
    qb_w_list, qb_c_init, qb_c_edge, qb_out = generate_qubit_registers(puzzle)
    qb_w_all = list(itertools.chain(*[qb_w_reg for qb_w_reg in qb_w_list]))
    cb_all = ClassicalRegister(len(qb_w_all), name="cbits")
    qcirc.add_register(*qb_w_list, qb_c_init, qb_c_edge, qb_out, cb_all)

    ctrl_gate = apply_constrains_sudoku(puzzle, to_gate=True)
    diffuser_gate = generate_diffuser(len(qb_w_all))

    qcirc.h(qb_w_all)
    # qcirc.initialize([1, -1]/np.sqrt(2), qb_out)
    qcirc.x(qb_out)
    qcirc.h(qb_out)
    qcirc.barrier()
    for _ in range(iteration):
        qcirc.append(ctrl_gate, qb_w_all + list(qb_c_init) + list(qb_c_edge))
        qcirc.x(list(qb_c_init) + list(qb_c_edge))
        qcirc.mct(list(qb_c_init) + list(qb_c_edge), qb_out)
        qcirc.x(list(qb_c_init) + list(qb_c_edge))
        qcirc.append(ctrl_gate, qb_w_all + list(qb_c_init) + list(qb_c_edge))
        qcirc.append(diffuser_gate, qb_w_all)
        qcirc.barrier()
    qcirc.measure(qb_w_all, cb_all)
    return qcirc

以 Puzzle (1) 为例,如果 Oracle 执行三次,则其线路可以表示如下:

qcirc = generate_qcirc_sudoku(puzzle1, 3)
qcirc.draw(scale=0.5, fold=-1)
_images/02bed468fb8b8e875ccf9c0c66f0444fa2114d8149348568ae53412d15e65393.svg

数独问题的解#

对 Puzzle (1) 的讨论#

我们知道 Puzzle (1) 的解有两种:\((V_0, V_1, V_2)\) 的可能取值是 \((1, 2, 1)\)

fig, ax = plt.subplots(figsize=(2.3, 2.3))
plot_puzzle_2x2(Sudoku.decode("1234341223414123"), ax, original=puzzle1)
_images/3c9eaf35c4637b5af1bb95cbe070d34f7a346a8e7428e8635d5f7e6e8735f4e6.svg

Oracle 执行的次数是需要用户手动输入的。那么输入多少合适呢?

我们现在必须基于已经知道数独解的数量 \(M\) 的前提讨论问题。对于 Puzzle (1),我们知道解的数量 \(M\) 是 1。而总共又有 3 个待定格子,每个格子都可能取 1~4 任何一个数;因此求解的搜索空间大小 \(N = 4^3 = 64\)

我们先前已经展示过,若要使求得正解的概率最大,那么 Oracle 迭代次数

\[ k = \frac{\pi}{4 \arcsin \sqrt{M / N}} - \frac{1}{2} \]

在当前的例子中,\(k \simeq 6\)

np.pi / 4 / np.arcsin(np.sqrt(1 / 64)) - 1 / 2
5.766749819872207

我们不妨看一下,依据量子线路执行后的本征态,6 次迭代后正确求得解 \((V_0, V_1, V_2) = (0, 1, 2)\) 的概率是

qcirc = generate_qcirc_sudoku(puzzle1, 6)
prob_dict = get_result_prob_by_statevector(qcirc, qcirc.qubits[:2 * 3])
{tuple(decode_cbits_sudoku(key, 2)): val for key, val in prob_dict.items()}
{(1, 2, 1): 0.9965856807865088}

我们还可以作正确率关于迭代次数的关系图:

Hide code cell source
success_list = []
for iteration in range(0, 12):
    qcirc = generate_qcirc_sudoku(puzzle1, iteration)
    prob_dict = get_result_prob_by_statevector(qcirc, qcirc.qubits[:2 * 3], tol=0)
    prob_dict = {tuple(decode_cbits_sudoku(key, 2)): val for key, val in prob_dict.items()}
    success_list.append(prob_dict[(1, 2, 1)])
Hide code cell source
fig, ax = plt.subplots(figsize=(3, 2))
ax.plot(range(12), success_list)
ax.set_xlim(0, 11); ax.set_ylim(0, 1)
ax.plot([0, 0], [0, 1], color="black"); ax.plot([0, 11], [0, 0], color="black")
ax.set_xlabel("Oracle Iterations"); ax.set_ylabel("Probability of success"); ax.set_title("Puzzle (1)")
fig.tight_layout()
_images/91a0a92ac0568fe105be614f614c58a467f8cab51b3d75323c5fa743353bd35d.svg

我们就会发现,能正确找到问题的概率随着 Oracle 迭代次数大致呈正弦函数平方的关系;并非迭代次数越多越好。因此,在不知道问题的解的数量时,应用 Oracle 求解数独问题不一定是非常好的策略;这是 Grover 算法的缺点之一。其中一种可能的解决办法是,将求取解的数量 \(M\) 转化为循环群的求阶问题。

并且从上面的讨论中,我们注意到,Grover 算法寻找解的数量,如果不需要考虑量子线路的误差,那么可以以近乎 100% 的概率得到正确结果 (特比是当解空间 \(N\) 非常大时)。这可能与我们习惯上认知的量子力学不一样 (比如 薛定谔的猫),是几乎不存在不确定性的,或者说不确定性是可以近乎任意精度地控制在非常低的范围。

我认为,这种情况下,可以称这种量子算法不具有明显的不确定性。另一种近乎不存在不确定性的算法是 Shor 分解质因数算法。但许多基于量子计算的优化算法 (包括化学中提及的 VQE-UCCSD 问题) 则是有较强不确定性,需要依靠大量测量得到相对准确的算符观测期望值。

对 Puzzle 2 的讨论#

我们知道 Puzzle (2) 的解有两种:\((V_0, V_1, V_2, V_3)\) 的可能取值是 \((1, 2, 2, 1)\)\((2, 1, 1, 2)\)

Hide code cell source
fig, axs = plt.subplots(ncols=2, figsize=(5, 2.3))
plot_puzzle_2x2(Sudoku.decode("1234341221434321"), axs[0], original=puzzle2)
plot_puzzle_2x2(Sudoku.decode("2134341212434321"), axs[1], original=puzzle2)
_images/3cfa1e4e0a63f16c66386ff4aa2e29d54786cb574b9676d91069ea49f3097493.svg

对于该问题,解空间的大小是 \(N = 4^4 = 256\)、可能解的数量是 \(M = 2\)。由此,可以推算得到需要运行的 Oracle 次数是 \(k \sim 8\) 次:

np.pi / 4 / np.arcsin(np.sqrt(2 / 256)) - 1 / 2
8.374170154616117

对于 Puzzle (2),以我们的算法进行线路构造时,需要的量子比特数会比较多,达到 21 个。在这种情况下,使用在一般的计算机上使用 statevector 精确地模拟运行结果会比较耗费资源。我们这里就模拟地进行 1000 次测量,并将比特测量的情况解码到数独问题的解:

qcirc = generate_qcirc_sudoku(puzzle2, 8)
prob_dict = get_result_prob_by_shots(qcirc, shots=1000)
{tuple(decode_cbits_sudoku(key, 2)): val for key, val in prob_dict.items()}
{(2, 3, 2, 1): 0.001,
 (3, 4, 1, 3): 0.001,
 (3, 3, 4, 4): 0.001,
 (4, 1, 3, 3): 0.001,
 (3, 3, 2, 2): 0.001,
 (1, 3, 4, 3): 0.001,
 (4, 2, 3, 2): 0.001,
 (2, 3, 2, 3): 0.001,
 (1, 3, 3, 2): 0.001,
 (3, 4, 1, 4): 0.001,
 (1, 2, 2, 1): 0.481,
 (2, 1, 1, 2): 0.509}

可以看到,在大量采样时还是会有大约 0.5% 的概率得到错误的解,但这个概率已经非常小了。

挑战?3 阶数独简单情形的求解#

在最后一个例子中,我们引入下述 \(n = 3\) 的三阶矩阵 Puzzle (3),并使用我们的量子线路尝试进行求解。

这个问题是不是太简单了亿点 hhhh

Hide code cell source
puzzle3 = sudokutools.sudoku.Sudoku.decode("012753649943682175675491283154237896369845721287169534521974368438526917796318452")
sudokutools.solve.init_candidates(puzzle3)
solution3 = sudokutools.sudoku.Sudoku.decode("812753649943682175675491283154237896369845721287169534521974368438526917796318452")

fig, ax = plt.subplots(figsize=(5, 5))
plot_puzzle_3x3(solution3, ax=ax, original=puzzle3)
ax.set_xlabel("Puzzle (3)", fontsize=18)
Text(0.5, 0, 'Puzzle (3)')
_images/e865e18baf7c1f421c38354226f8b93d8e88f8b0b0df63da0ae2df5cf4015e48.svg

但是在这个问题的求解过程中,我们需要消耗大量的辅助量子比特。唯一的待定格子由于与 1, 2, 3, 4, 5, 6, 7, 9 都不相同,因此需要 8 个辅助量子比特用于实现待定格不能与已知数相等的条件。

但除此之外,注意到对每个取值范围是 1~9 数字进行二进制编码,耗费的量子比特是 4 个;可是,待定格子也不能取 10, 11, 12, 13, 14, 15, 16。我们在当前的程序中,使用了最简单的办法,即将这些数字一并列入限制条件。这样一来,这个待定格子就需要额外多 7 个辅助量子比特了。这样一算下来,整个线路总共就需要 20 个量子比特。

qcirc = generate_qcirc_sudoku(puzzle3, 3)
qcirc.num_qubits
20

20 个量子比特基本上已经是目前在经典计算机上,实现量子模拟的极限了 (内存受限)。因此,对于 3 阶数独,以我们目前的算法实现,在模拟机上只能解决 1 个待定格子问题。这确实可谓是杀鸡用 牛刀 氦闪了。

不过我们可以验证一下,我们的程序确实能给出正确的答案,即待定格子的值是 8。

此时的解数量 \(M = 1\)、解空间是 \(N = 16\),对应了 \(k \simeq 3\) 的情况。我们能以大约 95% 的概率获得正确解。

qcirc = generate_qcirc_sudoku(puzzle3, 3)
prob_dict = get_result_prob_by_shots(qcirc, shots=1000)
{tuple(decode_cbits_sudoku(key, 4)): val for key, val in prob_dict.items()}
{(16,): 0.002,
 (2,): 0.003,
 (12,): 0.004,
 (8,): 0.962,
 (4,): 0.003,
 (13,): 0.002,
 (9,): 0.002,
 (3,): 0.002,
 (1,): 0.003,
 (14,): 0.004,
 (11,): 0.003,
 (15,): 0.001,
 (10,): 0.004,
 (6,): 0.002,
 (5,): 0.003}

复杂度分析与杂谈#

算法所需的量子比特数#

对于数独算法,其所需要的量子比特数其实还是不少的。

  • 编码一个数字所需要的比特数是 \(n_\mathrm{qubit} = \lceil \log_2(n^2) \rceil \sim 2 \log_2 n\)

  • 对于待定格子数是 \(n_\mathrm{empty}\)\(n\) 阶数独,编码这些待定格子所需要的比特数就达到 \(n_\mathrm{empty} \lceil \log_2(n^2) \rceil \sim 2 n_\mathrm{empty} \log_2 n\)

  • 用于待定格子不能与已知数字等值的规则的辅助比特,需要不多于 \(n_\mathrm{empty} 2^{\lceil \log_2(n^2) \rceil} \sim n_\mathrm{empty} n^2\) 个;其中,\(2^{\lceil \log_2(n^2) \rceil}\) 是量子比特所有可能表示的数值,它大约等于数独棋盘的横或纵格子数 \(n^2\)

  • 用于待定格子之间不能等值的规则的辅助比特,需要不多于 \(\frac{1}{2} n_\mathrm{empty} n_\mathrm{empty}\)\(3 n^2 n_\mathrm{empty} / 2\) 个;

    • 前者所代表的是 \(n_\mathrm{empty}\) 比较小的情况,最坏时所有格子对都或者同行、或者同列、或者同宫;

    • 后者所代表的是 \(n_\mathrm{empty}\) 比价大的情况,最坏时单个待定格子所处的所有同行、同列、同宫的格子也都是待定的;

  • 以及最后有一个用于给出相位变化的辅助比特。

但需要指出,用于待定格子不能与已知数字等值的规则的辅助比特,是有可能省略的。以 2 阶数独为例,正常情况下,我们会将一个待定格子所对应的量子比特初始化为均衡叠加态

\[ | \psi \rangle = \frac{1}{\sqrt{4}} \big( | 1 \rangle + | 2 \rangle + | 3 \rangle + | 4 \rangle \big) \]

但如果这个待定格子只可能取 1, 2 值呢 (就像 Puzzle (1) 中的 \(V_1\) 的情况)?那么不如干脆将 \(| 3 \rangle + | 4 \rangle\) 剔除出均衡叠加态的初始态,而初始化为

\[ | \psi \rangle = \frac{1}{\sqrt{2}} \big( | 1 \rangle + | 2 \rangle \big) \]

这种做法就将与已知数字不等值的规则,从利用额外的 \(n^2 n_\mathrm{empty}\) 个辅助比特实现,转化到量子态的初始化的问题。这也并非是毫无代价的:它要求使用大约 \(\log_2^2 (n^2) n^2\) 个单、双比特门[9]。不过这个代价相对于 Oracle 的实现,还是非常能接受的。单纯从这点上,我们的算法实现上也还有很大的改进空间。

问题:8192 个量子比特可以解多少阶数独?#

在电影《流浪地球 2》中,功能强大的 550W 型量子计算机的“量子体积”是 8192。这种计算机不仅可以高效地调度月球上大量工程的建设,而且还可以进行长时间的虚拟智能生命演化。从我在写这文档的当前,以最强大的传统超级计算机的算力来看,这都是相当难以企及的 (当然也可能只是软件对硬件的实现效率、或人工智能网络的复杂程度还有待提升)。

我们先不管 量子体积 真正的定义是什么。就不妨假设 550W 型量子计算机有 8192 个量子比特,且量子噪声为零。

我们对这个问题作进一步框定。

  • 假设我们可以任意地对量子态进行初始化 (即不需要考虑待定格子不能与已知数字等值的规则,所需要占用的辅助比特);

  • 数独的盘面上有一半的待定格 (\(n_\mathrm{empty} \simeq n^4 / 2\))。

在这个限制条件下,我们会估计对于每个待定格子平均下来,与其在同行、同列或同宫的待定格子数大约是 \(3 n^2 / 2\) 个。因此,整个问题大约需要的量子比特数是

\[ 2 n_\mathrm{empty} \log_2 n + \frac{1}{2} n_\mathrm{empty} \times \frac{3 n^2}{2} \simeq n^4 \log_2 n + \frac{3 n^6}{8} \]

如果上面的估计没有错的话,依 Grover 算法,8192 个量子比特也只能解 5 阶数独而已 (实际消耗大约 7310 个量子比特)。这棋盘上还只是一半的格子被镂空了;但不妨回想一下,你所遇到过的现实的三阶数独问题,有哪一个只把一半镂空的?

警告

结论 五阶数独求解的难度等同于智能生命的演化 (doge)

当然,之所以得到这么离谱的结论,也可以归结为当前的 Grover 算法或许没有高效地实现。不过即使不需要辅助比特,光是 \(n^4 \log_2 n\) 的工作比特数,这也只够解 7 阶数独。

也可能只是眼高手低的我,小看了数独问题的难度。

n_qubit_sudoku_asymptotic = lambda n: int(n**4 * np.log2(n) + 3 * n**6 / 8)
print("Order   Asymptotic")
for n in range(2, 8):
    print("{:3} {:12d}".format(n, n_qubit_sudoku_asymptotic(n)))
Order   Asymptotic
  2           40
  3          401
  4         2048
  5         7310
  6        20846
  7        50858

线路复杂度#

整个量子线路,基本上就是重复执行 Oracle 门路与相移增幅算符 \(U_s\)。Oracle 门路又分为为了应用数独限制条件而设计的对辅助比特的控制门路、以及对多个辅助比特实现受控非门的门路。

先考虑 Oracle 门路。每个数独限制条件的实现都不困难:

  • 对于待定格子不能与已知数字等值的规则,它使用了若干个可以并行的非门、以及一个多控非门。考虑到整个线路的冗余辅助比特非常多,因此应用于 \(\lceil \log_2 (n^2) \rceil\) 个比特的多控非门,可以在 \(O(\log_2 n)\) 的双比特门路下实现[10]

  • 对于待定格子之间不能等值的规则,除了多控非门外,还涉及到一些两比特间的 XNOT 门路 (使用非门和受控非门各一个实现);这也是 \(O(\log_2 n)\) 的复杂度。

因此,总地来说,既然规则数量在 \(O(n^6)\) 级别,那么单个 Oracle 门路所需要的单、双比特数就在 \(O(n^6 \log_2 n)\) 级别上。

对于相移增幅算符 \(U_s\),它最复杂的部分是作用在大约 \(2 n_\mathrm{empty} \log_2 n\) 个工作比特上的多控非门、考虑到 \(n_\mathrm{empty} < n^4\),那么相移增幅算符 \(U_s\) 所需要的单、双比特数在 \(O(n^5 \log_2 n)\) 级别上。

事实上,对于 Oracle 门路,会有许多算符是相互对易的;因此,Oracle 门路预期可以大幅利用并行优势。我猜并行的提升在 \(n^2\) 倍左右。因此,单次 Grover 过程 (一次 Oracle 门路接一次相移增幅算符 \(U_s\)) 的总复杂度是 \(O(n^5 \log_2 n)\) 左右。


但 Grover 算法最大的困难,还在于需要迭代执行 Oracle 门路的次数。事实上,解空间大小

\[ N = 2^{n_\mathrm{empty} \lceil \log_2 (n^2) \rceil} \simeq n^{2 n_\mathrm{empty}} \]

如果我们不考虑解的数量 \(M\),那么 Orcale 门路运行次数的估计就是

\[ k \simeq \frac{\pi}{4} \sqrt{\frac{N}{M}} - \frac{1}{2} \sim O(n^{n_\mathrm{empty}}) \]

所以实际上,Grover 算法总的复杂度会在 \(O(n^{n_\mathrm{empty}} n^5 \log_2 n)\) 左右。它仍然无法成为多项式的计算量。同时要注意到,Grover 算法的线路复杂度也是指数级别的;这对于含有噪声的量子计算机而言,不一定是好事。

乱谈一波?#

这副标题就很有做计算化学的风范~ (假装斯文)

不过作为外行,没有近期文献的阅读经历,以下言论对得起乱谈二字。

这份文档是在前几天的一次聚会上,听到同学提及,如果将数独的所有数字全都编码为二进制或者布尔值,那么是否有办法在这种新的、不太能直观理解的结构下,找到更快速的解数独的方法。

我后来回去一想,觉得数独是一个搜索问题;同时如果能将数独编码为二进制,那么它就与量子比特所表示的态无异了。再随后一找,果然数独被认为是一种 Grover 算法的典型应用范例。

但现在的不少说明文档,于我而言总觉得有些缺憾。Qiskit 老版本文档[5]详细地介绍了 Grover 算法;新版本[11]代码更是有对 3-SAT 问题的讨论。不过对数独问题,老版本文档只介绍了 1 阶数独的求解;对于更高阶的数独,没有提供 Orcale 的实现 (但从本文档来看,也不可谓很难)。Microsoft 的代码示例[12]给出了更高阶数独的解法,但一来不能说有详细的文档、二来 Orcale 算法似乎是直接使用上色问题而不是从头实现 (尽管对于 API 使用者来说这样的代码更友好)、三来不是 Python (我已经不会编译语言了 hhh)。

尽管说从头写一份文档其实也不过抄来抄去 (鄙校编教材中招 `•ω•´),但我自己还是挺喜欢这么搞的。


\(n\) 阶数独已经被证明是 NP-complete 问题了[13]。而我印象里,现在一般并未认为经典计算的 NP 问题是可以转化为量子计算的 P (BQP) 问题;甚至很有可能,经典计算的 NP 问题在量子计算下也是 NP (QMA) 的。这也就意味着,很多时候,恐怕无法指望量子计算极大程度地改善经典计算近乎于无法解决的问题。拿 \(n\) 阶数独来说,恐怕它即使是在量子计算下,也大概率是 NP (QMA) 的。

但也不能太悲观。

  • 一方面,Grover 相对于暴力搜索算法而言,其效率提升是平方级的。

  • 另一方面,在经典计算机中仍然存在一些无法被证明是 NP-complete,但实现上暂未找到 P 实现算法的问题 (譬如质因数分解问题);在这些问题上,量子计算有很大的优势。

但又不能太乐观。

  • 平方级的效率提升很大,但若搜索空间 \(N\) 没有大到一定程度,暴力搜索完全可能因为经典计算机处理芯片 (CPU, GPU 等) 的处理速度比量子计算机的算一个门路快 \(\sqrt{N}\) 倍,而使得量子计算机毫无优势可言。

  • 平方级的效率提升,也可以通过改进经典计算机上的算法实现。见 Qiskit 新文档 关于 Grover 算法与 3-SAT 问题的讨论;对 3-SAT 算法的一些改进就可以在复杂度上超越 Grover 算法。

  • 数独已经被证明与 3-SAT 结构相似,是 NP-complete 的。从这一点上,也不能简单地将数独与素因数分解问题作类比,认为可能在多项式线路复杂度下解决问题;毕竟素因数分解似乎没有被证明 NP-complete。似乎现在没有 NP-complete 但 BQP 的先例?

但还是不能太悲观。

  • 质因数分解算法 (Shor) 之所以能在多项式复杂度下解决问题,一部分原因是幂次计算 (\(n^m \ \mathrm{mod} \ r\)) 在量子计算机下是对数时间下 (在 \(\log_2 n\)\(\log_2 m\) 级别下) 可实现的。

    尽管幂次在经典计算机下也是对数级别可实现的,量子计算机并没有什么特别。但我想说的意思是,我们不妨回顾到,对于 QCQI[8] 6.3 节介绍的量子计数问题 (求出搜索问题的解数量 \(M\)),如果将 Grover 门路换成幂次计算的门路,那么就与 Shor 算法完全等价 (同样是相位估计算法)。Shor 算法可以看成是循环群求阶问题,也可以看成是搜索问题的量子计数问题。但 Shor 算法就与数独问题不同,不需要在 \(O(\sqrt{N})\) 代价而只需要 \(O(\log^{4} N)\) 实现 (也可能是 \(O(\log \log N \log^3 N)\)?总之是对数下多项式的),原因就是 Grover 门路的幂次计算 \(G^n\) 对应到质因数分解问题,只需要 \(O(\mathrm{poly}(\log n))\) 时间;但数独问题的 \(G^n\),恐怕现在还没有找到一种比 \(O(n)\) 代价更低的实现方式。

    我现在也还不清楚,数独问题的 Grover 算符的幂次,是否存在一种特殊的数学结构,使得 \(G^n\) 的计算也可以在对数时间 \(O(\mathrm{poly}(\log n))\) 完成。(但如果真有人发现新的数学结构,那应该可以冲一波大奖了?)

  • 也如 Qiskit 新文档 末尾所言,量子和经典算法的混合,或许会有新的可能性产生。

注:在 EPL 2020[14]中,作者应该是使用了量子态删除的方式实现了量子线路的数独实现,且为经典-量子混合方法,并表明有 \(O(\log (k))\) 级别的提速。他们使用的量子比特数,按我们的文档记号,是 \(2 n^4 \log_2 n\) 的,即非常精简的。我可能还没能完全理解他们的文章。


如文档开头所述,各种平台[3]或软件[4]其实已经对数独问题有很深入的研究了。我直到写这篇文档时,才发现原来数独问题可以有如此多的求解策略。不得不说我以前确实小看了数独问题。

但这些策略在 3 阶数独并不见得可以加速传统计算机上的求解。现在的数独求解,最快的程序之一可能是 tdoku[15]。以我的能力暂时还不能完全理解,但我估计它大概可以看作是应用了非常简单的规则,同时考虑到 CPU 的架构,实现的指令集优化的 DFS 代码。

即使没有将数独从指数复杂度降到多项式复杂度,但经典计算机对这类问题的求解能力,还是相当恐怖的。而且极少的规则就可以大幅减少搜索空间的数量 \(N\),从而事实上也会计算复杂度。这个降幅,或许未必比 \(\sqrt{N}\) 小。

程序版本信息#

Hide code cell source
print("=== Version Info ===")
print("{:15}: {}".format("Python", sys.version.split()[0]))
print("{:15}: {}".format("Qiskit", qiskit.__version__))
print("{:15}: {}".format("NumPy", np.__version__))
print("{:15}: {}".format("sudokutools", sudokutools.__version__))
print("{:15}: {}".format("Matplotlib", mpl.__version__))
=== Version Info ===
Python         : 3.8.13
Qiskit         : 0.23.1
NumPy          : 1.23.5
sudokutools    : 0.4.0
Matplotlib     : 3.5.1

这部分的内容置于 github 仓库 中。

ajz34 的其它文档#

其它 ajz34 编写的文档 (包括已不维护的) 有

检索#