xDH 在 Python 下实现的简易教程

在这份文档中,我们将会介绍 xDH 型函数的典型:XYG3,其在 Python 下的实现过程。同时,为了方便,这里大量使用 PySCF API 进行中间矩阵的输出与计算。作者希望,读者可以借助于这些成熟量化软件接口,以及 NumPy 对矩阵、张量计算的强大支持,可以较为轻松地在 Post-HF 或 DFT 方法理论推导,与上机实验的结果进行相互比对,并最终享受亲手实现新方法的乐趣。

动机

在量子化学领域中,计算方法 (而非算法优化) 的开发瓶颈除了最初的灵感与公式推导之外,表达式的程序实现与其正确性也非常重要。事实上,往往后者左右了方法的开发效率、完成度,以及方法的推广流传程度。通常,应用一个量子化学方法或者会在已有程序上作改动,或者自己重新构建一个程序。

对于程序改动,方法开发者通常需要面临理解复杂程序结构、阅读大量冗余代码、了解编译流程、缺少充足的文档等困难,在程序上所消耗的时间不亚于、甚至远高于方法开发的耗时。成功的程序改动决定于方法开发者的程序调试能力。

而对于从头构建程序,耗时将会因人而异,这取决于方法开发者的综合程序能力,包括编译、调试、框架搭建、底层工具设计、公式的程序表达等等。同时,由于大多数方法开发者只关心实现而不关心代码质量,因此这类程序的复用性较差,通常不会有充足的文档,也不适合进行稍复杂的改动。在量子化学发展的早期,正因为程序的开发难以跟上量化方法的发展,同时课题组的交流与合作受限于没有因特网,从而催生了数量众多的量化软件或程序。

这份文档更为偏向于从头构建程序。早期的计算机发展主要受限于计算效率与内存,而即使是很小的量子化学的任务,也对这两者有较高的消耗。然而,现在的微机已经可以快速处理中小体系的量子化学计算;从方法开发的角度上看,硬件不应当成为瓶颈——方法的开发只需要较小的模型体系通常即可。而对硬件要求的降低,催生了高级语言的流行。对于 Python,它对方法开发者的限制会显著减小:不存在编译问题,调试可以通过实时交互界面 (interactive interface,例如 Python consoles 或 Jupyter Notebook) 或 IDE 完成。对于底层工具,Python 社区提供众多的库函数可以胜任;在量子化学领域,底层工具绝大部分与矩阵计算有关,因此 NumPy 可以胜任。

对于量子化学中的 Post-HF 与 DFT 领域,剩下的问题则是获得量子化学积分、量子化学相关的底层工具、程序框架设计与复用性、以及公式与程序的对应。对于前两者,现有的软件,例如 Psi4PySCF 均提供了不少对象接口 (API),对它们的活用将会简化程序书写上的困难。对于第三个问题,我们尽管不应当在学习过程中太多考虑,但会在文档开始的部分提一下。

因此,也许看起来从头构建程序是很可怕的事情,但在现在众多程序便利的环境下,方法开发者所真正面临的问题已经可以回归到最为本质的问题,即如何将公式写成程序。在这份教程中,一个目标便是对于我们需要计算的公式中的大多数项 (譬如张量乘积),尽可能在 1 行代码内解释,至少一般可以在 5 行以内的代码块内说明。并且,我们不希望涉及非必要的算法细节;整个教程中,绝大多数代码开始将不会多于 3 个 Tab,尽少使用循环与判断,让程序的书写的目的回归于公式表达本身。

写这份教程的灵感实际上来源于 Psi4 的一份官方简易实现 Psi4NumPy;它包含了许多高级 Post-HF 方法简单但有效的实现。但 Psi4 并非是原生的 Python 程序,它基于 C++ 开发,因此在调用 Psi4 程序时多少会有一些困难,也会出现在错误调用 C++ 程序后 Python 内核崩溃,无法继续交互地使用 Jupyter 笔记本的问题。因此,这份教程更倾向于使用几乎原生的 Python 软件 PySCF 辅助我们进行中间步骤的量化计算。

前置准备:序

这部分准备并非新手指南。在这里,作者假定读者已经了解基本的 Python、NumPy 的使用方式。如果对 C++、Matlab 等工具有足够多的经验相信也能阅读这份笔记。

在这里,我们会回顾一些不太常用的话题;或者说在一个《数据结构与算法》课程编程中,一般不会遇到的问题。这些问题很可能会穿插在以后的笔记中,或者在 pyxdh 的项目中。

环境搭建

在这份文档中,我们会大量使用 Python 与 PySCF,并通常使用 Jupyter Notebook 进行笔记记录与程序呈现。因此,在开始这份笔记之前,我们需要先搭建好程序环境。

提示

由于 PySCF 一般只在 Linux 环境下运行,因此请使用 Linux 机器、Linux 虚拟机、或者使用 Windows Subsystem of Linux (WSL)。WSL 只在 Windows 10 系统上运作,其安装方式参考 微软文档

Python 环境

在这里,我们使用 PyPI 进行库管理。

提示

Python 的库管理工具有许多;一般来说,最常用的 PyPI 是 Python 社区支持的库索引,Anaconda 则是另一个大型商业的社区库索引。PyPI 的库管理工具是 pip,而 Anaconda 的库管理工具是 conda

由于 conda 管理工具的效率较低,在图方便的情况下,我们可以使用 pip 管理当前的 Python 库;但如果担心依赖包冲突的情况,则使用 conda 安装 pyscf。当然,大多数情况下,我们可以同时使用两者进行库管理.

对于我们目前的工作,安装必须库所执行的命令是

$ pip install numpy pyscf jupyter

任务

  1. 先在用户目录下安装一个 Python 发行版。Python 发行版可以是 官方发行版,但更通常的做法是使用 Anaconda 发行版。Anaconda 发行版在大小适中的硬盘空间下,配置了绝大多数科学计算所必须的 Python 库,较为便利。注意请尽量不要安装 Python 3.6 以下的版本。

  2. 安装后,请先执行 python,并在 Bash 下执行 which python 查看 Python 可执行文件是否正确。若不正确,请向 $HOME/.bash_profile$HOME/.bashrc 中修改 PATH 路径。

  3. (可选) 根据 清华镜像 PyPI 帮助 文档的指示,修改默认 PyPI 索引镜像,以加速 Python 库的下载速度。Anaconda 也具有镜像加速的可能性,但由于 清华镜像源被要求停止使用 Anaconda 镜像,其下游镜像也因而无法使用,因此国内目前几乎没有可用的 Anaconda 镜像。

  4. (可选) 有时,我们想要在一套全新干净的 Python 环境中工作;有时,会碰到 Python 库依赖冲突的问题。在这种情况下,我们可以考虑使用虚环境解决这些问题。若只用 PyPI 进行库管理,可以使用 virtualenv 进行管理 (一份有用的中文帮助可以参考 廖雪峰的博文)。如果使用 conda 进行库管理,开可以使用 conda create 构建新的虚环境;可以在 Bash 下执行 conda create -h 查看帮助与示例。

  5. (可选) 在了解如何构建虚环境后,可以考虑将在一个新的虚环境中使用 Intel 提供的 Python 主程序与各种关键的数学库。可以参照 Intel Python 安装文档 配置你的 Python 环境。Intel Python 提供了 conda 与 pip 的安装途径。

  6. (可选) 如果我们还希望通过一阶梯度信息进行几何结构优化,一个方便的 Python 库是 berny 库。安装方式如下:

    $ pip install pyberny
    
  7. (可选) Jupyter Notebook 具有一些非官方的插件,譬如代码折叠、文档标题折叠、代码块隐藏、PEP8 检查等功能。若对这些功能感兴趣,可以参考 Unofficial Jupyter Notebook Extensions

提示

在组内服务器上,默认情况下我们可能无法连接到互联网,从而难以更新 Python 库。一种解决方案是使用信息办提供的脚本。解决方案文档请参考

/share/home/zyzhu/Documents-Shared/group_related/2019-01-09-how_to_connect_internet.markdown

pyxdh 库配置

为了运行文档,我们还需要对库 pyxdh 进行配置。该库可以执行 xDH 型泛函的梯度、MP2 的二阶梯度等分子性质,并且可以提取计算过程中的中间矩阵。以后的文档会经常使用该库。

请参考代码库主页文档 README.md 进行配置,并运行以下代码。下述验证与 Gaussian 所得到的 B3LYP 梯度的代码应当要能执行通过。关于下述代码的解释,将会在以后的文档中说明。

[1]:
from pyxdh.DerivOnce import GradSCF
from pyxdh.Utilities.test_molecules import Mol_H2O2

H2O2 = Mol_H2O2()
grad_helper = GradSCF({"scf_eng": H2O2.gga_eng})
grad_helper.E_1
[1]:
array([[-0.03447596,  0.0666383 ,  0.1260704 ],
       [ 0.00989735,  0.16068374, -0.160493  ],
       [ 0.00681508,  0.01243452,  0.03260963],
       [ 0.01776359, -0.2397567 ,  0.00181296]])
[2]:
from pkg_resources import resource_filename
from pyxdh.Utilities import FormchkInterface
formchk = FormchkInterface(resource_filename("pyxdh", "Validation/gaussian/H2O2-B3LYP-freq.fchk"))
[3]:
import numpy as np
np.allclose(grad_helper.E_1, formchk.grad(), atol=1e-5, rtol=1e-4)
[3]:
True

PySCF 环境

绝大多数情况下,我们无需更改 PySCF 的代码就能调用并调试其中的函数。因此,一般来说,无需作任何准备。

注意

我们在以前的软件 Hacking 过程中,经常会通过语句打印来了解程序运行流程与结果;并通过修改程序以引入新的功能。

但若用常识思考,打印语句其实不是很便利,而修改程序则是非常危险的行为。即使使用版本控制工具,也可能因为使用不善造成工作损失,或者在代码更改历史中迷失方向。

对于面向对象语言,在已有程序上实现新功能可以通过类的继承于重载来实现。大多数 Fortran 与 C 语言 (或者写得不友好的 C++ 程序) 的软件难以做到这一点。当然,面向对象的优势不只具有修改程序的安全性,毕竟“面向对象”的本来的主要意义是使用类 (Class) 打包方法 (method function) 与成员 (member),不过这是后话;我们以后也会渐渐接触类的概念。

而程序流程的控制与结果的打印则可以通过集成开发环境 (IDE) 不更改代码地通过打断点,并使用代码逐步执行的功能实现。对于 Python,一般来说还支持在程序运行过程中计算数据。

PyCharm 环境

若要了解说明文档的运行,Jupyter Notebook 的环境已经足够。但 pyxdh 库作为程序,如果打算作更为细致的阅读与调试,IDE 是必不可少的。

IDE 通常可以大大加速代码的阅读、调试与编写能力。有许多 IDE 支持 Python;作者通常使用 PyCharm。不同 IDE 之间的功能会大同小异,因此读者跟着自己的习惯就行。常用的其它 IDE 可以是 Spyder、Visual Studio、Eclipse。常用的带有 IDE 功能的文本编辑器是 Visual Studio Code。尽管带插件的 Vim 编辑器应当也可以当作 IDE 使用,但通常来说,体验会远差于 IDE。不带插件的 Vim 不推荐用来调试与编写 Python 程序。

任务

下面的任务专门针对 PyCharm。

  1. 对于学校用户,可以使用 PyCharm Professional。请参考 JetBrain 公司网页 Free individual licenses for students and faculty members。你也许同时可以注册一个 GitHub Student Developer Pack

  2. 对于 Windows 用户,PyCharm Professional 支持 WSL 下的 Python 解释器。请参考 PyCharm 下的设置、以及下述网页:Configure a remote interpreter using WSL

  3. (可选) 若需要对 IDE 的使用特性快速入门,可以尝试 JetBrain 的另一个产品 IntelliJ IDEA (Java 语言)。该软件在安装过程中提供了交互的 IDE 的新手入门插件。PyCharm 也属于 IntelliJ 系列;可以参考该入门以熟悉 IntelliJ 的一系列产品。

下面的任务可以看作使用 IDE 的练习,但需要对 Python 或其它编程语言的代码风格约定有一定的认识。当然,IDE 的功能不仅仅是检查语法错误和代码风格;其它的功能需要真正地编写或调试程序才会使用到。

  1. PEP 8 是一种通用与宽松的 Python 代码风格约定。良好的代码风格会帮助程序的书写与阅读。请通过 IDE 打开程序 pyxdh/DerivOnce/grad_scf.py,指出程序中不符合 PEP 8 规则的代码以及原因。pyxdh 库一般来说会避免 PEP 8 coding style violation,但少数情况下不可避免地使用不良风格的代码。

  2. (可选) 如果你没有找到 PEP 8 naming convention violation,请尝试到 Setting 重新打开命名规则检查。pyxdh 库的绝大部分变量名称不遵守 PEP 8 规则。

  3. (可选) (作者未尝试解决) PEP 8 尽管有很多限制,但现实中有更为严格的代码风格。若对 Python 稍有了解,则可以参考 Google Python Style Guide,理解一些规则的来龙去脉,并对程序的代码风格作一些判断。你可能会发现 pyxdh 的代码风格并不好。如果希望尝试一些自动化的初步的代码风格纠正工具,可以参考 black

需要的 Python 技巧

[2]:
import numpy as np
import matplotlib.pyplot as plt
import copy
from abc import ABC, abstractmethod

迭代器

范围

最经典的循环任务是从 1 到 100 相加得到 5050。这样一类任务通常由范围来实现:

[2]:
summation = 0
for i in range(1, 101):
    summation += i
summation
[2]:
5050

提示

Python 的范围使用类似于“尾后指针”的迭代方式;即对于 Python 的

for i in range(a, b):

而言,这等同于 C 语言的

for (int i = a; i < b; ++i)

或者如果 a, b 分别指代一个 C++ 可迭代容器 vector<T> vec 的首指针 vec.begin() 与尾指针 vec.end(),那么等同于

for (vector<T>::iterator i = vec.begin(); i < vector.end(); ++i)

由于 b = vector.end() 所指代的指针并不在列表 vec 中,它是列表最后一个元素之后的指针,因此也称为“尾后指针”。

对于 C++ 编程者而言,Python 的迭代方式可能比较直观;但对于 Fortran 编程者,这需要适应。

列表的浅复制问题

这里列举一些列表复制中会遇到的现象;或者跟一般地,浅复制 (shallow copy) 与深复制 (deep copy) 现象。更原理性的讨论需要参考其它资料。

这里使用到 Python 的内置库 copy

我们先考察下述的几个变量:

[3]:
lst = [[]] * 3
lst1 = lst
lst2 = copy.copy(lst)
lst3 = copy.deepcopy(lst)

其中,lst1lst 是完全等价的,除非重新定义 lst1;而剩下两个变量则略有区别。这从内置函数 id 可以看出:

[4]:
for l in [lst, lst1, lst2, lst3]:
    print(id(l))
139982160465800
139982160465800
139982160465672
139982160465416

即使不是所有列表的 id 返回值相同,但确实这些列表是相同的列表:

[5]:
for l in [lst, lst1, lst2, lst3]:
    print(l)
[[], [], []]
[[], [], []]
[[], [], []]
[[], [], []]
[6]:
bools = [[None] * 4 for _ in range(4)]  # construct empty list which is free from shallow copy problem
for i1, l1 in enumerate([lst, lst1, lst2, lst3]):
    for i2, l2 in enumerate([lst, lst1, lst2, lst3]):
        bools[i1][i2] = l1 == l2  # output matrix indicates identity between lists
bools
[6]:
[[True, True, True, True],
 [True, True, True, True],
 [True, True, True, True],
 [True, True, True, True]]

现在我们向 lst[1] 的字列表中添加元素:

[7]:
lst[1].append(1)

我们发现,除了 lst3 之外,其它的列表的三个字列表都变成了含有元素的列表。这与 lst[1].append(1) 这行程序所表达的意义完全不同:

[8]:
for l in [lst, lst1, lst2, lst3]:
    print(l)
[[1], [1], [1]]
[[1], [1], [1]]
[[1], [1], [1]]
[[], [], []]

如果我们向 lst3 也作类似的操作,我们发现 lst3 也产生了相似的效应,但并没有对其它的三个列表也产生影响:

[9]:
lst3[0].append(2)
for l in [lst, lst1, lst2, lst3]:
    print(l)
[[1], [1], [1]]
[[1], [1], [1]]
[[1], [1], [1]]
[[2], [2], [2]]

这意味着,copy.deepcopy 在大多数情况下可以将变量自身的数据与被复制变量的数据区分开,但自身变量之间的引用关系仍然保留。copy.copy 有时仍然引用了被复制变量的数据。这也能从调用 id 的输出中能看出:

[10]:
for l in [lst, lst1, lst2, lst3]:
    print([id(val) for val in l])
[139982160465544, 139982160465544, 139982160465544]
[139982160465544, 139982160465544, 139982160465544]
[139982160465544, 139982160465544, 139982160465544]
[139982160465864, 139982160465864, 139982160465864]

同时,统一列表中的三个元素相同,意味着 [[]] * 3 事实上是将三个完全相同而非同值的空列表赋予了列表中。因此,对任何其中一个子空列表添加值将会对其它两个子空列表也产生影响。

下面我们并不是打算添加或更改字列表,而是将列表的元素替换。如果我们执行下述代码:

[11]:
lst1[0] = 3
lst2[1] = 4
for l in [lst, lst1, lst2]:
    print(l)
[3, [1], [1]]
[3, [1], [1]]
[[1], 4, [1]]

我们注意到,lst1 作为 lst 的等价变量,两者都会受到元素更变的影响;但 lst2 作为 lst 的浅复制,则没有受到元素变更的影响。

我们在这里停止对列表复制的讨论。尽管浅复制通常可以减少数据在内存中的复制并提高存储效率,但也会引起一些不直观、不易调试的问题。列表复制这一小节的主要目的是提醒读者,在对包括列表、numpy 向量之内的其它 Python 对象进行操作时,需要小心可能出现的浅复制、引用等问题。同时,我们应当要知道,在 Python 中,没有任何变量是严格可以作为常量传值的;因此即使对于一些元素通过只设定 getting 函数而不设定 setting 函数进行保护,你也很可能会通过浅复制过程将这种被保护的变量的值改写。

任务 (1)

  1. 尽管我们刚才说 [[]] * 3 在增添元素 1 时会出现期望之外的 [[1], [1], [1]],但我们也使用了看起来非常类似的危险的代码 bools = [[None] * 4 for _ in range(4)];事实上,这段代码在对元素赋值过程中是安全的。请尝试执行下述两个代码块,并解释输出结果。

[12]:
bools1 = [[None] * 3 for _ in range(3)]
bools1[1][2] = 0
bools1  # Expected
[12]:
[[None, None, None], [None, None, 0], [None, None, None]]
[13]:
bools2 = [[None] * 3] * 3
bools2[1][2] = 0
bools2  # Unexpected
[13]:
[[None, None, 0], [None, None, 0], [None, None, 0]]

类迭代器

类迭代器是一种可以由编程者自定义的迭代器。尽管平时不会构造这种迭代器,但 pyxdh.Utilities.GridIterator 使用了这种迭代器。GridIterator 类会用于 DFT 格点积分时产生原子轨道格点的过程中,因此会经常使用到。

类迭代器从使用上与列表迭代器并无二致,但对其性质与原理的理解总是有帮助的。类迭代器可以参考 RUNOOB Python3 迭代器与生成器

特殊的函数

函数作为返回值

Python 中的函数,通常来说可以使用 def 来定义;譬如我们定义函数输入 \(x\),输出 \(\sin(x)\),那么这个函数可以借助于 numpy,定义为

[14]:
def sin(x):
    return np.sin(x)

现在我们考虑下述问题:我们希望构建下述泛函并绘制图像

\[F[f(x)] = \sin(f(x))\]

其中,\(f\) 是函数。如果 \(f(x) = x^2 + 2x\),那么这个问题就化为绘制 \(\sin(x^2 + 2x)\) 图像。这可以使用代码表示如下:

[15]:
x_list = np.arange(0, 4, 0.01)  # x component of plot points

def f(x):
    return x ** 2 + 2 * x

plt.plot(x_list, sin(f(x_list)))
[15]:
[<matplotlib.lines.Line2D at 0x7f50229b29b0>]
_images/intro_intro_python_38_1.png

我们换一种思路。刚才,我们是通过泛函 \(F[f] = \sin(f)\) 与函数 \(f(x) = x^2 + 2 x\) 构成新的关于 \(x\) 的函数 \(F[f](x)\);但我们可以不必真的构建新的函数,只需要分别构建 \(F[f]\)\(f(x)\) 即可。\(f(x)\) 我们已经通过 f 构建,它的传入、传出都是值;但泛函 \(F[f]\) 事实上是传入、传出的是函数。因此,我们定义下述的泛函 F

[16]:
def F(f):
    def r(x):
        return np.sin(f(x))
    return r

它看起来有些绕;而其调用似乎更不直观。我们通过下述的代码绘制图像:

[17]:
plt.plot(x_list, F(f)(x_list))
[17]:
[<matplotlib.lines.Line2D at 0x7f50228e1198>]
_images/intro_intro_python_42_1.png

之所以 F(f)(x_list) 可以执行,是因为 F(f) 实际上是一个函数。

从执行过程的角度上,

  • F(f)([x1, x2]) 类似于将函数 F[f] 直接映射到列表 [x1, x2],先得到 F(f)(x1),随后得到 F(f)(x2),最终将两者拼起来。

  • sin(f([x1, x2])) 类似于先将 f 映射到 [x1, x2] 得到 sin([f(x1), f(x2)]),再最终将 sin 映射到 [f(x1), f(x2)],得到 [sin(f(x1)), sin(f(x2))]

尽管从这个例子来看,两种代码除了括号的位置不太一样之外并无区别,计算量也没有真正的区别;但如果我们现在将绘图程序打包成以下函数:

[18]:
def plot_func(f):
    x_list = np.arange(0, 4, 0.01)
    plt.plot(x_list, f(x_list))

其意义是,绘图程序只通过获得 \(y = f(x)\) 来获得纵坐标信息。这时,函数的输入 \(f\) 就必须是一个函数,而 sin(f(x)) 这种调用方式并不能提炼出一个函数来;反之,F(f) 则是一个函数。

这种通过输入函数或其它参量,返回函数的情形不会遇到太多;但在计算 CP-HF 方程时,由于对于问题 \(\textbf{A} \boldsymbol{x} = \boldsymbol{b}\)\(\textbf{A}\) 获得的代价太大;因此其中一个输入的参数会是 \(\texttt{Ax}: \boldsymbol{x} \mapsto \textbf{A} \boldsymbol{x}\)。对于这个泛函绘图的问题有所了解的话,CP-HF 方程的代码就会容易理解一些。

只实例化一次的属性

这是关于类的属性 (property) 的讨论。在 pyxdh 中,程序大量使用到属性。一般来说,属性的作用是

  • 方便 [g/s]etting 函数调用,以及可能的代码重构;

  • 可以设定一些变量不可设定,只可访问;

  • 对于 Python 以外的语言,还能对 getting 与 setting 函数采取不同的可访问性。

利用这些特性,pyxdh 中的许多矩阵确实是只可访问的。除此之外,这些矩阵还有一个特性,即只在第一次被访问到时会执行计算;但代价是计算的结果储存到内存。我们用一个非常简单的例子解释。

[19]:
class A:

    def __init__(self):
        self._var = NotImplemented

    @property
    def var(self):
        if self._var is NotImplemented:
            self._var = self._get_var()
        return self._var

    def _get_var(self):
        print("getter aquired!")
        return np.array([0, 1])

现在,我们实例化这个类,并查看弱保护变量 _var 的值:

[20]:
a = A()
a._var
[20]:
NotImplemented

但如果我们调用了属性 var,那么弱保护变量 _var 也同时被赋值,并经历了一次 _get_var 函数的调用:

[21]:
a.var
getter aquired!
[21]:
array([0, 1])
[22]:
a._var
[22]:
array([0, 1])

如果我们以后再调用属性 var_get_var 就永远不会再执行 (除非更改 _var 的值为 NotImplemented)。

[23]:
a.var
[23]:
array([0, 1])

之所以采用这种稍繁琐的方式定义属性,一方面是希望保留编程上的便利,避免程序计算其他矩阵的时候还要检查哪些作为前置条件的矩阵没有被计算过;另一方面,由于计算矩阵都有着不小的代价,因此不希望让矩阵多次计算。

但这里要指出,由于 NumPy 经常使用引用传值 (这种说法未必正确,可能更恰当的用词是 视图);因此,下述的代码仍然可以改变看起来是保护变量的 var。这种代码疏忽很难被发现,因此编写程序时要格外注意。

[24]:
a.var[0] = 2
a.var
[24]:
array([2, 1])

菱形继承

pyxdh 中使用了大量菱形继承。这一般被认为不是正常的编程思路,但这个项目仍然使用之。如果只是希望使用代码或者阅读文档,那么菱形继承的理解将不那么重要;但如果打算理解 pyxdh 的工作原理,这一段可能是有帮助的。下面的代码是一个典型的菱形继承,同时也表明一个菱形继承的缺陷:

[1]:
class A:
    def val(self):
        return "A printed"

class B(A):
    def val(self):
        return "B printed"

class C(A):
    def val(self):
        return "C printed"

class D(C, B):
    pass

这种继承关系可以图示表示为

  A
 / \
B   C
 \ /
  D

但既然 D 同时继承了 BC,那么其 val 函数是否也同时继承两者?并不是如此。

[2]:
d = D()
d.val()
[2]:
'C printed'

在 Python 中,这种多继承发生定义冲突时,解决方案不是未定义的,而是通过 MRO 或称 C3 Linearization 的方式解决的;这并非是深度优先或广度优先的类搜索,因此较为复杂;这里不进行太深入的讨论。但是由于 pyxdh 项目中大量使用这种菱形继承的思路,因此这里有必要对其有基本的印象,并且了解这种菱形继承所可能具有的严重缺陷或陷阱。我们通过下面一个任务来粗略地了解其中一种缺陷。

任务 (2)

  1. 下面一个例子从编程上不是一个好的例子,但也许能帮助你了解菱形继承过程中的函数重载方式。现在我们考虑下述具有构造函数重载的菱形继承类。

    • 看起来,类 C 的构造函数非常不合理,因为它的父类 A 是无参的构造函数,但 C 却调用了父类的有参构造函数。确实,实例化 C 类的 c = C("C") 是不成功的。

    • 但下述的代码对于 D 的实例化 d = D("D") 是成功的。这从“直觉”看上去确实很不合理,因为 D 调用了 C 的构造函数,而 C 调用了 A 的构造函数;后一个过程看起来就是实例化 C 类,应当会与前面的 C 类的实例化一样不成功。

    请尝试使用你能想到的方法,或者使用插入打印语句、或者 IDE 断点、或者反射 (Reflection),指出上述“直觉”是错误的。作者是通过插入打印语句大致确定原因的。

    正因为 Python 的菱形继承具有这种反直觉的问题,因此作者在 pyxdh 中,大多数与梯度有关的类均使用有且仅有一个字典参数 config 来初始化类,因为字典通过不同键值可以包含很多参数;但被传参时,只需要一个字典即可,不需要额外指定多个参数或可选参数。

[27]:
class A:
    def __init__(self):
        self.var = NotImplemented

class B(A):
    def __init__(self, var):
        super(B, self).__init__()
        self.var = var

class C(A):
    def __init__(self, var):
        super(C, self).__init__(var)

class D(C, B):
    def __init__(self, var):
        super(D, self).__init__(var)

d = D("D")
print(d.var)

c = C("C")
D
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
<ipython-input-27-303e885b2862> in <module>()
     19 print(d.var)
     20
---> 21 c = C("C")

<ipython-input-27-303e885b2862> in __init__(self, var)
     10 class C(A):
     11     def __init__(self, var):
---> 12         super(C, self).__init__(var)
     13
     14 class D(C, B):

TypeError: __init__() takes 1 positional argument but 2 were given

从上面一个任务,我们应当知道 Python 中,多个父类的继承与父类的顺序是有关的。

在完成上面一个问题的前提下,我们考虑一个更为现实但稍更复杂的问题。我们考察这样一个类:

[28]:
from pyxdh.DerivOnce import GradXDH

这是 xDH 型泛函的一阶原子核坐标梯度的类。我们回顾到从编程序的角度上,xDH 型泛函可以看作是融合了非自洽 DFT 与基于自洽 DFT 的 MP2 方法 (但从原理上不应该这么粗浅地理解);而 MP2 与非自洽 DFT 都是从自洽场的 DFT 方法衍生而来。同时,我们希望在计算核坐标梯度与计算偶极矩时,大多数代码能够重复用上;因此我们将偶极矩与核坐标梯度具有共性的部分整合为抽象类,而剩余的特化部分就由特化的类实现。对于 GradXDH 类,我们指出下述包含继承顺序信息的完整的继承关系:

  • GradXDH 继承于 xDH 型泛函一阶梯度抽象类 DerivOnceXDH、MP2 核坐标梯度类 GradMP2 与非自洽泛函核坐标梯度类 GradNCDFT

  • DerivOnceXDH 继承于 MP2 一阶梯度抽象类 DerivOnceMP2 与非自洽泛函一阶梯度抽象类 DerivOnceNCDFT

  • GradMP2 继承于 DerivOnceMP2 与 SCF 核坐标梯度类 GradSCF

  • GradNCDFT 继承于 DerivOnceNCDFT 与 SCF 核坐标梯度类 GradSCF

  • DerivOnceMP2DerivOnceNCDFT 都继承于 SCF 一阶梯度抽象类 DerivOnceSCF

  • GradSCF 也继承于 SCF 一阶梯度抽象类 DerivOnceSCF

  • 所有 DerivOnce* 都代表抽象类;它们都继承于 Python 自带的 abc.ABC 类。

其它的类,包括偶极矩 DipoleXDH、Hessian HessXDH、极化率 PolarXDH 类的继承关系与上述描述一致。对类的继承顺序有所了解,会对 Hacking 源代码,寻找函数的重载关系有所帮助。

任务 (3)

  1. 请画出 GradXDH 类的继承关系图。与 GradXDH 继承图有关的类一共应当是九个。这应当是一个带帽四方棱柱型的继承关系,且“帽”是 abc.ABC 类。

  2. (可选) 请在 PyCharm (或其它可能的 IDE) 中,找到选项绘制 pyxdh.DerivOnce.GradXDH 类的 Python class diagram。通常这类图是平面的 UML 图;请与你刚才画出的类继承关系图作对比。

  3. (可选) 假定上述类均有构造函数,并且使用 super(Class, self).__init__(*args, **kwargs) 的方式初始化类。请确定在实例化 GradXDH 时,上述九个类的构造函数调用顺序。请注意构造函数的调用顺序会因多重继承中指定的父类顺序,譬如 D(B, C)D(C, B) 的不同而不同。

参考任务解答

任务 (2)

事实上,尽管在语句定义中,我们让 D 依次是 CB 的子类;但实际上,经过 MRO 处理后的继承关系仍然是一一继承的,关系如下:

  A
 /
B - C
   /
  D

注意上述的继承关系仅仅针对 D 类成立。对于类 BC,则仍然是普通地继承类 A。因此我们不可以通过上一行的继承关系来判断 CA 的继承关系。

  A    A
 /      \
B        C

任务 (3)

任务 (3.2) 可选

assets/UML_GradXDH.png

任务 (3.3) 可选
[4]:
class DerivOnceSCF(ABC):
    def __init__(self, config):
        print("Before init DerivOnceSCF")
        print("After init DerivOnceSCF")

class DerivOnceNCDFT(DerivOnceSCF, ABC):
    def __init__(self, config):
        print("Before init DerivOnceNCDFT")
        super(DerivOnceNCDFT, self).__init__(config)
        print("After init DerivOnceNCDFT")

class DerivOnceMP2(DerivOnceSCF, ABC):
    def __init__(self, config):
        print("Before init DerivOnceMP2")
        super(DerivOnceMP2, self).__init__(config)
        print("After init DerivOnceMP2")

class DerivOnceXDH(DerivOnceMP2, DerivOnceNCDFT, ABC):
    def __init__(self, config):
        print("Before init DerivOnceXDH")
        super(DerivOnceXDH, self).__init__(config)
        print("After init DerivOnceXDH")

class GradSCF(DerivOnceSCF):
    def __init__(self, config):
        print("Before init GradSCF")
        super(GradSCF, self).__init__(config)
        print("After init GradSCF")

class GradNCDFT(DerivOnceNCDFT, GradSCF):
    def __init__(self, config):
        print("Before init GradNCDFT")
        super(GradNCDFT, self).__init__(config)
        print("After init GradNCDFT")

class GradMP2(DerivOnceMP2, GradSCF):
    def __init__(self, config):
        print("Before init GradMP2")
        super(GradMP2, self).__init__(config)
        print("After init GradMP2")

class GradXDH(DerivOnceXDH, GradMP2, GradNCDFT):
    def __init__(self, config):
        print("Before init GradXDH")
        super(GradXDH, self).__init__(config)
        print("After init GradXDH")
[5]:
GradXDH("config")
Before init GradXDH
Before init DerivOnceXDH
Before init GradMP2
Before init DerivOnceMP2
Before init GradNCDFT
Before init DerivOnceNCDFT
Before init GradSCF
Before init DerivOnceSCF
After init DerivOnceSCF
After init GradSCF
After init DerivOnceNCDFT
After init GradNCDFT
After init DerivOnceMP2
After init GradMP2
After init DerivOnceXDH
After init GradXDH
[5]:
<__main__.GradXDH at 0x7f68460b7470>

需要的 NumPy 技巧:初步

这一节我们会回顾一些 numpy 的相关问题。整个 pyxdh 的电子积分、基函数等问题都建立在 PySCF 库,但其余的量化方法都建立在 numpy 中;对 numpy 的熟悉将是至关重要的。

一般认为,numpy 具有正常的效率与并行能力;但处理特别的计算问题时,numpy 并不很鲁棒。对于多节点计算与 GPU 计算,需要 numpy 以外的环境。

在以后,我们会使用修改版的 Einstein Summation Convention,即无上下标区分的张量角标,以简化公式表达,与 np.einsum 函数形成稳定的关联。

numpy 具有非常详尽的 API 文档;在 PyCharm 中,numpy 的 python 部分函数源码也可以通过 Ctrl + 鼠标左键得到,其文档 (docstring) 可以通过 Ctrl + Q 获得。

[1]:
import numpy as np
import scipy
import scipy.linalg

矩阵与张量定义

numpy 的最基本单元是 ndarray 类;一般来说,我们的矩阵与张量都是这个类的实例 (instance)。从列表 lst 通过 np.array 可以生成一个矩阵 mat

[2]:
lst = [
    [ 0,  1,  2],
    [10, 11, 12],
    [20, 21, 22],
]
mat = np.array(lst)
mat
[2]:
array([[ 0,  1,  2],
       [10, 11, 12],
       [20, 21, 22]])

矩阵的最基本信息包括矩阵维度、元素类型、占用内存空间大小等。这些可以从成员直接查看:

[3]:
print(mat.dtype)
print(mat.shape)
print(mat.size)
print(mat.nbytes)
int64
(3, 3)
9
72

与 Fortran 不同,numpy 沿用 Python (包括绝大多数其它语言) 的 0 索引方式:

[4]:
mat[1]
[4]:
array([10, 11, 12])

但与 Fortran 相同的是,通常可以使用冒号取其中的某一列 (这是 C 程序所不便利之处):

[5]:
mat[:, 1]
[5]:
array([ 1, 11, 21])

如果要生成随机但维度确定的矩阵,则可以使用 np.random 中的函数。这里不再详述。

任务 (1)

  1. 生成一个不等维度的三维列表,并将其化为三维张量.观察三维张量的属性、并了解它是如何索引并输出的.

    在 Hessian 任务中,我们需要了解最高八维张量的计算,以及最高四维张量的输出.对任意维度张量的操控与调试是后面笔记中所经常使用的能力.

元素操作

numpy 中的许多操作,包括张量与数 (scaler)、算符 (operation) 的操作,是元素操作 (elementwise manuplation)。举例来说,对于矩阵与数的加法

\[\mathbf{A} = \mathbf{M} + 5\]

这就等价于

\[A_{ij} = M_{ij} + 5\]
[6]:
A = mat + 5
A
[6]:
array([[ 5,  6,  7],
       [15, 16, 17],
       [25, 26, 27]])

幂次计算则可以表示为

\[B_{ij} = M_{ij}^3\]
[7]:
B = mat ** 3
B
[7]:
array([[    0,     1,     8],
       [ 1000,  1331,  1728],
       [ 8000,  9261, 10648]])

对于指数计算也类似。譬如下述的的过程表示

\[C_{ij} = \exp (- M_{ij} / 5)\]
[8]:
C = np.exp(- mat / 5)
C
[8]:
array([[1.        , 0.81873075, 0.67032005],
       [0.13533528, 0.11080316, 0.09071795],
       [0.01831564, 0.01499558, 0.01227734]])

任务 (2)

  1. (可选) 上述 \(M_{ij}^3\) 并不是通常矩阵运算中的 \(\mathbf{M}^3\)。请指出如何计算 \(\mathbf{M}^3\)

  2. (可选) 上述 \(\exp(- M_{ij} / 5)\) 并不是通常矩阵运算中的 \(\exp(- \mathbf{M} / 5)\)。请指出如何计算 \(\exp(- \mathbf{M} / 5)\)

矩阵乘积

在量化计算中,最基本的操作是矩阵乘积。矩阵乘积在 numpy 中的实现方法至少有三种;在这里,我们会拿一个简单的例子说明。

[9]:
A = np.array([[1, 2, 3], [11, 12, 13]])                           # A.shape = (2, 3)
B = np.array([[1, 2, 3, 4], [11, 12, 13, 14], [21, 22, 23, 24]])  # B.shape = (3, 4)

我们知道,(2, 3) 维矩阵与 (3, 4) 维矩阵的乘积是 (2, 4) 维。这两个矩阵的乘积可以用 Einstein Convention 表示为

\[C_{ij} = A_{ik} B_{kj}\]

矩阵乘积的最标准写法是使用 @ 或等价地 np.matmul;另两种写法是使用 np.dot,以及使用 np.einsum。后者则更直观地体现角标变化。

[10]:
C_byat = A @ B
C_bydot = A.dot(B)  # equivalent to np.dot(A, B)
C_byein = np.einsum("ik, kj -> ij", A, B)
C_bydot
[10]:
array([[ 86,  92,  98, 104],
       [416, 452, 488, 524]])

我们可以使用 np.allclose 来查看矩阵之间是否近乎相等:

[11]:
print(np.allclose(C_byat, C_bydot))
print(np.allclose(C_byein, C_bydot))
True
True

任务 (3)

  1. 使用 np.dot 的写法,写一段三矩阵连续相乘的代码;并指出在三矩阵相乘时,使用 A.dot(B)np.dot(A, B) 的便利之处.在量化计算中,我们经常会遇到原子轨道 (AO) 矩阵转换到分子轨道 (MO) 矩阵的操作,这是一个三矩阵的计算.

  2. 使用 @np.einsum 写一段三矩阵连续相乘的代码,思考哪种方式更适合自己的公式代码化的思路.我相信不同的人有不同的见解.注意 @ 需要用到两次,但 np.einsum 应该只能用到一次,即不应该出现如下代码:

    np.einsum("ik, kj -> ij", A, np.einsum("kl, lj -> kj", B, C))
    
  3. (可选) 将

    np.einsum("ik, kj -> ij", A, B)
    

    更改为

    np.einsum("ik, kj", A, B)
    

    并查看效果.尽管我自己不太推荐这种做法,但这是 Einstein Convention 中“角标出现两次或以上时就对角标求和”的具体例子.

矩阵乘积效率评估

下面我们简单地讨论上述三种矩阵乘法的效率。在 Jupyter Notebook 中,可以使用 timeit magic command (API 文档) 进行大致的效率评估。

[12]:
%%timeit -r 7 -n 10000
A @ B
1.26 µs ± 90 ns per loop (mean ± std. dev. of 7 runs, 10000 loops each)
[13]:
%%timeit -r 7 -n 10000
A.dot(B)
1.12 µs ± 62.6 ns per loop (mean ± std. dev. of 7 runs, 10000 loops each)
[14]:
%%timeit -r 7 -n 10000
np.einsum("ik, kj -> ij", A, B)
1.91 µs ± 160 ns per loop (mean ± std. dev. of 7 runs, 10000 loops each)

我们能发现,在效率上,np.dot 通常是最快的,而 np.einsum 通常是最慢的。但这未必是确定的;在矩阵较大时,np.matmul 会稍快一些,这可以留待读者测试。

尽管一般来说,np.einsum 的效率较低,但在处理巨大的或量化张量时,np.einsum 通常不会给出太差的效率;甚至有时自己使用 np.matmul 的效率不见得比 np.einsum 高。

任务 (4)

  1. (可选) 对三个 (1000, 1000) 矩阵相乘的情况作效率测评。

矩阵与向量运算:内积运算

类似于普通的矩阵乘法,矩阵与向量也有普通的矩阵乘法或称内积运算。该过程即

\[b_i = A_{ij} x_j\]
[15]:
A = np.array([[1, 2, 3], [11, 12, 13]])  # A.shape = (2, 3)
x = np.array([5, 6, 7])                  # x.shape = (3, )

上述的三种矩阵乘法操作在矩阵与向量计算中仍然成立:

[16]:
b_byat = A @ x
b_bydot = A.dot(x)  # equivalent to np.dot(A, x)
b_byein = np.einsum("ij, j -> i", A, x)
b_bydot
[16]:
array([ 38, 218])
[17]:
print(np.allclose(b_byat, b_bydot))
print(np.allclose(b_byein, b_bydot))
True
True

任务 (5)

  1. 我们现在已经了解矩阵之间、矩阵与向量的乘法关系的代码编写了;对于三维张量与一维向量或二维矩阵之间的乘法运算,能否给出类似的代码?

  2. (可选) 若现在是三维张量与三维张量之间的乘法运算,np.dotnp.matmul 是如何工作的?

张量转置

处理量化问题时经常会遇到张量转置的问题。张量转置的弱化问题是矩阵转置;这几乎是显然的。在 numpy 中,通常可以用 np.transpose 或者直接在矩阵后使用 T 方法获得矩阵:

[18]:
lst = [
    [0, 1, 2],
    [10, 11, 12]
]
mat = np.array(lst)
mat
[18]:
array([[ 0,  1,  2],
       [10, 11, 12]])
[19]:
mat.T
[19]:
array([[ 0, 10],
       [ 1, 11],
       [ 2, 12]])
[20]:
print(np.allclose(np.transpose(mat), mat.T))
print(np.allclose(mat.transpose(), mat.T))
True
True

但对于一般的张量转置问题,就不是那么显然了。除了 np.transpose 或者 T 方法外, np.einsum 也能进行转置,其优势仍然是直观,但效率偏低,比较适合作为验证工具;np.swapaxes 也可以解决张量转置工具,但只适合于对换两个角标。

我们拿一个不等维度的三维张量进行说明。转置的目标是

\[R_{ijk} \rightarrow R_{ikj}\]

待转置张量是 R

[21]:
R = np.arange(24).reshape(2, 3, 4)
R
[21]:
array([[[ 0,  1,  2,  3],
        [ 4,  5,  6,  7],
        [ 8,  9, 10, 11]],

       [[12, 13, 14, 15],
        [16, 17, 18, 19],
        [20, 21, 22, 23]]])

成功转置的张量是 R_T

[22]:
R_T = np.einsum("ijk -> ikj", R)
R_T
[22]:
array([[[ 0,  4,  8],
        [ 1,  5,  9],
        [ 2,  6, 10],
        [ 3,  7, 11]],

       [[12, 16, 20],
        [13, 17, 21],
        [14, 18, 22],
        [15, 19, 23]]])
[23]:
print(np.allclose(R.transpose(0, 2, 1), R_T))
print(np.allclose(R.swapaxes(1, 2), R_T))
True
True

任务 (6)

  1. 现在考虑涉及三角标的轮换的转置.如果现在的转置目标是 \(R_{ijk} \rightarrow R_{jki}\),是应该使用 R.transpose(1, 2, 0),还是 R.transpose(2, 0, 1)?请用 np.einsum("ijk -> jki", R),或者用 shape 查看转之后张量的形状信息辅助验证.

参考任务解答

任务 (1)

任务 (1.1) 可选

尽管原题目是希望通过 python 三维列表生成 numpy 三维张量,但我们也可以使用偷懒一些的方法。使用 np.arange 可以生成类似于 python 的 range 迭代器,但 np.arange 最终返回的是以 ndarray 形式的迭代器,即一维向量:

[24]:
tensor = np.arange(24)
tensor
[24]:
array([ 0,  1,  2,  3,  4,  5,  6,  7,  8,  9, 10, 11, 12, 13, 14, 15, 16,
       17, 18, 19, 20, 21, 22, 23])

随后我们将上述向量重塑为 (2, 3, 4) 的三维张量的形状:

[25]:
tensor.shape = (2, 3, 4)
tensor
[25]:
array([[[ 0,  1,  2,  3],
        [ 4,  5,  6,  7],
        [ 8,  9, 10, 11]],

       [[12, 13, 14, 15],
        [16, 17, 18, 19],
        [20, 21, 22, 23]]])

以此为出发,可以了解 ndarray 三维张量的索引方式。应当能发现 numpy 的索引基本与 C 的索引方式等同。

任务 (2)

任务 (2.1) 可选

矩阵计算中,\(\mathbf{B}' = \mathbf{M}^3\) 事实上用 Einstein Notation,应当表示为

\[B'_{ij} = M_{ik} M_{kl} M_{lj}\]

即三次连续的矩阵乘法。因此,

[26]:
lst = [
    [ 0,  1,  2],
    [10, 11, 12],
    [20, 21, 22],
]
mat = np.array(lst)
[27]:
B_prime = mat @ mat @ mat
B_prime
[27]:
array([[ 1650,  1809,  1968],
       [12150, 13299, 14448],
       [22650, 24789, 26928]])

np.linalg.matrix_power (API 文档) 可以实现整数的矩阵幂次的运算:

[28]:
np.linalg.matrix_power(mat, 3)
[28]:
array([[ 1650,  1809,  1968],
       [12150, 13299, 14448],
       [22650, 24789, 26928]])

scipy.linalg.fractional_matrix_power (API 文档) 功能更强大,可以实现非整数的矩阵幂次运算 (但这里作为范例仍然使用整数):

[29]:
scipy.linalg.fractional_matrix_power(mat, 3.)
[29]:
array([[ 1650,  1809,  1968],
       [12150, 13299, 14448],
       [22650, 24789, 26928]])
任务 (2.2) 可选

我们先给出矩阵计算下 \(\mathbf{C}' = \exp(- \mathbf{M} / 5)\) 的结果。这通过 `scipy.linalg.fractional_matrix_power <https://docs.scipy.org/doc/scipy/reference/generated/scipy.linalg.expm.html>`__ 实现:

[30]:
C_prime = scipy.linalg.expm(- mat / 5)
C_prime
[30]:
array([[ 1.28820913,  0.07655702, -0.1350951 ],
       [-0.27198272,  0.68929065, -0.34943599],
       [-0.83217457, -0.69797572,  0.43622312]])

下面我们使用更容易理解的语言解释矩阵计算的指数运算。指数运算从定义上是 Taylor 展开;我们这里也使用这一事实:

\[\mathbf{C}' = \sum_{n = 1}^{\infty} \frac{(- \mathbf{M} / 5)^n}{n!}\]

下面的代码就是将上述 Taylor 展开截断到 \(n = 50\) 所给出;我们应当能看到 C_primeC_prime_truncated 几乎相同:

[31]:
C_prime_truncated = np.zeros_like(mat, dtype=np.float64)
for n in range(0, 50):
    C_prime_truncated += np.linalg.matrix_power(- mat / 5., n) * (1. / np.math.factorial(n))
C_prime_truncated
[31]:
array([[ 1.28820913,  0.07655702, -0.1350951 ],
       [-0.27198272,  0.68929065, -0.34943599],
       [-0.83217457, -0.69797572,  0.43622312]])

不过对于这个例子,在 Taylor 展开截断较少,譬如 \(n = 10\) 附近时,还无法看出收敛的迹象。

任务 (3)

这一部分任务中,我们指定 A, B, C 如下。矩阵计算的目标是

\[D_{ij} = A_{ik} B_{kl} C_{lj}\]
[32]:
np.random.seed(0)
A = np.random.randn(2, 3)
B = np.random.randn(3, 4)
C = np.random.randn(4, 5)
任务 (3.1)

\(D_{ij}\) 的表达式为 (维度为 (2, 5))

[33]:
D = A.dot(B).dot(C)
D
[33]:
array([[ 1.49830283,  1.73384943, -6.13304366,  2.74972964,  1.03516199],
       [-3.99086154,  2.1109627 , -7.82833666,  2.93379787,  3.17459153]])

如果要使用 np.dot,代码会变得不太容易理解,但仍然能得到正确结果:

[34]:
np.dot(A, np.dot(B, C))
[34]:
array([[ 1.49830283,  1.73384943, -6.13304366,  2.74972964,  1.03516199],
       [-3.99086154,  2.1109627 , -7.82833666,  2.93379787,  3.17459153]])
任务 (3.2)

使用 @ 的代码如下:

[35]:
A @ B @ C
[35]:
array([[ 1.49830283,  1.73384943, -6.13304366,  2.74972964,  1.03516199],
       [-3.99086154,  2.1109627 , -7.82833666,  2.93379787,  3.17459153]])

使用 @ 的代码显然是所有矩阵乘法中最短小的写法;而 np.einsum 可以认为是最为省事的做法,因为它能完整地还原公式中张量的乘法过程:

[36]:
np.einsum("ik, kl, lj -> ij", A, B, C)
[36]:
array([[ 1.49830283,  1.73384943, -6.13304366,  2.74972964,  1.03516199],
       [-3.99086154,  2.1109627 , -7.82833666,  2.93379787,  3.17459153]])

尽管显然下述的代码也能给出正确的结果:

[37]:
np.einsum("ik, kj -> ij", A, np.einsum("kl, lj -> kj", B, C))
[37]:
array([[ 1.49830283,  1.73384943, -6.13304366,  2.74972964,  1.03516199],
       [-3.99086154,  2.1109627 , -7.82833666,  2.93379787,  3.17459153]])

但上面这种对 np.einsum 的使用方法反而会将问题复杂化。

任务 (3.3)

我们不妨尝试 \(D_{ij} = A_{ik} B_{kl} C_{lj}\)

[38]:
np.einsum("ik, kl, lj", A, B, C)
[38]:
array([[ 1.49830283,  1.73384943, -6.13304366,  2.74972964,  1.03516199],
       [-3.99086154,  2.1109627 , -7.82833666,  2.93379787,  3.17459153]])

但假设,现在遇到的张量乘法的情形是 \(D_{ji} = A_{ik} B_{kl} C_{lj}\),那么上述的代码不能一次性地给出正确的结果,而一定要写为

[39]:
np.einsum("ik, kl, lj -> ji", A, B, C)
[39]:
array([[ 1.49830283, -3.99086154],
       [ 1.73384943,  2.1109627 ],
       [-6.13304366, -7.82833666],
       [ 2.74972964,  2.93379787],
       [ 1.03516199,  3.17459153]])

同时,如果现在的问题是

\[D_{ik} = A_{ik} B_{kl} C_{lj}\]

或者使用普通的求和记号,即

\[D_{ik} = \sum_{jl} A_{ik} B_{kl} C_{lj}\]

那么这种情况用完整的张量缩并字符串会比较方便:

[40]:
np.einsum("ik, kl, lj -> ik", A, B, C)
[40]:
array([[-2.91662457,  0.68839088,  3.11223391],
       [-3.70501713,  3.21276204, -3.10759101]])

任务 (4)

任务 (4.1) 可选
[41]:
A = np.random.randn(1000, 1000)
B = np.random.randn(1000, 1000)
C = np.random.randn(1000, 1000)
[42]:
%%timeit -r 7 -n 10
A @ B @ C
54.2 ms ± 3.08 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)
[43]:
%%timeit -r 7 -n 10
A.dot(B).dot(C)
57.2 ms ± 2.99 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)
[44]:
%%timeit -r 7 -n 10
np.einsum("ik, kl, lj -> ij", A, B, C, optimize=True)
60.8 ms ± 4.25 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)

可以看出,对于较大的矩阵操作,np.einsum 的效率并没有比其它两种乘法更差。因此,在实际实践中,即使微秒级的运算中 np.einsum 的效率非常低,但真正影响量化计算的微秒、秒级运算中,np.einsum 完全是可以使用的。

需要注意,np.einsumoptimize 选项需要设定为 True 或其它优化方式。关于这部分,我们将在下一节文档中作更详细的描述。

任务 (5)

任务 (5.1)

现在假定 A 为维度 (2, 3, 5) 的张量,B 为 (5, 4) 的张量,那么对于下述问题,

\[C_{ijk} = A_{ijl} B_{lk}\]

可以清楚验证的方式是 np.einsum

[45]:
np.random.seed(0)
A = np.random.randint(50, size=(2, 3, 5))
B = np.random.randint(50, size=(5, 4))
[46]:
C = np.einsum("ijl, lk -> ijk", A, B)
C
[46]:
array([[[2494, 3229, 2399, 2356],
        [3049, 2791, 1724, 3636],
        [2151, 1825, 1122, 2712]],

       [[4248, 2183, 3673, 4987],
        [2861, 2394, 1875, 3623],
        [1482, 1038,  980, 1872]]])
[47]:
print(np.allclose(A @ B, C))
print(np.allclose(A.dot(B), C))
True
True
任务 (5.2) 可选

现在假定 A 为维度 (2, 3, 5) 的张量,B 为维度 (2, 5, 3) 的张量:

[48]:
np.random.seed(0)
A = np.random.randint(50, size=(2, 3, 5))
B = np.random.randint(50, size=(2, 5, 3))

对于 A @ B,其形式为

\[C_{pij} = A_{pik} B_{pkj}\]

结果是 (2, 3, 3) 维度的张量:

[49]:
np.allclose(A @ B, np.einsum("pik, pkj -> pij", A, B))
[49]:
True

而对于 A.dot(B),其形式为

\[C_{piqj} = A_{pik} B_{qkj}\]

结果是 (2, 3, 2, 3) 维度的张量:

[50]:
np.allclose(A.dot(B), np.einsum("pik, qkj -> piqj", A, B))
[50]:
True

任务 (6)

任务 (6.1)

待转置张量是 R,转置张量则是 R_T

[51]:
R = np.arange(24).reshape(2, 3, 4)
R_T = np.einsum("ijk -> jki", R)
R_T.shape
[51]:
(3, 4, 2)

对于题目中给出的两种转置,

[52]:
print(R.transpose(1, 2, 0).shape)
print(R.transpose(2, 0, 1).shape)
(3, 4, 2)
(4, 2, 3)

我们能注意到,R.transpose(1, 2, 0) 应当是正确的转置。

[53]:
np.allclose(R.transpose(1, 2, 0), R_T)
[53]:
True

需要的 NumPy 技巧:进阶

上一节我们回顾了了一部分 numpy 技巧,它们可以解决通常的量化问题或者其它应用问题。这一节我们回顾一些 numpy 中不太常用的,但在 pyxdh 包中使用到的一些技巧。

[1]:
import numpy as np

ndarray 数据储存

在进行下面的话题前,我们有必要简单了解 ndarray 储存方式,以及使用 np.base 判断 ndarray 之间是否共享相同的数据内存空间。这有助于我们判断是否修改了张量的数值,或者判断是否复制了多余且无用的内存数据。这部分的描述也可以参考 一份公开书籍

ndarray 类型可以看成由底层数据与修饰信息构成;底层数据就是一个一个数值,而修饰信息则是由维度、拆分方式等构成。一般来说,修饰信息的内存占用总远小于底层数据。

在 numpy 中,除非必要,否则大多数时候不会真正地复制底层数据,只复制修饰信息。我们可以使用下面的 get_memloc 函数来获取 ndarray 底层数据的头指针在内存的位置信息:

[2]:
def get_base(array):
    while isinstance(array.base, np.ndarray):
        array = array.base
    return array

def get_memloc(array):
    return get_base(array).__array_interface__['data'][0]
[3]:
R = np.arange(24).reshape((2, 3, 4))
get_base(R)
[3]:
array([ 0,  1,  2,  3,  4,  5,  6,  7,  8,  9, 10, 11, 12, 13, 14, 15, 16,
       17, 18, 19, 20, 21, 22, 23])
[4]:
get_memloc(R)
R_NoCopy = R                                       # Nothing changed
R_ShallowCopy = R.view()
R_DeepCopy = R.copy()
print(R is R_NoCopy)                               # Equivalent to `id(R) == id(R_NoCopy)`
print(R is R_ShallowCopy)                          # Different id means objects are actually different
print(get_memloc(R) == get_memloc(R_ShallowCopy))  # ... but a shallow copy means that base data is not actually copied
print(get_memloc(R) == get_memloc(R_DeepCopy))     # A deep copy means all things are cloned to another block of memory
print(np.all(R == R_DeepCopy))                     # ... however, the value of copy is the exactly the same to origin
True
False
True
False
True

is 作为 python 命令是判别两个对象是否完全相同;但 get_memloc 所给出的是作为 ndarray 底层数据的指针;因此,如果对于两个 ndarray 对象,is 或者 get_memloc 给出相等的判断,那么底层数据就没有被复制。

上述代码中,直接赋值与浅复制就没有复制底层的数据,因此代码的运行几乎没有事件与内存的消耗,但修改其中的数据的过程是不可逆的。而深复制则会复制整个底层的数据,但反过来修改深复制后的数据不会对原始的数据造成影响。

任务 (1)

  1. (可选) 这里的 get_base 函数使用了 Python 的 isinstance 函数.该函数在 PySCF 的源代码中涉及到临时信息如何储存的部分中经常出现.但是我们也可以不使用循环与 isinstance 函数,而使用递归方法来写 get_base.试写出这样一个函数.

    提示:你应当可以发现 type(R.base) == np.ndarrayR.base.base is None == True

一般来说,单纯的张量转置与重塑不会进行深复制:

[5]:
print(get_memloc(R) == get_memloc(R.reshape(-1, 6)))
print(get_memloc(R) == get_memloc(R.reshape(8, 3)))
print(get_memloc(R) == get_memloc(R.transpose(1, 0, 2).reshape(3, 2, 2, 2)))
True
True
True

但下述的代码的最后行则会给出深复制的结果:

[6]:
print(R.transpose(0, 2, 1).shape)
print(get_memloc(R) == get_memloc(R.transpose(0, 2, 1).reshape(2, 4, 3)))
print(get_memloc(R) == get_memloc(R.transpose(0, 2, 1).reshape(8, 3)))
(2, 4, 3)
True
False

原因应该是,对于一个 ndarray 对象,其储存需要使用连续的内存;如果转置与重塑矩阵使得内存的储存无法连续,那么 numpy 会进行深复制。

同时,需要指出,即使转置后的矩阵与转置前的矩阵值不相等,但底层的数据仍然是相等的:

[7]:
A = np.random.random((10, 10))
print(np.allclose(A, A.T))
print(get_memloc(A) == get_memloc(A.T))
False
True

最后我们讨论运算下的情况。尽管对于 python 中简单普通的数值运算,下述关系 (有时) 是成立的:

[8]:
(1.5 + 1.5) is (9. / 3.)
[8]:
True

但对于 numpy 的矩阵操作,这通常不成立:

[9]:
A = B = np.random.random((10, 10))
print(A @ B is A @ B)
print(np.all(A @ B == A @ B))
False
True

有趣的是,使用 np.einsum 进行矩阵转置仍然是浅复制:

[10]:
print(get_memloc(R) == get_memloc(np.einsum("ijk -> jki", R)))
True

向量截取

附近几小节 (到向量直和为止) 的内容可以参考 numpy 的 Boardcasting 文档。

我们曾经说过,numpy 可以使用类似于 Fortran 的截取方式:

[11]:
R = np.arange(15)
R[1:12:3]
[11]:
array([ 1,  4,  7, 10])

我们现在就可以检查截取 R[:, 1, 3]R 是否共享相同的内存空间:

[12]:
get_memloc(R) == get_memloc(R[1:12:3])
[12]:
True

但是需要留意,因为这种截取共享了相同的内存空间,因此对其更改会直接影响原始数据:

[13]:
R[1:12:3] = [-1, -2, -3, -4]
R
[13]:
array([ 0, -1,  2,  3, -2,  5,  6, -3,  8,  9, -4, 11, 12, 13, 14])

这种截取方式称为 slice,与下述的代码完全等价:

[14]:
R[slice(1, 12, 3)]
[14]:
array([-1, -2, -3, -4])

如果对这种截取进行重新赋值,也会直接地改变原始向量的数据:

[15]:
R[slice(1, 12, 3)] = [-5, -6, -7, -8]
R
[15]:
array([ 0, -5,  2,  3, -6,  5,  6, -7,  8,  9, -8, 11, 12, 13, 14])

使用 slice(start, stop, step) 的方便之处是可以将分割作为一种对象来赋值,这在以后处理原子轨道的分割时会经常使用。

除了 slice 分割之外,还可以使用列表或者 range 截取向量中的元素:

[16]:
R[[1, 4, 7, 10]]
[16]:
array([-5, -6, -7, -8])
[17]:
R[range(1, 12, 3)]
[17]:
array([-5, -6, -7, -8])

尽管列表的截取相对来说更为自由 (可以截取任意位置的元素,不受制于 slice 的 start, stop, step 模式),但它们实际上很可能作了一层深复制:

[18]:
print(get_memloc(R) == get_memloc(R[range(1, 12, 3)]))
print(get_memloc(R) == get_memloc(R[[1, 4, 7, 10]]))
False
False

但这仍然可以更改底层的数据:

[19]:
R[[1, 4, 7, 10]] = [0, 0, 0, 0]
R
[19]:
array([ 0,  0,  2,  3,  0,  5,  6,  0,  8,  9,  0, 11, 12, 13, 14])

对于这种更改底层数据的特性,需要补充的例子是,如果重复更改底层数据,那么一般来说会用最后一个值覆盖:

[20]:
R[[1, 2, 2, 1]] = [-1, -2, -3, -4]
R
[20]:
array([ 0, -4, -3,  3,  0,  5,  6,  0,  8,  9,  0, 11, 12, 13, 14])

矩阵截取与对角元

下面我们讨论矩阵的截取。

[21]:
A = np.arange(25).reshape(5, 5)
A
[21]:
array([[ 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]])

平时最为常用的截取方式仍然是使用 slice 截取子矩阵:

[22]:
A[slice(3), slice(3)]
[22]:
array([[ 0,  1,  2],
       [ 5,  6,  7],
       [10, 11, 12]])

但按相同的方式,在列表和 range 截取的结果会完全不同:

[23]:
A[range(3), range(3)]
[23]:
array([ 0,  6, 12])

我们仍然应当能发现这几种截取使用不同的底层数据复制方式:

[24]:
print(get_memloc(A) == get_memloc(A[slice(3), slice(3)]))
print(get_memloc(A) == get_memloc(A[range(3), range(3)]))
True
False

但这两种方法都能对底层数据作修改:

[25]:
A[slice(3), slice(3)] = np.arange(9).reshape(3, 3)
A[range(3), range(3)] = - np.arange(3)
A
[25]:
array([[ 0,  1,  2,  3,  4],
       [ 3, -1,  5,  8,  9],
       [ 6,  7, -2, 13, 14],
       [15, 16, 17, 18, 19],
       [20, 21, 22, 23, 24]])

依据列表或 range 的截取方式,我们可以获得矩阵的对角元。截取对角元在处理分子轨道基组下的矩阵、或处理 U 矩阵的对角元时会有所帮助:

[26]:
A[range(5), range(5)]
[26]:
array([ 0, -1, -2, 18, 24])

在 numpy 中,函数 np.diagonal 也能截取对角元:

[27]:
A.diagonal()
[27]:
array([ 0, -1, -2, 18, 24])

尽管这种截取是浅复制:

[28]:
get_memloc(A.diagonal()) == get_memloc(A)
[28]:
True

但这种截取不可以进行赋值:

[29]:
A.diagonal() = - np.arange(5)
  File "<ipython-input-29-c113c2cc9ca7>", line 1
    A.diagonal() = - np.arange(5)
                                 ^
SyntaxError: can't assign to function call

如果希望对矩阵求迹,还可以使用 np.trace

[30]:
A.trace()
[30]:
39

张量截取与对角元

下面我们考虑张量的截取。在梯度求取过程中,我们会经常遇到最后两维度的大小等于原子轨道数的张量;这里用 (2, 3, 3) 维张量来描述这一问题。

[31]:
R = np.arange(18).reshape(2, 3, 3)
R
[31]:
array([[[ 0,  1,  2],
        [ 3,  4,  5],
        [ 6,  7,  8]],

       [[ 9, 10, 11],
        [12, 13, 14],
        [15, 16, 17]]])

如果原子轨道数为 3,其中有两个原子,第一个原子占用轨道 1,第二个原子占用轨道 2, 3;那么截取其中第二个原子的部分就可以写为

[32]:
R[:, slice(1, 3), slice(1, 3)]
[32]:
array([[[ 4,  5],
        [ 7,  8]],

       [[13, 14],
        [16, 17]]])

如果要取该张量最后两维度的对角元,则写为

[33]:
R.diagonal(axis1=-2, axis2=-1)
[33]:
array([[ 0,  4,  8],
       [ 9, 13, 17]])

张量与向量和

向量的直和会在出现 \(\varepsilon_i - \varepsilon_a\) 的情形中出现.在不考虑内存占用效率的情况下,我们可以定义 \(D_i^a = \varepsilon_i - \varepsilon_a\) 矩阵;这个矩阵的生成方式即是向量 \(\varepsilon_i\)\(- \varepsilon_a\) 的直和.

求向量直和的情形可以出现在 CP-HF 方程的等式左,也可以出现在 MP2 方法的分母的求轨道能差中.在其它语言中,这些计算完全可以通过二重或四重循环解决;但一般认为 Python 自身的的循环效率较低,因此若单个循环体的计算量不大,可以不利用循环之处要尽量不用.在这种情况下,牺牲内存而保证一定效率的构建 \(D_i^a\) 矩阵或 \(D_{ij}^{ab}\) 张量的做法通常会提高计算效率.

在 numpy 中没有直接配备向量直和的函数.通常,我们会采用下述方式生成直和:

[34]:
eo = - np.arange(1, 4)
ev = np.arange(1, 6)
D_ia = eo[:, None] - ev[None, :]
D_ia
[34]:
array([[-2, -3, -4, -5, -6],
       [-3, -4, -5, -6, -7],
       [-4, -5, -6, -7, -8]])

这个过程可以看作是将 eoev 分别展开为两个矩阵从而相减;但两者分别展开为 (3, 1) 与 (1, 5) 维矩阵。随后,维度不为 1 的部分与维度为 1 的部分之间作类似于向量与数的元素操作,从而这两个矩阵相减后成为 (3, 5) 的矩阵。

在 numpy 中,维度前置为 1 的部分在直和中是可有可无的:

[35]:
np.allclose(eo[:, None] - ev, D_ia)
[35]:
True

np.allclose 的补充说明

我们已经使用很多次 np.allclose 了,它是判断两个张量之间是否几乎相等的函数。之所以并未必完全相等,是因为 np.allclose 实际上允许一些微小的数值偏差。事实上,如果要判断两个矩阵是否 完全 相等,应当是通过 np.all 来判断。譬如对于下述矩阵:

[36]:
np.random.seed(2)
A = np.random.random((3, 3))
B = A.copy()

这两个矩阵应当是完全相等的:

[37]:
A == B
[37]:
array([[ True,  True,  True],
       [ True,  True,  True],
       [ True,  True,  True]])

np.all 会判断布尔张量的元素是否全部为 True:

[38]:
np.all(A == B)
[38]:
True

显然,如果用 np.allclose 也会给出 True:

[39]:
np.allclose(A, B)
[39]:
True

但如果引入非常微小的偏差,np.all 会给出这两个矩阵不相等,但 np.allclose 则会给出大致相等的结论:

[40]:
B = A + 1e-10
print(A == B)
print(np.all(A == B))
print(np.allclose(A, B))
[[False False False]
 [False False False]
 [False False False]]
False
True

np.allnp.allclose 都是判断一个 ndarray 对象元素是否全部为 True。np.all 是对 A == B 的元素作判断,np.allclose 则是对 np.isclose(A, B) 的元素作判断:

[41]:
np.isclose(A, B)
[41]:
array([[ True,  True,  True],
       [ True,  True,  True],
       [ True,  True,  True]])

但如果微小的偏差渐渐地不再微小,那么 np.isclose 就不会给出全部 True 的 ndarray 对象;并且即使只有一个值为 False,那么 np.allclose 就会给出 False:

[42]:
B = A + 1e-6
print(np.isclose(A, B))
np.allclose(A, B)
[[ True False  True]
 [ True  True  True]
 [ True  True  True]]
[42]:
False

但如果降低 np.allclosenp.isclose 的判断阈值,就可以给出 True 的结果了。atol 代表绝对值阈值,默认为 1e-8;rtol 代表相对值阈值,默认为 1e-5,这些可以参考 API 文档

[43]:
print(np.allclose(A, B, atol=8e-7))
print(np.allclose(A, B, rtol=5e-5))
True
True

也可以混合两种判断阈值:

[44]:
print(np.allclose(A, B, atol=5e-7))
print(np.allclose(A, B, rtol=3e-5))
print(np.allclose(A, B, atol=5e-7, rtol=3e-5))
False
False
True

以后我们会非常经常地使用 np.allclose 来判断我们的计算结果与 PySCF 程序的,或与数值结果的是否相似。

np.einsum 的使用:布尔值

在整个笔记中,我们会大量地使用 np.einsum 函数。这个函数可以非常直观地将公式与程序对应起来。从作者的角度,np.einsum 可以确实地颠覆了对量化程序或者普遍的张量缩并程序编写的认知。

以前代码中,我们已经对 np.einsum 有基本的认识,一般来说足够应对以后的张量乘积的处理;而后面三小节我们会稍稍了解一下这个函数的进阶使用方式。

optimize 选项是优化算法的选项。optimize 可以设定为布尔值,以判断是否自动优化算法。所谓自动优化,是指根据张量的维度与乘积的方式自动优化计算过程。我们模拟双电子积分转换的情形,来了解自动优化的意义。双电子积分的计算公式是

\[(ij|kl) = C_{\mu p} C_{\nu q} (\mu \nu | \kappa \lambda) C_{\kappa r} C_{\lambda s}\]

我们用 C 代表系数矩阵 \(C_{\mu p}\),而用 eri 代表 AO 基组下的双电子积分 (ERI) 张量;原子轨道基组与分子轨道数都定为 5。下面的程序并不处理真正的化学问题,但可以评价处理化学问题过程中算法的效率。

[45]:
nmo = nao = 5
C = np.random.random((nmo, nmo))
eri = np.random.random((nmo, nmo, nmo, nmo))
[46]:
%%timeit -r 7 -n 10
np.einsum("up, vq, uvkl, kr, ls -> pqrs", C, C, eri, C, C, optimize=True)
412 µs ± 129 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)
[47]:
%%timeit -r 7 -n 10
np.einsum("up, vq, uvkl, kr, ls -> pqrs", C, C, eri, C, C, optimize=False)
9.05 ms ± 185 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)

我们会发现,如果使用打开 optimize=True 的代码,运行效率会快很多,并且时间复杂度是 \(O(N^5)\);否则时间复杂度就会成为 \(O(N^8)\)。事实上,如果基组数再大一些,optimize=False 的代码就会基本卡死。

但是必须指出,默认情况下,np.einsum 不会打开 自动优化的代码。我们从下面模拟 AO 基组密度生成的例子 (也是我们以前使用过的普通矩阵乘法的例子) 就能说明原因:

[48]:
%%timeit -r 7 -n 1000
np.einsum("ui, vi -> uv", C, C)  # Equivalent to `optimize=False` added
2.06 µs ± 315 ns per loop (mean ± std. dev. of 7 runs, 1000 loops each)
[49]:
%%timeit -r 7 -n 1000
np.einsum("ui, vi -> uv", C, C, optimize=True)
70.4 µs ± 3.11 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)

有两个缘由导致上述的情况。其一是,两个系数矩阵相乘的可能方法 (几乎) 只有一种,时间复杂度是 \(O(N^3)\) 并且无法优化。其二是,打开 optimize=True 后,程序会使用与 optimize=False 完全不同的方式构建张量,并且还会使用 Python 而非 C 的代码进行最优时间复杂度的算法搜索。因此,在处理小矩阵计算,或者无法优化代码的计算中,optimize=True 具有非常大的劣势。反之,一旦计算过程可以被优化,optimize=True 则会体现出极大的优势。大多数情况下,手写张量缩并程序会比 np.einsum 的效率慢一些。

np.einsum 的使用:np.einsum_path

np.einsum_path 则是给出当前计算的算法,它不实际执行计算;它将输出二维元组。我们仍然拿双电子积分转换为例:

[50]:
path, info = np.einsum_path("up, vq, uvkl, kr, ls -> pqrs", C, C, eri, C, C, optimize=True)

我们先来看相对友好的路径信息:

[51]:
print(info)
  Complete contraction:  up,vq,uvkl,kr,ls->pqrs
         Naive scaling:  8
     Optimized scaling:  5
      Naive FLOP count:  1.953e+06
  Optimized FLOP count:  2.500e+04
   Theoretical speedup:  78.122
  Largest intermediate:  6.250e+02 elements
--------------------------------------------------------------------------
scaling                  current                                remaining
--------------------------------------------------------------------------
   5               uvkl,up->klpv                      vq,kr,ls,klpv->pqrs
   5               klpv,vq->klpq                         kr,ls,klpq->pqrs
   5               klpq,kr->lpqr                            ls,lpqr->pqrs
   5               lpqr,ls->pqrs                               pqrs->pqrs

我们知道,双电子积分从 AO 到 MO 的转化若未经优化是 \(O(N^8)\) 的时间复杂度,而优化后是 \(O(N^5)\) 的时间复杂度。

  • 第 2, 3 行:优化前后的时间复杂度;

  • 第 4, 5 行:优化前后的代码的计算次数;

  • 第 6 行:代表优化前后的速度比值;

  • 第 7 行:算法计算过程中,中间张量所占用的空间 (以元素数记而非实际比特大小)

  • 第 8 行及以后:算法的实际计算过程.

每个张量计算后,生成的临时矩阵会放在下一次张量缩并列表的最后。

随后我们来看实际交给程序阅读的路径信息:

[52]:
path
[52]:
['einsum_path', (0, 2), (0, 3), (0, 2), (0, 1)]

其第一个元素是 'einsum_path',之后的信息是每个最基本张量乘积缩并的操作对象的编号。具体来说,我们一开始代入程序的张量顺序是

\[[C_{\mu p}, C_{\nu q}, (\mu \nu | \kappa \lambda), C_{\kappa r}, C_{\lambda s}]\]

张量缩并总是两两进行的。第一次缩并是计算 \((p \nu | \kappa \lambda) = C_{\mu p} (\mu \nu | \kappa \lambda)\),这两者分别对应到长度为 5 的张量缩并列表中的第 (0, 2) 个元素 (从 0 计数)。计算得到的 \((p \nu | \kappa \lambda)v\) 会放到列表的最后。第一次缩并结束得到的张量列表则是

\[[C_{\nu q}, C_{\kappa r}, C_{\lambda s}, (p \nu | \kappa \lambda)]\]

而第二次缩并是计算 \((pq | \kappa \lambda) = C_{\nu q} (p \nu | \kappa \lambda)\),被缩并的两者分别是列表中第 (0, 3) 个元素。依次类推,就可以得到上述的张量缩并列表。

交由程序阅读的路径信息实际上可以代回 np.einsumoptimize 选项:

[53]:
%%timeit -r 7 -n 10
np.einsum("up, vq, uvkl, kr, ls -> pqrs", C, C, eri, C, C, optimize=['einsum_path', (0, 2), (0, 3), (0, 2), (0, 1)])
323 µs ± 114 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)

我们也许能发现,其计算速度还比 optimize=True 快一些:

[54]:
%%timeit -r 7 -n 10
np.einsum("up, vq, uvkl, kr, ls -> pqrs", C, C, eri, C, C, optimize=True)
453 µs ± 194 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)

这是因为 optimize=True 还需要通过一小段 python 程序,通过张量的维度信息推测最优的缩并方式,但直接向 np.einsum 提交计算路径则省去了这一步。我们也许会感到,np.einsum 判断程序路径确实会花不少时间;但这部分的计算量不会随着张量的增大而增大,因此大多数情况下是可以忽略的。只是如果打算在使用 np.einsum 的情况下将代码效率优化到极限,那么可以手动地设置张量计算与缩并路径。

下面两段程序可以判断 optimize=True 在推测最优缩并过程时所多花费的时间;对于作者的电脑而言,大约是 100 微秒:

[55]:
%%timeit -r 7 -n 10
np.einsum_path("up, vq, uvkl, kr, ls -> pqrs", C, C, eri, C, C, optimize=True)
229 µs ± 92.9 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)
[56]:
%%timeit -r 7 -n 10
np.einsum_path("up, vq, uvkl, kr, ls -> pqrs", C, C, eri, C, C, optimize=['einsum_path', (0, 2), (0, 3), (0, 2), (0, 1)])
125 µs ± 22.3 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)

我们也可以对 optimize=False 的情形给出缩并列表;当然,它会给出 \(O(N^8)\) 的计算量级:

[57]:
print(np.einsum_path("up, vq, uvkl, kr, ls -> pqrs", C, C, eri, C, C, optimize=False)[1])
  Complete contraction:  up,vq,uvkl,kr,ls->pqrs
         Naive scaling:  8
     Optimized scaling:  8
      Naive FLOP count:  1.953e+06
  Optimized FLOP count:  1.953e+06
   Theoretical speedup:  1.000
  Largest intermediate:  6.250e+02 elements
--------------------------------------------------------------------------
scaling                  current                                remaining
--------------------------------------------------------------------------
   8      ls,kr,uvkl,vq,up->pqrs                               pqrs->pqrs

np.einsum 的使用:中转内存设置

下面是以前我在实际工作中遇到过的情况。一般来说,optimize=True 已经能很好地处理张量乘积的算法问题了;但在处理某些涉及格点积分问题时,其计算效率低得令人难以忍受.这是因为 np.einsum 默认的临时张量内存大小设定为所有张量列表中,或张量结果中最大的内存占用值.这也就意味着,默认情况下中间张量的大小其实不是很大。

在计算 GGA 对 \(F_{\mu \nu}^{A_t}\) 即 Fock 矩阵对原子坐标的偏导数的贡献时,默认中间张量内存大小限制就成为了计算耗时的瓶颈。我们使用 np.einsum 默认使用的贪心算法 greedy 来给出设定不同内存大小时,计算路径与理论提速之间的差别。

我们首先定义一些张量。我们在这里不对它们赋予实际意义。

[58]:
grid_fg      = np.random.random((3000))
grid_rho_1   = np.random.random((3, 3000))
grid_A_rho_2 = np.random.random((4, 3, 3, 3000))
grid_ao_1    = np.random.random((3, 3000, 22))
grid_ao_0    = np.random.random((3000, 22))

下述计算的时间复杂度在默认内存限制下,仍然为 \(O(N^6)\)

[59]:
%%timeit -r 7 -n 7
np.einsum(
    "g, Atrg, rgu, gv -> Atuv",
    grid_fg, grid_A_rho_2, grid_ao_1, grid_ao_0,
    optimize="greedy"  # Equivalent to `optimize=True`
)
149 ms ± 2.15 ms per loop (mean ± std. dev. of 7 runs, 7 loops each)
[60]:
print(np.einsum_path(
    "g, Atrg, rgu, gv -> Atuv",
    grid_fg, grid_A_rho_2, grid_ao_1, grid_ao_0,
    optimize="greedy"  # Equivalent to `optimize=True`
)[1])
  Complete contraction:  g,Atrg,rgu,gv->Atuv
         Naive scaling:  6
     Optimized scaling:  6
      Naive FLOP count:  2.091e+08
  Optimized FLOP count:  1.569e+08
   Theoretical speedup:  1.333
  Largest intermediate:  6.600e+04 elements
--------------------------------------------------------------------------
scaling                  current                                remaining
--------------------------------------------------------------------------
   2                    gv,g->vg                        Atrg,rgu,vg->Atuv
   6           vg,rgu,Atrg->Atuv                               Atuv->Atuv

但如果我们放开内存限制到 2 GB 的大小 (之所以要除以 8 是因为一般我们使用 64 位浮点数进行矩阵计算,而每 1 Byte 指代 8 位,因此每个浮点数占用 8 Byte 空间),那么代码变成 \(O(N^5)\) 复杂度,提速约 4 倍 (实际提速与理论提速似乎还有不少区别,我还解释不了):

[61]:
%%timeit -r 7 -n 7
np.einsum(
    "g, Atrg, rgu, gv -> Atuv",
    grid_fg, grid_A_rho_2, grid_ao_1, grid_ao_0,
    optimize=["greedy", 1024 ** 3 * 2 / 8]  # 1024 ** 3 * 2 -> 2GB; 8 -> float64
)
29.7 ms ± 925 µs per loop (mean ± std. dev. of 7 runs, 7 loops each)
[62]:
print(np.einsum_path(
    "g, Atrg, rgu, gv -> Atuv",
    grid_fg, grid_A_rho_2, grid_ao_1, grid_ao_0,
    optimize=["greedy", 1024 ** 3 * 2 / 8]  # 1024 ** 3 * 2 -> 2GB; 8 -> float64
)[1])
  Complete contraction:  g,Atrg,rgu,gv->Atuv
         Naive scaling:  6
     Optimized scaling:  5
      Naive FLOP count:  2.091e+08
  Optimized FLOP count:  3.967e+07
   Theoretical speedup:  5.271
  Largest intermediate:  7.920e+05 elements
--------------------------------------------------------------------------
scaling                  current                                remaining
--------------------------------------------------------------------------
   2                    gv,g->vg                        Atrg,rgu,vg->Atuv
   5              rgu,Atrg->tAug                            vg,tAug->Atuv
   5               tAug,vg->Atuv                               Atuv->Atuv

但我们注意到,其代价是生成了非常大的中间张量 (以 tAug 指代);其大小是

\[3 \times 4 \times 22 \times 3000 = 792000 = 6187.5 \, \text{kB}\]

而在张量列表与张量结果中,最大的张量也只有

\[4 \times 3 \times 3 \times 3000 = 108000 = 843.8 \, \text{kB}\]

因此,我们实际上是以牺牲内存占用为代价,换取计算效率的提升.

最后指出,np.einsum 中,除了 greedy 贪心算法寻找张量缩并过程之外,还有 optimal 方式,即遍历所有可能的张量缩并过程并寻找最优解。但遍历所有可能缩并的时间复杂度是 \(O(N!)\);若 \(N\) 代表被缩并张量数,因此一旦涉及比较多的张量,optimal 可能会出现非常糟糕的性能表现。通常来说,greedy 已经能给出不错的缩并,因此使用 greedy 已经足够了。

技巧

以后,我们会默认使用 ["greedy", 1024 ** 3 * 2 / 8] 作为 np.einsum 中的 optimize 选项,即使用贪心算法,在 2 GB 大小下作算法优化.

同时,以后在程序运行之前会使用下述代码,使得当前程序的 np.einsumoptimize 默认选项会由 ["greedy", 1024 ** 3 * 2 / 8] 所覆盖.

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

参考任务解答

任务 (1)

任务 (1.1) 可选
[63]:
R = np.arange(24).reshape((2, 3, 4))
def get_base(array):
    if isinstance(array.base, np.ndarray):
        return array.base
    else:
        return array
[64]:
get_base(R)
[64]:
array([ 0,  1,  2,  3,  4,  5,  6,  7,  8,  9, 10, 11, 12, 13, 14, 15, 16,
       17, 18, 19, 20, 21, 22, 23])
[65]:
print(get_base(R).base)
None

pyxdh 使用介绍

pyxdh 库是一个迷你量化库,可以用不多的几行命令直接获得 xDH 型双杂化泛函 (目前只能闭壳层、非冻核,下略) 的一阶梯度,以及 B2-PLYP 型双杂化泛函的二阶梯度。同时,它还能提供一些便利的中间变量的输出,可以帮助我们更好地理解梯度的程序实现过程;我们以后会非常经常地使用这个工具。

这一节我们会讨论 pyxdh 库的最基本调用方法。更为细节的调用将在后文呈现。

警告

pyxdh 没有经过严格的测评,目前也没有任何同行评议。在这份警告撤销之前,请不要在正式发表的论文中使用此处的做法作为 XYG3 及其导数性质的计算方法。对于其它方法,譬如 MP2、双杂化泛函等性质,也请在正式发表论文中使用成熟的量化软件。

[1]:
import warnings
warnings.filterwarnings("ignore")

示例:XYG3 核坐标梯度

[2]:
import numpy as np
from pyscf import gto, dft, lib, grad
from pyxdh.DerivOnce import GradXDH

分子与格点构建

下面两个代码块实际上是 mol PySCF 的分子构建:

[3]:
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()
[3]:
<pyscf.gto.mole.Mole at 0x7f89241f2be0>

我们打算进行格点积分;如果要使用 grids 自定义的格点,则在 PySCF 中可作如下的定义:

[4]:
grids = dft.Grids(mol)
grids.atom_grid = (99, 590)
grids.build()
[4]:
<pyscf.dft.gen_grid.Grids at 0x7f89586269b0>

B3LYP 自洽泛函与 XYG3 非自洽泛函的定义

XYG3 的自洽场泛函,或者说为 XYG3 泛函提供密度与轨道的参考态是 B3LYP 泛函。B3LYP 泛函的能量求取类 scf_eng 在 PySCF 可以写为

[5]:
scf_eng = dft.RKS(mol)
scf_eng.xc = "B3LYPg"
scf_eng.grids = grids

XYG3 泛函本身是由下述项构成:

\[E_\mathrm{xc}^\mathrm{XYG3} = 0.8033 E_\mathrm{x}^\mathrm{exact} - 0.0140 E_\mathrm{x}^\mathrm{LDA} + 0.2107 E_\mathrm{x}^\mathrm{B88} + 0.6789 E_\mathrm{c}^\mathrm{LYP} + 0.3211 E_\mathrm{c}^\mathrm{PT2}\]

泛函分为两部分,上式的最后一项接近于 MP2;而前四项是一般的杂化泛函贡献项,因此前四项可以归于普通的泛函类 nc_eng

[6]:
nc_eng = dft.RKS(mol)
nc_eng.xc = "0.8033*HF - 0.0140*LDA + 0.2107*B88, 0.6789*LYP"
nc_eng.grids = grids

XYG3 能量与泛函梯度计算

所有 pyxdh 包的类都由配置字典 config 实例化。XYG3 型泛函可以通过三个变量给出定义:scf_eng 自洽场泛函、nc_eng 不包含 PT2 的非自洽泛函、以及 PT2 系数 (对于 XYG3 而言为 0.3211)。代入 pyxdh.DerivOnce.GradXDH,便得到可以计算能量与梯度的 XYG3 的类 grad_xDH

[7]:
config = {
    "scf_eng": scf_eng,
    "nc_eng": nc_eng,
    "cc": 0.3211
}
grad_xDH = GradXDH(config)

XYG3 的单点能 (原子单位) 可以通过如下方式生成:

[8]:
grad_xDH.eng
[8]:
-151.1962818434803

而梯度 (原子单位) 则可以通过下述方式生成:

[9]:
grad_xDH.E_1
[9]:
array([[-0.03967538,  0.06717703,  0.14149365],
       [ 0.00876854,  0.15758362, -0.17123915],
       [ 0.01226317,  0.01305055,  0.03179645],
       [ 0.01864365, -0.23781121, -0.00205102]])

示例:B2PLYP 型泛函极化率

B2PLYP 型泛函极化率的计算

下面一个例子是计算 B2PLYP 的极化率。为了计算极化率,我们先引入以下两个类:

[10]:
from pyxdh.DerivOnce import DipoleMP2
from pyxdh.DerivTwice import PolarMP2

分子 mol 与格点 grids 选取都与上面 XYG3 的梯度计算相同:

[11]:
b2plyp_eng = dft.RKS(mol)
b2plyp_eng.xc = "0.53*HF + 0.47*B88, 0.73*LYP"
b2plyp_eng.grids = grids

我们首先给出 B2PLYP 的偶极矩助手 b2plyp_dip_helper 类;其中,cc = 0.27 是 B2PLYP 中 PT2 部分的系数。偶极矩的计算类似于梯度,可以通过 E_1 给出:

[12]:
config = {
    "scf_eng": b2plyp_eng,
    "cc": 0.27
}
b2plyp_dip_helper = DipoleMP2(config)
b2plyp_dip_helper.E_1
[12]:
array([ 0.83235837,  0.60533609, -0.34817672])

通过上述定义的偶极矩助手 b2plyp_dip_helper,我们可以给出极化率助手 b2plyp_polar_helper

[13]:
config = {
    "deriv_A": b2plyp_dip_helper,
    "deriv_B": b2plyp_dip_helper,
}
b2plyp_polar_helper = PolarMP2(config)

极化率助手所给出的极化率可以通过 E_2 给出;需要注意对于极化率,需要对 E_2 给出的值取负值才是通常的极化率结果:

[14]:
- b2plyp_polar_helper.E_2
[14]:
array([[ 6.89985042, -0.11067359, -1.07620158],
       [-0.11067359,  4.74839861,  0.25707374],
       [-1.07620158,  0.25707374, 14.38297718]])

pyxdh 所提供的标准结果

pyxdh 中,B2PLYP 的极化率是 pyxdh.DerivTwice.polar_mp2 文件的其中一个 pytest 测试例。这里我们简单了解一下文件的使用方式。我们首先引入以下两个对象:

[15]:
from pkg_resources import resource_filename
from pyxdh.Utilities import FormchkInterface

我们已经预先通过 Gaussian 计算了双氧水分子 B2PLYP 的频率结果;这个结果通过 b2plyp_formchk 储存下来:

[16]:
b2plyp_formchk = FormchkInterface(resource_filename("pyxdh", "Validation/gaussian/H2O2-B2PLYP-freq.fchk"))

通过调用其中的方法,就可以获得偶极、极化率等必要的信息:

[17]:
b2plyp_formchk.dipole()
[17]:
array([ 0.83235859,  0.60533611, -0.34817773])
[18]:
b2plyp_formchk.polarizability()
[18]:
array([[ 6.89984471, -0.11067149, -1.07619714],
       [-0.11067149,  4.74839444,  0.25707124],
       [-1.07619714,  0.25707124, 14.3829714 ]])

最后,我们可以通过 np.allclose 判断我们计算的偶极、极化率是否与 Gaussian 给出的结果大致一致:

[19]:
np.allclose(
    b2plyp_dip_helper.E_1, b2plyp_formchk.dipole(),
    atol=1e-6, rtol=1e-4
)
[19]:
True
[20]:
np.allclose(
    - b2plyp_polar_helper.E_2, b2plyp_formchk.polarizability(),
    atol=1e-6, rtol=1e-4
)
[20]:
True

示例:XYG3 型泛函极化率

为了计算 XYG3 型泛函极化率,我们先引入以下两个类:

[21]:
from pyxdh.DerivOnce import DipoleXDH
from pyxdh.DerivTwice import PolarXDH

首先,我们给出 XYG3 型泛函自洽场与非自洽场泛函部分的 PySCF 能量计算类 scf_eng, nc_eng

[22]:
scf_eng = dft.RKS(mol)
scf_eng.xc = "B3LYPg"
scf_eng.grids = grids
[23]:
nc_eng = dft.RKS(mol)
nc_eng.xc = "0.8033*HF - 0.0140*LDA + 0.2107*B88, 0.6789*LYP"
nc_eng.grids = grids

与梯度类的构造相似地,我们给出 XYG3 的偶极矩类 dip_xDH

[24]:
config = {
    "scf_eng": scf_eng,
    "nc_eng": nc_eng,
    "cc": 0.3211
}
dip_xDH = DipoleXDH(config)

与 B2PLYP 的极化率构造相似地,我们给出 XYG3 的极化率类 polar_xDH

[25]:
config = {
    "deriv_A": dip_xDH,
    "deriv_B": dip_xDH,
}
polar_xDH = PolarXDH(config)

XYG3 的极化率也可以通过 E_2 属性的负值给出:

[26]:
- polar_xDH.E_2
[26]:
array([[ 6.87997982, -0.1021484 , -1.09976624],
       [-0.1021484 ,  4.7171979 ,  0.29678172],
       [-1.09976624,  0.29678172, 14.75690205]])

这就获得了 XYG3 的极化率了。最后我们可以通过预置在 pyxdh 库中,通过数值梯度给出的 XYG3 极化率参考值 ref_polar,来验证我们所计算的 - polar_xDH.E_2 确实是正确的极化率值:

[27]:
import pickle
with open(resource_filename("pyxdh", "Validation/numerical_deriv/xdh_polarizability_xyg3.dat"), "rb") as f:
    ref_polar = pickle.load(f)["polarizability"]
[28]:
np.allclose(- polar_xDH.E_2, ref_polar, atol=1e-7, rtol=1e-5)
[28]:
True

量化基础必要背景:序

这一部分中,我们讨论二阶梯度计算中所使用到的 PySCF 的一些内容,包括调用方式、函数、以及必要的量化背景知识、以及一些符号的定义。

分子结构、基组、电子积分

对于真空下的分子,通过定态的、电子态的 Schrodinger 方程 \(\hat H \Psi = E \Psi\) 的波函数 \(\Psi\),原则上我们可以获得所有我们所期望的分子性质。\(E\) 是能量或其它物理可观测量表征,因此 \(E\) 也是被求解量。

\(\Psi\)\(E\) 的求解的第一步即是确定 \(\hat H\) 的形式。为了确定 \(\hat H\),我们至少要做四件事:

  • 理论上,\(\hat H\) 是以分子构型为变量的算符量。因此,分子结构是必要的成分。

  • 从实践上,由于我们不可能写出 \(\Psi\) 的解析形式,因此需要使用近似的、截断的函数展开。这些函数称为基组 (basis)。

  • 所有与算符 \(\hat H\) 相关的运算结果则是电子积分

  • 事实上,即使使用了截断的函数展开,使用 \(\hat H\) 的计算 \(\Psi\) 的代价仍然非常巨大。为此,我们需要使用近似的量化方法来处理问题。

其中,分子结构、电子积分都是能使用程序确切定义的量;基组一般来说使用约定俗成的定义即可 (譬如 6-31G、cc-pVTZ 等);但量化方法现在仍然在积极地发展。我们假设前三个问题不再是困扰我们的问题;而量化方法则是我们最为关心的问题。

尽管分子结构、基组与电子积分并不是这一系列文档的核心问题,但为了解决量化方法问题,我们有必要对这些问题有所熟悉。在这一小节中,这里我们讨论 PySCF 下的分子结构、基组与电子积分的调用过程。这也可以看作是对 pyscf.gto.Mole 类的不完全的解释文档。这将是我们第一份讨论量化程序问题的文档。

[52]:
import numpy as np
from pyscf import gto, lib
from functools import partial

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

分子结构

分子构建

在这一节、以及今后的文档中,我们会始终以下述结构的双氧水分子进行说明。

[3]:
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()
[3]:
<pyscf.gto.mole.Mole at 0x7f62a95d7080>

上述指令中,

  • mol = gto.Mole() 是初始化一个 gto.Mole 类到变量 mol 中。该类保存了与分子和基组有关的绝大部分信息、以及电子积分的调用方式。

  • mol.atom = ... 通过较为简单的方式作分子的结构定义。默认情况下,长度的单位是 Angstrom;这也类同于 Gaussian 的定义方式。在 PySCF 中,还可以通过内坐标等方式定义分子。

  • mol.basis = "6-31G" 是定义分子统一使用 6-31G 基组。PySCF 支持对分子中不同原子给出不同的基组的解决方案;但在这系列文档中就从简考虑。

  • mol.verbose = 0 指定 PySCF 对这个分子计算过程中的输出控制。默认值为 3,但为了减少文档长度,我们尽量静默这部分输出。

  • mol.build() 对分子进行构建。尽管一般来说,PySCF 会在进行量化计算时也会对分子进行构建,但建议平时写脚本时手动补上这一句。

也有一些指令可能在实际实践中经常会使用到,但在整个文档中不作考虑:

  • mol.cart = False 指定是否使用三维笛卡尔坐标形式的基组。默认是 False,即使用球谐形式的基组。默认与 Gaussian 中定义 5D 7F 是相同的。需要留意,在 Gaussian 中,6-31G 系列基组在 Gaussian 中的默认是 6D 7F,意味着在 Gaussian 中 (以及历史上的文献),6-31G 的 \(d\) 轨道与 \(f, g, h, i, ...\) 轨道是分开考虑的,且前者使用球谐基组,后者使用笛卡尔基组。

  • mol.spin 指定分子的自旋数;在整份文档中,由于我们只考虑闭壳层的情形,因此使用默认值 0

  • mol.charge 指定分子的带电数;由于我们使用的是双氧水分子而非离子,因此使用默认值 0

构型量输出

我们知道,一般来说,分子体系的能量是由电子态能量 \(E_\mathrm{elec}\) 与原子核互斥能量 \(E_\mathrm{nuc}\) 的加和构成的。如果看 Gaussian 程序的输出,SCF Done 之后的能量便是如此计算出来的。电子态的能量需要通过量化方法获得,但原子核互斥能只需要通过简单的初中物理知识就能解决。下面,我们通过计算原子核互斥能来了解 gto.Mole 类的分子构型量的输出。

首先,原子核互斥能可以表示为

\[E_\mathrm{nuc} = \frac{1}{2} \frac{Z_A Z_B}{r_{AB}}\]

回顾 Einstein Summation,上面的等式右边应当要对 \(A, B\) 有所求和。其中,对角线特殊处理的原子间距离矩阵

\[\begin{split}\begin{equation} r_{AB} = \begin{cases} \Vert \boldsymbol{A} - \boldsymbol{B} \Vert_2 & A \neq B \\ + \infty & A = B \end{cases} \end{equation}\end{split}\]

记号说明

  • 上下角标 \(A, B\):表示原子;对于双氧水,可以是两个氢、两个氧原子中的任意一个。

  • 三维向量 \(\boldsymbol{A}, \boldsymbol{B}\):表示原子三维笛卡尔坐标。以上述双氧水为例,第四个原子 (氢原子) 的坐标表示为 \((0, 0.7, 1.0)\),向量单位为 Angstrom。

  • 下角标 \(t, s, r, w\):表示三维坐标分量,取值范围 \(\{ x, y, z \}\)。以如果第四个原子被记号 \(A\) 表示,那么 \((A_x, A_y, A_z) = (0, 0.7, 1.0)\),向量单位为 Angstrom。上述公式并没有出现,但以后会非常经常地使用到。

  • 电荷标量 \(Z_A\)\(A\) 原子的核电荷数;正值,单位为 a.u.。若第二个原子 (氧原子) 被记号 \(A\) 表示,那么 \(Z_A = 8\)

记号冲突

  • 在以后,上下角标 \(A, B\) 还可能代表被求导的分子性质量。在不加说明的情况下,这种记号定义会产生混淆。以后的文档中,默认情况下会让 \(A, B\) 代表原子;代表分子性质量时会在文档开始处作说明。

  • \(r\) 在斜体的情况下,如果单独出现则表示坐标分量;但若是 \(r_t\) 的带有下标的形式,则表示电子坐标 \(\boldsymbol{r}\) 的三维分量 \((r_x, r_y, r_z)\) 之一。

  • \(w\) 在斜体的情况下,如果单独出现则表示坐标分量;但若是 \(w_g\) 的带有下标的形式,则表示 DFT 格点在 \(g\) 点处的权重。

为此,我们需要得到分子的核电荷量 \(Z_A\) 与原子核坐标量 \(\boldsymbol{A}\)。核电荷量 Z_A \(Z_A\) 可以通过下述方法获得:

[4]:
Z_A = mol.atom_charges()
Z_A
[4]:
array([8, 8, 1, 1], dtype=int32)

可以看到,这是一个关于 \(A\) 的四维整数向量,分别代表两个氧、两个氢原子的核电荷数。

原子核坐标量 A_t \(\boldsymbol{A}\) 事实上应当是关于原子 \(A\) 与其坐标分量 \(t\) 的二维张量。一般来说,以后会写作 \(A_t\)

[5]:
A_t = mol.atom_coords()
A_t
[5]:
array([[0.        , 0.        , 0.        ],
       [0.        , 0.        , 2.83458919],
       [1.88972612, 0.        , 0.        ],
       [0.        , 1.32280829, 1.88972612]])

需要注意到,上述二维张量的单位是 Bohr 半径,或者等价地,a.u.。如果我们将上述矩阵乘以 Bohr 半径到 Angstrom 的转换系数 lib.param.BOHR,那么就与我们构造分子时所使用的坐标值相同了:

[6]:
A_t * lib.param.BOHR
[6]:
array([[0. , 0. , 0. ],
       [0. , 0. , 1.5],
       [1. , 0. , 0. ],
       [0. , 0.7, 1. ]])

原子间距离矩阵 r_AB \(r_{AB}\) 可以通过下述循环获得,其中 mol.natm 表示分子的原子数目,对于双氧水而言则是 4:

[7]:
r_AB = np.empty((mol.natm, mol.natm))
for A in range(mol.natm):
    for B in range(mol.natm):
        if A != B:
            r_AB[A, B] = np.linalg.norm(A_t[A] - A_t[B])
        else:
            r_AB[A, B] = np.infty
r_AB
[7]:
array([[       inf, 2.83458919, 1.88972612, 2.3067047 ],
       [2.83458919,        inf, 3.40675222, 1.62560388],
       [1.88972612, 3.40675222,        inf, 2.98193753],
       [2.3067047 , 1.62560388, 2.98193753,        inf]])

我们已经凑齐了所有计算 E_nuc \(E_\mathrm{nuc}\) 所需要的所有要素了,随后便是简单的整合:

[8]:
E_nuc = 0.5 * np.einsum("A, B, AB ->", Z_A, Z_A, 1 / r_AB)
E_nuc
[8]:
37.88467440864127

在 PySCF 中,存在函数 mol.energy_nuc 用于计算原子核排斥能 \(E_\mathrm{nuc}\)。我们以该函数来验证我们的结果是否正确:

[9]:
np.allclose(E_nuc, mol.energy_nuc())
[9]:
True

任务 (1)

  1. (可选) 我们刚才使用了双重循环获得 r_AB \(r_{AB}\)。但 Python 的循环效率低下,一般来说不建议使用显式的 for 循环。请尝试不使用 for,构造 r_AB;并分别对使用与不使用 for 循环的效率作评价。

    需要指出,计算原子核排斥能不是非常耗时的计算,因此使用显示 for 循环也未尝不可;但这权当是练习。

基组与电子积分

基组的表示

尽管基组已经通过 mol.basis = "6-31G" 有所定义,但显然我们还需要进一步将其转化为数字,才能让程序进行计算。获得基组的数值信息可以通过弱保护变量 mol._basis 获得:

[10]:
mol._basis
[10]:
{'O': [[0,
   [5484.6717, 0.0018311],
   [825.23495, 0.0139501],
   [188.04696, 0.0684451],
   [52.9645, 0.2327143],
   [16.89757, 0.470193],
   [5.7996353, 0.3585209]],
  [0, [15.539616, -0.1107775], [3.5999336, -0.1480263], [1.0137618, 1.130767]],
  [0, [0.2700058, 1.0]],
  [1, [15.539616, 0.0708743], [3.5999336, 0.3397528], [1.0137618, 0.7271586]],
  [1, [0.2700058, 1.0]]],
 'H': [[0,
   [18.731137, 0.0334946],
   [2.8253937, 0.23472695],
   [0.6401217, 0.81375733]],
  [0, [0.1612778, 1.0]]]}

我们简单回顾线性组合的 Gaussian 基组 (Contracted Gaussian-Type Orbital, CGTO) 的相关知识。由于我们以后不回顾这部分内容,因此记号都是临时的,并且与 Szabo 书中的记号较为相似。

对于高斯基组 CGTO,根据 (Szabo, 3.212),可以表示为 (下述等式 RHS (Reft-Hand Side of equation) 的 Einstein Summation 是关于 \(p\) 的求和)

\[\phi_\mu^\mathrm{CGTO} (\boldsymbol{r} - \boldsymbol{A}) = d_{p \mu} \phi_p^\mathrm{GTO} (\alpha_{p \mu}, \boldsymbol{r} - \boldsymbol{A})\]

上述记号中,\(\mu\) 代表用于量化计算的原子轨道,\(p\) 代表原始的 Gaussian 函数 (primitive),\(d_{p \mu}\) 代表 primitive 对 CGTO 的贡献系数;\(\alpha_{p \mu}\) 表征的是 Gaussian 函数的形状,值愈大则 Gaussian 函数愈尖锐。根据 (Szabo, 3.203),如果原子轨道对应的是角量子数为 1 的原子轨道 (\(s\) 轨道) (下述公式不能用于高角量子数的函数),那么 primitive 函数

\[\phi_{s}^\mathrm{GTO} (\alpha_{p \mu}, \boldsymbol{r} - \boldsymbol{A}) = \left( \frac{2 \alpha_{p \mu}}{\pi} \right)^{3/4} \tilde g_{s} (\alpha_{p \mu}, \boldsymbol{r} - \boldsymbol{A}) = \left( \frac{2 \alpha_{p \mu}}{\pi} \right)^{3/4} \exp(- \alpha_{p \mu} \Vert \boldsymbol{r} - \boldsymbol{A} \Vert_2^2)\]

记号说明

  • 三维向量 \(\boldsymbol{r}\) 表示电子坐标。

  • 下标 \(\mu, \nu, \kappa, \lambda\) 表示原子轨道角标。

  • 函数或格点 \(\phi\) 一般都指原子轨道。以后原子轨道的右上角会去除 \(\mathrm{CGTO}\)\(\mathrm{GTO}\) 的记号,因为一般来说不会引起歧义;但这一节会保留该记号。

记号冲突

  • \(r\) 在斜体的情况下,如果单独出现则表示坐标分量;但若是 \(r_t\) 的带有下标的形式,则表示电子坐标 \(\boldsymbol{r}\) 的三维分量 \((r_x, r_y, r_z)\) 之一。

  • \(p, q\) 在这一节中表示 primitive 角标;但以后会用来表示任意分子轨道角标,因为以后不再涉及 primitive 函数问题。

以氢原子为例,每个氢原子相当于贡献了两个原子轨道基组;这两个原子轨道可以表示为 (为了讨论方便,暂时假定这个氢原子的坐标是 \(\boldsymbol{A} = (0, 0, 0)\))

\begin{align} \phi_\mu^\mathrm{CGTO} (\boldsymbol{r}) = & + 0.0334946 \times \phi_s^\mathrm{GTO} (18.731137, \boldsymbol{r}) \\ & + 0.23472695 \times \phi_s^\mathrm{GTO} (2.8253937, \boldsymbol{r}) \\ & + 0.81375733 \times \phi_s^\mathrm{GTO} (0.6401217, \boldsymbol{r}) \\ \phi_\nu^\mathrm{CGTO} (\boldsymbol{r}) = & + 1.0 \times \phi_s^\mathrm{GTO} (0.1612778, \boldsymbol{r}) \end{align}

对应到方才输出的 mol._basis 的结果,我们指出,\(\phi_\mu^\mathrm{CGTO} (\boldsymbol{r})\) 的线性系数 \(d_{p \mu}\) 与指数系数 \(\alpha_{p \mu}\) 就是下述输出:

[11]:
mol._basis["H"][0]
[11]:
[0, [18.731137, 0.0334946], [2.8253937, 0.23472695], [0.6401217, 0.81375733]]

其中,列表的第一个值为 0,表示该轨道是 \(s\) 轨道;我们会在氧原子的基组下看到一些 primitive 列表的第一个值是 1,表示那是 \(p\) 轨道。而列表后三个值是三个子列表,分别表示三个 primitive 的指数系数 \(\alpha_{p \mu}\) 与线性系数 \(d_{p \mu}\)

对于 \(\phi_\nu^\mathrm{CGTO}\),我们也能从 mol._basis 中获取相应的信息:

[12]:
mol._basis["H"][1]
[12]:
[0, [0.1612778, 1.0]]

任务 (2)

我们假定读者已经阅读过 Szabo 书本的 1, 2, 3, 6 章,并对书中的 Appendix A 有所了解;这里的一些问题不是对这些知识的学习,而是从程序的角度上的复习。通过此练习,读者应当能对量化程序的最基石的电子积分的求取有大概的印象;在以后调用电子积分时,心里可以有些底气。

  1. 通过 mol._basis 的信息,指出双氧水分子在 6-31G 基组下的 primitive 函数数量、以及原子轨道数量。这两个值事实上可以分别通过 mol.npgto_nr()mol.nao 分别给出;请通过这两个结果验证你的答案。

  2. 请设计函数

    def integral_ovlp_gaussian_s(alpha, beta, rAB):
    """Implement your function here"""
    

    该函数代入 alpha \(\alpha\), beta \(\beta\) 作为被积分的 Gaussian 函数的指数系数,rAB \(\Vert \boldsymbol{A} - \boldsymbol{B} \Vert_2\) 作为两原子的距离,得到重叠积分

    \[\int \tilde g_{s} (\alpha, \boldsymbol{r} - \boldsymbol{A}) \tilde g_{s} (\alpha, \boldsymbol{r} - \boldsymbol{B}) \, \mathrm{d} \boldsymbol{r} = \left( \frac{\pi}{\alpha + \beta} \right)^{3 / 2} \exp \left( - \frac{\alpha \beta}{\alpha + \beta} \Vert \boldsymbol{A} - \boldsymbol{B} \Vert_2 \right)\]
  3. 请设计函数

    def integral_ovlp_primitive_s(alpha, beta, rAB):
    """Implement your function here"""
    

    该函数的输入参数与上一小题一致,但输出的结果是 primitive 基组的积分,或者对于现在的问题,等价于归一化后的 Gaussian 函数的积分:

    \[\int \phi_{s}^\mathrm{GTO} (\alpha, \boldsymbol{r} - \boldsymbol{A}) \phi_{s}^\mathrm{GTO} (\alpha, \boldsymbol{r} - \boldsymbol{B}) \, \mathrm{d} \boldsymbol{r} = \left( \frac{2 \alpha_{p \mu}}{\pi} \right)^{3/4} \left( \frac{2 \beta_{p \mu}}{\pi} \right)^{3/4} \int \tilde g_{s} (\alpha, \boldsymbol{r} - \boldsymbol{A}) \tilde g_{s} (\alpha, \boldsymbol{r} - \boldsymbol{B}) \, \mathrm{d} \boldsymbol{r}\]
  4. 根据下述的氢原子的原子轨道的表述

    \[\begin{split}\begin{align} \phi_\mu^\mathrm{CGTO} (\boldsymbol{r}) = & + 0.0334946 \times \phi_s^\mathrm{GTO} (18.731137, \boldsymbol{r}) \\ & + 0.23472695 \times \phi_s^\mathrm{GTO} (2.8253937, \boldsymbol{r}) \\ & + 0.81375733 \times \phi_s^\mathrm{GTO} (0.6401217, \boldsymbol{r}) \\ \phi_\nu^\mathrm{CGTO} (\boldsymbol{r}) = & + 1.0 \times \phi_s^\mathrm{GTO} (0.1612778, \boldsymbol{r}) \end{align}\end{split}\]

    请尝试利用上述函数,计算下述积分:\((\mu | \nu)\)\((\mu | \mu)\)\((\nu | \nu)\)。其中,\(\mu, \nu\) 都属于相同的氢原子。你也可以尝试构建一个用于 \(s\) 的原子轨道的积分函数来解决该问题。对于 \((\mu | \mu)\)\((\nu | \nu)\),你可以使用原子轨道的归一性,推断其积分的值为 1。

  5. 如果现在 \((\mu | \nu)\)\((\mu | \mu)\)\((\nu | \nu)\) 竖线左右的原子轨道分属于上面定义的双氧水的两个氢原子上 (相距大约 1.578 Angstrom,但读者应当能用程序给出距离的精确值);那么积分的值是多少?

上述题目的结果应当能通过后文会作说明的 mol.intor("int1e_ovlp")[18:, 18:] 的结果来验证。

注意上述的积分对非 \(s\) 轨道譬如 \(p, d, f, \cdots\) 轨道,是不适用的。因此,上述的结论不能推广到所有的氧原子的原子轨道。任意角量子数的电子积分的学习与实践将是非常考验代数、程序实践与调试、程序效率提升等能力,一般的量化方法开发者与发展者应当不需要对此有很深的认识。

任务 (3)

  1. 请前往 Basis Set Exchange,找到氢与氧原子的 6-31G 基组,并与 mol._basis 的输出进行比对。以后也许会遇到需要手动设置基组的情况;Basis Set Exchange 可以满足通常基组的需求。

  2. 简单回顾一下 6-31G 基组的意义。6-31G 基组可以看作 Double-zeta 基组;zeta 指 Slater 函数的指数系数。以氧原子为例,其 \(1s\) 轨道仍然是 Single-zeta 的,并且这个 Single-zeta 使用 6 个 primitive 组合而成;\(2s\)\(2p\) 轨道是 Double-zeta 的,其中一个 zeta 使用 3 个 primitive 组合,而另 1 个 zeta 则用 1 个 primitive 表示。尽管 6-31G 被称为 Double-zeta,但有两处有些名不副实。一来,Double-zeta 只针对价层轨道,内层轨道仍然是 Single-zeta 的。二来,zeta 在 STO-3G 有明确的与 Slater 函数的对应关系,但 6-31G 基组的参数却是从拟合原子能量获得的,不是真正的对 Slater 函数的拟合;换言之不是真正的 Slater 函数的参数 \(\zeta\)

    现在问,对于氧原子的每个 6-31G 的 \(s\) 轨道基组 (一共有 3 个),有几个原子轨道在全空间没有节面 (类似于 \(1s\) 轨道),有几个原子轨道有一个球形节面 (类似于 \(2s\) 轨道)?

电子积分

在上面的任务 (2) 中,我们通过现成的公式计算了氢原子之间重叠积分。在 PySCF 中,包括重叠积分的各种电子积分,在不考虑效率的情况下,可以使用 mol.intor 函数给出。

[13]:
S = mol.intor("int1e_ovlp")

该重叠积分的两个维度大小均是双氧水分子的原子轨道数量:

[14]:
S.shape
[14]:
(22, 22)

除了重叠积分,我们还可以获得各种各样的积分;譬如动能积分

[49]:
T = mol.intor("int1e_kin")
T.shape
[49]:
(22, 22)

不仅是矩阵类型的积分,张量的积分也是可以导出的,譬如双电子积分

[15]:
eri = mol.intor("int2e")

它是四维原子轨道数量的张量:

[16]:
eri.shape
[16]:
(22, 22, 22, 22)

一般来说,有上述积分后,若不考虑效率与收敛问题,一个粗略的 HF 自洽场计算就是可以实现的了。

但不是所有积分的维度都一定与原子轨道数量相关。对于负值的偶极积分,它是三维张量,第一维度代表笛卡尔坐标的三个分量:

[17]:
mdip = mol.intor("int1e_r")
mdip.shape
[17]:
(3, 22, 22)

所有种类的电子积分调用方式可以参考 PySCF 文档。显然在梯度求取过程中,不会仅仅使用上面叙述的积分;所需要的积分、包括这些积分的公式符号,会在以后的文档中作说明。

壳层分割与原子轨道分割

获得各种电子积分后,尽管我们确实可以做各种量化计算,但这些积分的每个元素代表何种意义?这就需要使用 PySCF 中的分割函数 mol.aoslice_by_atom 来辅助我们理解。

[21]:
mol.aoslice_by_atom()
[21]:
array([[ 0,  5,  0,  9],
       [ 5, 10,  9, 18],
       [10, 12, 18, 20],
       [12, 14, 20, 22]])

上述列表的值作如下解释:

  • 列表 4 行、4 列。其中,4 行是指原子数量为 4;而列数是固定的。

  • 第一行 [0, 5, 0, 9] 是第一个氧原子的分割;我们知道,氧原子的 6-31G 基组包含 5 种不同的基函数,其中 3 个为 \(s\) 轨道,2 个为 \(p\) 轨道;因此,5 称为氧原子的壳层数 (Shell)。列表的前两个元素 0, 5 表示第一个氧原子壳层的起始与终止位置。

  • 我们也知道,氧原子 6-31G 基组包含 9 个原子轨道,其中 3 个为 \(s\) 轨道,6 个分别为 2 个 \(p\) 壳层所派生出来的 \(p_x, p_y, p_z\) 轨道。列表后两个元素 0, 9 表示第一个氧原子的原子轨道的起始与终止位置。

  • 第二行 [5, 10, 9, 18] 是第二个氧原子的分割。我们能发现其中的起始位置 5, 9 与第一个氧原子分割的终止位置相同。注意这里使用 Python 尾后指针或索引的习惯,因此第二个氧原子的壳层包含的数值是 5, 6, 7, 8, 9,不包含 10。对于原子轨道分割也作相同的理解。

  • 最后一行 [12, 14, 20, 22] 中,我们能知道分子总壳层数是 14,而原子轨道总数是 22。

我们方才求取了两个氢原子轨道的重叠积分。根据上述列表,我们应当知道,这两个氢原子的的原子轨道序号分别是 18, 19 与 20, 21;因此任务 (2) 中有关重叠积分计算的答案应当在下述矩阵中:

[22]:
mol.intor("int1e_ovlp")[18:22, 18:22]
[22]:
array([[1.        , 0.65829205, 0.0410283 , 0.20486272],
       [0.65829205, 1.        , 0.20486272, 0.48819655],
       [0.0410283 , 0.20486272, 1.        , 0.65829205],
       [0.20486272, 0.48819655, 0.65829205, 1.        ]])

壳层的分割以后会用得比较少,但原子轨道的分割会经常使用到。我们以后会经常使用以下的函数来调取原子轨道分割:

[28]:
def mol_slice(atm, mol=mol):
    _, _, p0, p1 = mol.aoslice_by_atom()[atm]
    return slice(p0, p1)

那么,第二个氧原子的原子轨道与分子内其它原子轨道的重叠积分就可以通过下述代码实现:

[45]:
print(S[mol_slice(1), :].shape)
S[mol_slice(1), :]
(9, 22)
[45]:
array([[ 0.00000,  0.00033,  0.02046,  0.00000,  0.00000,  0.00106,  0.00000,  0.00000,  0.05835,  1.00000,  0.23369,
         0.16728,  0.00000,  0.00000,  0.00000,  0.00000,  0.00000,  0.00000,  0.00025,  0.01847,  0.05133,  0.07625],
       [ 0.00033,  0.02130,  0.14113,  0.00000,  0.00000,  0.04048,  0.00000,  0.00000,  0.32436,  0.23369,  1.00000,
         0.76364,  0.00000,  0.00000,  0.00000,  0.00000,  0.00000,  0.00000,  0.00920,  0.12048,  0.32292,  0.41501],
       [ 0.02046,  0.14113,  0.33799,  0.00000,  0.00000,  0.12857,  0.00000,  0.00000,  0.49783,  0.16728,  0.76364,
         1.00000,  0.00000,  0.00000,  0.00000,  0.00000,  0.00000,  0.00000,  0.08429,  0.29491,  0.48399,  0.72901],
       [ 0.00000,  0.00000,  0.00000,  0.00955,  0.00000,  0.00000,  0.08729,  0.00000,  0.00000,  0.00000,  0.00000,
         0.00000,  1.00000,  0.00000,  0.00000,  0.50152,  0.00000,  0.00000,  0.00923,  0.04778,  0.00000,  0.00000],
       [ 0.00000,  0.00000,  0.00000,  0.00000,  0.00955,  0.00000,  0.00000,  0.08729,  0.00000,  0.00000,  0.00000,
         0.00000,  0.00000,  1.00000,  0.00000,  0.00000,  0.50152,  0.00000,  0.00000,  0.00000,  0.28199,  0.11808],
       [-0.00106, -0.04048, -0.12857,  0.00000,  0.00000, -0.07073,  0.00000,  0.00000, -0.21713,  0.00000,  0.00000,
         0.00000,  0.00000,  0.00000,  1.00000,  0.00000,  0.00000,  0.50152, -0.01384, -0.07167, -0.20142, -0.08434],
       [ 0.00000,  0.00000,  0.00000,  0.08729,  0.00000,  0.00000,  0.33799,  0.00000,  0.00000,  0.00000,  0.00000,
         0.00000,  0.50152,  0.00000,  0.00000,  1.00000,  0.00000,  0.00000,  0.11887,  0.21658,  0.00000,  0.00000],
       [ 0.00000,  0.00000,  0.00000,  0.00000,  0.08729,  0.00000,  0.00000,  0.33799,  0.00000,  0.00000,  0.00000,
         0.00000,  0.00000,  0.50152,  0.00000,  0.00000,  1.00000,  0.00000,  0.00000,  0.00000,  0.48364,  0.37477],
       [-0.05835, -0.32436, -0.49783,  0.00000,  0.00000, -0.21713,  0.00000,  0.00000, -0.39527,  0.00000,  0.00000,
         0.00000,  0.00000,  0.00000,  0.50152,  0.00000,  0.00000,  1.00000, -0.17830, -0.32487, -0.34546, -0.26769]])

任务参考解答

任务 (1)

get_r_AB_withForLoop 是原文中描述的方法:

[12]:
def get_r_AB_withForLoop(mol):
    A_t = mol.atom_coords()
    r_AB = np.empty((mol.natm, mol.natm))
    for A in range(mol.natm):
        for B in range(mol.natm):
            if A != B:
                r_AB[A, B] = np.linalg.norm(A_t[A] - A_t[B])
            else:
                r_AB[A, B] = np.infty
    return r_AB

get_r_AB_withVectorization 是不使用显示 for,或称使用向量化的方法:

[13]:
def get_r_AB_withVectorization(mol):
    A_t = mol.atom_coords()
    r_ABt = A_t[:, None, :] - A_t[None, :, :]
    r_AB = np.linalg.norm(r_ABt, axis=-1)
    r_AB += np.diag(np.ones(mol.natm) * np.inf)
    return r_AB

两者的结果是相同的:

[14]:
np.allclose(get_r_AB_withForLoop(mol), get_r_AB_withVectorization(mol))
[14]:
True

显然,不使用显式 for 的语句执行速度会快不少:

[15]:
%%timeit -r 10 -n 1000
get_r_AB_withForLoop(mol)
86.5 µs ± 2.83 µs per loop (mean ± std. dev. of 10 runs, 1000 loops each)
[16]:
%%timeit -r 10 -n 1000
get_r_AB_withVectorization(mol)
35.8 µs ± 4.03 µs per loop (mean ± std. dev. of 10 runs, 1000 loops each)

任务 (2)

任务 (2.1)

这个任务的本意实际上是希望读者能从 mol._basis 的输出,心算判断 primitive 与原子轨道数量。但如果希望有一种准确无误的计算方法,还是需要写一个小程序。这样一个小程序尽管不难,但需要耗费一些代码。读者可以向其中插入一些打印语句来获取一些被迭代对象是什么。

[17]:
# construct dict that stores primitive and ao number for atoms
bas_number_dict = {}
for atom, bas_list in mol._basis.items():
    primitive_number = 0
    ao_number = 0
    for ao in bas_list:
        ao_shape = ao[0]  # 0: s; 1: p; 2: d; 3: f ...
        # since we use spherical atomic orbital basis,
        # one defined basis contribute to atomic orbital numbers are
        # s: 1, p: 3, d: 5; f: 7, ...
        ao_number += 2 * ao_shape + 1
        for primitive in ao[1:]:
            primitive_number += 2 * ao_shape + 1
    bas_number_dict[atom] = {
        "primitive_number": primitive_number,
        "ao_number": ao_number
    }
bas_number_dict
[17]:
{'O': {'primitive_number': 22, 'ao_number': 9},
 'H': {'primitive_number': 4, 'ao_number': 2}}
[18]:
# calculate total primitive and ao number for H2O2 molecule
primitive_number = 0
ao_number = 0
for atm_id in range(mol.natm):  # mol.natm = 4 for H2O2
    atm_symbol = mol.atom_symbol(atm_id)  # return "O" or "H" for H2O2
    primitive_number += bas_number_dict[atm_symbol]["primitive_number"]
    ao_number += bas_number_dict[atm_symbol]["ao_number"]
print("H2O2 primitive number: ", primitive_number)
print("H2O2 ao        number: ", ao_number)
H2O2 primitive number:  52
H2O2 ao        number:  22

在 PySCF 中,他们可以通过下述函数或属性直接生成:

[19]:
print("H2O2 primitive number: ", mol.npgto_nr())
print("H2O2 ao        number: ", mol.nao)
H2O2 primitive number:  52
H2O2 ao        number:  22
任务 (2.2)
[20]:
def integral_ovlp_gaussian_s(alpha, beta, rAB):
    return (np.pi / (alpha + beta))**(3/2) * np.exp(- alpha * beta / (alpha + beta) * rAB**2)
任务 (2.3)
[21]:
def integral_ovlp_primitive_s(alpha, beta, rAB):
    return (2 * alpha / np.pi)**(3/4) * (2 * beta / np.pi)**(3/4) * integral_ovlp_gaussian_s(alpha, beta, rAB)
任务 (2.4)

我们首先看正确答案应该是多少。如果用原子轨道的归一化来判断结果,那么 \((\mu | \mu)\)\((\nu | \nu)\) 都应当为 1。另外,如果已经看完这一份笔记,那么应该知道 mol.intor("int1e_ovlp")[18:, 18:] 记录的是与这两个氢原子有关的重叠积分,并且应当能判断 mol.intor("int1e_ovlp")[18, 19] 即 0.65829205 是 \((\mu | \nu)\) 的值。

[22]:
mol.intor("int1e_ovlp")[18:, 18:]
[22]:
array([[1.        , 0.65829205, 0.0410283 , 0.20486272],
       [0.65829205, 1.        , 0.20486272, 0.48819655],
       [0.0410283 , 0.20486272, 1.        , 0.65829205],
       [0.20486272, 0.48819655, 0.65829205, 1.        ]])

下面我们写一个计算 CGTO 的原子轨道积分的函数:

[23]:
def integral_ovlp_ao_s(alpha_orb_list, beta_orb_list, rAB):
    integral = 0.
    for alpha, coef_a in alpha_orb_list:
        for beta, coef_b in beta_orb_list:
            integral += coef_a * coef_b * integral_ovlp_primitive_s(alpha, beta, rAB)
    return integral

其中,对于 \(\mu\) 轨道,alpha_orb_list 即是

[24]:
mol._basis["H"][0][1:]
[24]:
[[18.731137, 0.0334946], [2.8253937, 0.23472695], [0.6401217, 0.81375733]]

上面的列表的构造在前文叙述过:每一个子列表代表一个 primitive 基组;每一个 primitive 基组的第一个值是 \(\alpha\),而第二个值是 CGTO 的系数 \(d_{p \mu}\)。由于这一小题中,我们指定了两个原子轨道属于同一原子,因此两原子轨道的原子中心距离 \(\Vert \boldsymbol{A} - \boldsymbol{B} \Vert_2 = 0\)。可能有一些精度上的误差。

[25]:
integral_ovlp_ao_s(mol._basis["H"][0][1:], mol._basis["H"][0][1:], 0)  # (\mu | \mu) = mol.intor("int1e_ovlp")[18, 18]
[25]:
1.0000000284768251
[26]:
integral_ovlp_ao_s(mol._basis["H"][0][1:], mol._basis["H"][1][1:], 0)  # (\mu | \nu) = mol.intor("int1e_ovlp")[18, 19]
[26]:
0.6582920587123634
[27]:
integral_ovlp_ao_s(mol._basis["H"][1][1:], mol._basis["H"][1][1:], 0)  # (\nu | \nu) = mol.intor("int1e_ovlp")[19, 19]
[27]:
0.9999999999999999
任务 (2.5)

这一小题与上一小题的唯一区别是,现在被积分的两个原子轨道分属于两个不同的原子;我们只要指定这两个原子距离 rHH 即可。注意这个距离的单位是 a.u.,或 Bohr 半径,不是 Angstrom。

[28]:
rHH = np.linalg.norm(mol.atom_coords()[2] - mol.atom_coords()[3])
[29]:
integral_ovlp_ao_s(mol._basis["H"][0][1:], mol._basis["H"][0][1:], rHH)  # (\mu | \mu) = mol.intor("int1e_ovlp")[18, 20]
[29]:
0.04102829995317654
[30]:
integral_ovlp_ao_s(mol._basis["H"][0][1:], mol._basis["H"][1][1:], rHH)  # (\mu | \nu) = mol.intor("int1e_ovlp")[18, 21]
[30]:
0.20486272719145937
[31]:
integral_ovlp_ao_s(mol._basis["H"][1][1:], mol._basis["H"][1][1:], rHH)  # (\nu | \nu) = mol.intor("int1e_ovlp")[19, 21]
[31]:
0.488196553296388

任务 (3)

任务 (3.2)

所有三个基组都是没有空间节面的,类似于 \(1s\) 轨道。

对于氧原子,6-31G 的 6, 3, 1 分别对应下述的基组输出:

[32]:
print("`6` primitive number:", len(mol._basis["O"][0][1:]))
print(mol._basis["O"][0])
print("`3` primitive number:", len(mol._basis["O"][1][1:]))
print(mol._basis["O"][1])
print("`1` primitive number:", len(mol._basis["O"][2][1:]))
print(mol._basis["O"][2])
`6` primitive number: 6
[0, [5484.6717, 0.0018311], [825.23495, 0.0139501], [188.04696, 0.0684451], [52.9645, 0.2327143], [16.89757, 0.470193], [5.7996353, 0.3585209]]
`3` primitive number: 3
[0, [15.539616, -0.1107775], [3.5999336, -0.1480263], [1.0137618, 1.130767]]
`1` primitive number: 1
[0, [0.2700058, 1.0]]

经常地,我们会说,6-31G 基组的 6 模拟内层 \(1s\) 轨道,而 31 通过两个原子轨道模拟价层 \(2s\)\(2p\) 轨道。此话不假,但用来模拟 \(2s\) 轨道的函数事实上没有空间的节面。我们实际上会指望用 631 三个基组的某种线性组合,达到产生有节面的函数的效果;而这种线性组合就交给量化方法通过变分法来给出。这也在 (Szabo, 3.301-302) 公式中有所反映。

事实上,任务 (3.2) 使用了误导性的问法。我们需要对“原子轨道”一词作说明。

用语解释

“原子轨道”(Atomic Orbital) 一词在通篇文档中表示的是 CGTO 基组。尽管这个概念来源于物理,但它现在是一个纯粹的量化计算上的用语。这与通常意义下的原子轨道 (氢原子本征函数) 并不相同。

RHF 自洽场计算

从这一节开始,我们将真正地开始量化方法的学习。最为基础、但也最为重要的量化方法是 RHF 自洽场计算。我们依靠 RHF 的实现过程来了解公式记号,以及电子积分、基组在实际问题中的应用。

同时,PySCF 作为易于扩充的量化程序包,实现了非常庞大的量化方法;RHF 是其中的一个。我们将简单地了解 PySCF 对 RHF 的实现、以及其中的一些函数接口。

[1]:
import numpy as np
import scipy
from pyscf import scf, gto

from pkg_resources import resource_filename
from pyxdh.Utilities import FormchkInterface
from pyxdh.Utilities.test_molecules import Mol_H2O2

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

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

任务 (1)

  1. 上面的代码块是引入外部程序以及作文档初始化的代码块;请解释上述代码的每一行的意义。

    不同的文档会有不同的初始化代码块,即使这些代码块可能看起来一样。请在阅读一份新的文档之前检查第一个代码块与其它文档是否有不同。

量化软件的自洽场计算

PySCF 计算

我们先作一个 PySCF 的自洽场计算。我们以后会一直使用下面的 \(C_1\) 对称的 6-31G 基组的双氧水分子 mol 作为范例:

[2]:
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()
[2]:
<pyscf.gto.mole.Mole at 0x7f60e4096ac8>

而 PySCF 的 RHF 自洽场可以通过下述代码实现:

[3]:
scf_eng = scf.RHF(mol)
scf_eng.conv_tol = 1e-12
scf_eng.conv_tol_grad = 1e-10
scf_eng.kernel()
[3]:
-150.58503378083853

注意

  1. PySCF 的自洽场、CP-HF 方程等迭代求解在失败的情况下,也不会抛出异常。如果将 mol.verbose 设到默认值,就可以看到一些警告信息。或者,我们通过下述语句来判断自洽场过程是否确实收敛。

    由于刚才给出的自洽场收敛条件过于苛刻,因此即使自洽场并没有收敛,其结果仍然可以用于定量分析。

[4]:
scf_eng.converged
[4]:
False

注意

在将来,为了简化文档的初始化代码,我们会使用 Mol_H2O2 类来初始化 mol 变量与 DFT 的格点变量 grids。其调用方式通过下述代码单元给出;其中,mol 变量的参数与方才定义的 mol 完全一致,是 6-31G 的 \(C_1\) 对称的分子;而 grids 参数则是

  • 格点数量:径向 99,球面 590 的 Lebedev 格点;

  • 截断模式:NWChem 模式 (PySCF 默认)

  • 权重分配模式:Stratmann 模式 (Gaussian 默认)

[5]:
mol = Mol_H2O2().mol
grids = Mol_H2O2().gen_grids()

Gaussian 计算

我们也可以使用 Gaussian 计算得到相同的结果。Gaussian 的计算结果储存在 pyxdh 库的资源文件夹下,其调用方式是:

[6]:
ref_fchk = FormchkInterface(resource_filename("pyxdh", "Validation/gaussian/H2O2-HF-freq.fchk"))

上述语句会读取资源文件 H2O2-HF-freq.fchk。生成上述 formchk 文件的输入卡则在 H2O2-HF-freq.gjf;其内容是

%chk=H2O2-HF-freq
#p RHF/6-31G nosymm SCF(VeryTight) Freq

H2O2 HF Frequency

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

Gaussian 计算给出的 RHF 自洽场能量用下述方法调取:

[7]:
ref_fchk.total_energy()
[7]:
-150.5850337850876

可见,PySCF 所计算得到的 RHF 能量与 Gaussian 几乎一样。

pyxdh 计算

pyxdh 也可以进行 RHF 计算;但实际上,pyxdh 的 RHF 计算就是通过 PySCF 的接口实现的。这里仅仅介绍 pyxdh 程序在计算 RHF 时的接口,以为后续梯度的计算作准备。

我们已经在 B2PLYP 型泛函极化率的计算 一小节中介绍了 pyxdh 的计算方式,这里实际上是类似的,区别仅仅是没有引入 DFT:

[8]:
from pyxdh.DerivOnce import GradSCF
config = {"scf_eng": scf_eng}
scfh = GradSCF(config)
[9]:
scfh.eng
[9]:
-150.58503378083853

其中,引入 GradSCFDipoleSCF 在当前没有讨论梯度性质时是是没有区别的。可以发现其 RHF 给出的能量与 PySCF 和 Gaussian 的是近乎相同的。

事实上,pyxdh 仅仅是读入了 PySCF 已经给出的现成的 scf_eng 类,它不进行实际的自洽场计算。pyxdh 的便利之处是提供一些梯度计算中常用到的矩阵与张量,这些便利之处在以后才会体现。

小型自洽场程序

程序代码

自洽场程序会是通常量化课程的大作业。在这里,我们借用 PySCF 已经提供的函数接口来实现小的自洽场程序。这个程序是根据 Szabo and Ostlund [SO96] 书籍上的指示 (p146) 进行最简单的 SCF 程序编写。

提醒

  1. 由于该分子已经难以通过零密度初猜来得到能量,因此这里暂且利用了 PySCF 所提供的默认初猜。

[10]:
# Block 1

S = mol.intor("int1e_ovlp")
HC = mol.intor("int1e_kin") + mol.intor("int1e_nuc")
eri = mol.intor("int2e")
X = scipy.linalg.fractional_matrix_power(S, -0.5)
# X = np.linalg.inv(np.linalg.cholesky(S).T)

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

任务 (2)

  1. (可选) 我们在上面的代码单元中已经生成了电子积分,随后我们的工作仅仅是给出一个 SCF 算法。这无外乎 Python 与 numpy 的代码书写。你完全可以尝试不看下面的代码,自己先试写一个 SCF 过程。这可以是物化研究生一年级的量化程序大作业。你可能必须要一个更好的初猜;零密度初猜似乎对双氧水分子不适用。密度矩阵的初猜可以通过以下代码得到:

    D = scf_eng.get_init_guess()
    

    上面的代码单元中,积分 int1e_ovlpint1e_kinint2e 的意义在 电子积分 一小节中提到;int1e_nuc 表示的是电子与原子核的外势积分。

    提示:你可以了解 np.linalg.eigh 函数的意义,并思考它可能对 SCF 过程的编写有何帮助。

[11]:
# Block 2

A_t = mol.atom_coords()
r_ABt = A_t[:, None, :] - A_t[None, :, :]
r_AB = np.linalg.norm(r_ABt, axis=-1)
r_AB += np.diag(np.ones(mol.natm) * np.inf)
A_charge = mol.atom_charges()[:, None] * mol.atom_charges()
E_nuc = 0.5 * (A_charge / r_AB).sum()
print("Neucleus energy   ", E_nuc, " a.u.")
Neucleus energy    37.884674408641274  a.u.
[12]:
# Block 3

D = scf_eng.get_init_guess()
D_old = np.zeros((nao, nao))
count = 0

while (not np.allclose(D, D_old)):
    if count > 500:
        raise ValueError("SCF not converged!")
    count += 1
    D_old = D
    F = HC + np.einsum("uvkl, kl -> uv", eri, D) - 0.5 * np.einsum("ukvl, kl -> uv", eri, D)
    Fp = X.T @ F @ X
    e, Cp = np.linalg.eigh(Fp)
    C = X @ Cp
    D = 2 * C[:, so] @ C[:, so].T

E_elec = (HC * D).sum() + 0.5 * np.einsum("uvkl, uv, kl ->", eri, D, D) - 0.25 * np.einsum("ukvl, uv, kl ->", eri, D, D)
E_tot = E_elec + E_nuc

print("SCF Converged in  ", count, " loops")
print("Electronic energy ", E_elec, " a.u.")
print("Total energy      ", E_tot, " a.u.")
print("----------------- ")
print("Energy allclose   ", np.allclose(E_tot, scf_eng.e_tot))
print("Density allclose  ", np.allclose(D, scf_eng.make_rdm1()))
SCF Converged in   124  loops
Electronic energy  -188.46970818948094  a.u.
Total energy       -150.58503378083967  a.u.
-----------------
Energy allclose    True
Density allclose   True

从 Total energy 的输出来看,我们能发现我们给出了正确的 RHF 能量。下面我们对代码进行说明。

记号说明

Block 1 中,我们定义了三个电子积分、\(X_{\mu \nu}\) 矩阵以及与维度有关的量。除去分子轨道数,其余都是只与分子和基组有关的量。而一般来说,只要没有原子轨道线性依赖的情况,一般的程序都会定义分子轨道数与原子轨道基组数一致。

  • S \(S_{\mu \nu}\),或 int1e_ovlp 指交换积分,其在 Szabo (3.136) 定义

  • HC \(h_{\mu \nu}\) 为动能积分 int1e_kin 与核排斥积分 int1e_nuc 之和,其在 Szabo (3.149) 定义

  • eri \((\mu \nu | \kappa \lambda)\)int2e 指双电子互斥积分,其在 Szabo (Table 2.2) 定义,采用 Chemistry Convention

  • natm 表示原子数

  • nmo 表示分子轨道数,以后默认与原子轨道数相等,但一般地,根据表达式总应当能区分我们应该采用原子轨道还是分子轨道

  • nao 表示原子轨道数

  • nocc 为占据轨道数;以后会出现 nvir,为非占轨道数

  • so 为占据分子轨道分割;以后会出现 sv 为非占分子轨道分割,以及 sa 为全部分子轨道分割

  • X 只在自洽场过程中出现,以后将不再使用;但会对该记号赋予新的意义 (密度的 U 偏导有关量)。在这一节中,其表达式为 \(X_{\mu \nu}\),并满足关系式 (Szabo 3.165)

    \[X_{\kappa \mu} S_{\kappa \lambda} X_{\lambda \nu} = \delta_{\mu \nu}\]

记号说明

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

  • \(i, j, k, l\) 代表分子轨道,但出于程序编写需要 \(k, l\) 尽量不应与 \(\kappa, \lambda\) 同时出现

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

  • \(p, q, r, s, m\) 代表全分子轨道,但 \(r, s\) 的使用需要尽量避免,因与下述坐标分量记号冲突

  • \(t, s, r, w, x\) 代表坐标分量;一般 \(t, s\) 特指原子坐标分量,\(r, w, x\) 特指电子坐标分量;坐标分量的三种可能取向是 \(x, y, z\) 方向

  • \(A, B, M\) 代表原子;其中 \(M\) 一般是被求和的角标

  • \(\boldsymbol{A}, \boldsymbol{B}, \boldsymbol{M}\) 代表原子坐标的三维向量,区别于普通斜体字母

  • \(A_t, B_s\) 代表原子坐标的坐标分量,区别于 \(\boldsymbol{A}, \boldsymbol{B}\) 作为向量,也区别于 \(t, s\) 单纯地是坐标分量

任务 (3)

  1. 尝试验证关系式 \(X_{\kappa \mu} S_{\kappa \lambda} X_{\lambda \nu} = \delta_{\mu \nu}\)

在代码单元 Block 2 中,我们计算了核排斥能。其中,

  • r_AB \(r_{AB}\) 表示原子间的欧氏距离,在 构型量输出 一小节中提及:

    \[\begin{split}\begin{equation} r_{AB} = \begin{cases} \Vert \boldsymbol{A} - \boldsymbol{B} \Vert_2 & A \neq B \\ + \infty & A = B \end{cases} \end{equation}\end{split}\]
  • A_charge 表示量原子的电荷乘积:

    \[Z_{AB} = Z_A Z_B\]
  • E_nuc 为原子核排斥能,以 a.u. 为单位:

    \[E_\mathrm{nuc} = \frac{1}{2} Z_{AB} r_{AB}^{-1}\]

代码说明

随后我们考虑实际执行计算的 Block 3

  • Line 3

    D = scf_eng.get_init_guess()
    

    是除了电子积分外唯一使用 PySCF 的代码,它给一个合理的初猜 D \(D_{\mu \nu}\)

  • Line 12

    F = HC + np.einsum("uvkl, kl -> uv", eri, D) - 0.5 * np.einsum("ukvl, kl -> uv", eri, D)
    

    定义了 Fock 矩阵 F

    \[F_{\mu \nu} [D_{\kappa \lambda}] = h_{\mu \nu} + (\mu \nu | \kappa \lambda) D_{\kappa \lambda} - \frac{1}{2} (\mu \lambda| \kappa \nu) D_{\kappa \lambda}\]
  • Line 16

    D = 2 * C[:, so] @ C[:, so].T
    

    通过使用占据轨道分割 so,更新了密度矩阵 D

    \[D_{\mu \nu} = 2 C_{\mu i} C_{\nu i}\]
  • Line 18

    E_elec = (HC * D).sum() + 0.5 * np.einsum("uvkl, uv, kl ->", eri, D, D) - 0.25 * np.einsum("ukvl, uv, kl ->", eri, D, D)
    

    使用 SCF 收敛后的密度计算总能量 E_elec

    \[E_\mathrm{elec} [D_{\kappa \lambda}] = h_{\mu \nu} D_{\mu \nu} + \frac{1}{2} D_{\mu \nu} (\mu \nu | \kappa \lambda) D_{\kappa \lambda} - \frac{1}{4} D_{\mu \nu} (\mu \lambda| \kappa \nu) D_{\kappa \lambda}\]

至此,我们基本了解了 RHF 的实现过程。下面我们讨论一些更为细节的问题。

Hamiltonian Core 积分详述

我们刚才提到,在 \(h_{\mu \nu}\) 中,有动能的贡献量 \(t_{\mu \nu} = \langle \mu | \hat t | \nu \rangle\) 与核排斥能的贡献量 \(v_{\mu \nu} = \langle \mu | \hat v_\mathrm{nuc} | \nu \rangle\)。这两种积分可以通过更为底层的方式获得;特别是对于核排斥能的贡献量的理解,将会直接地影响到以后对 Hamiltonian Core 的原子核坐标梯度、二阶梯度的理解。

动能积分

动能积分可以写为

\[t_{\mu \nu} = \langle \mu | \hat t | \nu \rangle = - \frac{1}{2} \phi_\mu \cdot (\partial_r^2 \phi_\nu) = - \frac{1}{2} \phi_\mu \phi_{r r \nu}\]

记号说明

  • \(\phi\) 统一代表原子轨道函数,以电子坐标为自变量

  • \(\phi_\mu\) 代表原子轨道 \(\mu\) 所对应的原子轨道函数

  • \(\phi_{r \mu} = \partial_r \phi_\mu\) 代表原子轨道在电子坐标分量 \(r\) 下的偏导数

  • \(\phi_{r w \mu} = \partial_r \partial_w \phi_\mu\) 代表原子轨道在电子坐标分量 \(r\)\(w\) 下的二阶偏导数

  • \(\boldsymbol{r}\) 作为加粗的 r 代表电子坐标;区别于电子坐标分量 \(r\) 是一维变量,\(\boldsymbol{r}\) 为三维向量

一般来说,如果一个表达式看起来是函数表达式,那么我们默认对其进行积分或者格点求和.譬如上式若不使用 Einstein Summation,则表达结果是是

\[t_{\mu \nu} = - \frac{1}{2} \int \phi_{\mu} (\boldsymbol{r}) \nabla_{\boldsymbol{r}}^2 \phi_{\nu} (\boldsymbol{r}) \, \mathrm{d} \boldsymbol{r}\]

在 PySCF 的积分引擎中,一个积分选项是生成关于 \((r, w, \mu, \nu)\) 的 AO 积分张量 int1e_ipipovlp \(\langle \partial_r \partial_w \mu | \nu \rangle = \phi_{r w \mu} \phi_\nu\);我们可以对上述张量在 \(w = r\) 的情形求和,转置 \(\mu, \nu\),并乘以系数 \(-0.5\),就得到了动能积分 \(t_{\mu \nu}\) 了:

[13]:
int1e_ipipovlp = mol.intor("int1e_ipipovlp").reshape((3, 3, nao, nao))
np.allclose(
    - 0.5 * (int1e_ipipovlp.diagonal(axis1=0, axis2=1).sum(axis=2)).T,
    mol.intor("int1e_kin")
)
[13]:
True

任务 (4)

  1. 上述代码单元中使用的是 sum(axis=2),为什么?使用 sum(axis=0) 是否正确?使用 sum(axis=-1) 是否正确?

  2. 我们还可以用另一种方法生成动能积分.现定义 int1e_ipovlpip\(\langle \partial_r \mu | \partial_w \nu \rangle\),请解释下述代码块为何输出 True?

    提示 1:算符 \(\partial_r\) 是,厄米算符还是反厄米算符?为什么?

    提示 2:动能算符为何是厄米算符?

    提示 3:上述代码中,如果不转置 \(\mu, \nu\),即上述代码块的第三行末尾不加 .T,结果是否正确?

    对这些问题的了解将会允许我们更清晰地理解 AO 积分的对称性,辅助验证程序与公式的正确性,并辅助我们推导与核排斥势有关的导数.

[14]:
int1e_ipovlpip = mol.intor("int1e_ipovlpip").reshape((3, 3, nao, nao))
np.allclose(
    0.5 * (int1e_ipovlpip.diagonal(axis1=0, axis2=1).sum(axis=-1)),
    mol.intor("int1e_kin"))
[14]:
True

核排斥势积分

势能积分可以写为 (下式作为 Einstein Summation,等号左右都已对原子 \(M\) (以及对应的原子坐标 \(\boldsymbol{M}\)) 求和)

\[v_{\mu \nu}^\mathrm{nuc} = \langle \mu | \frac{- Z_M}{|\boldsymbol{r} - \boldsymbol{M}|} | \nu \rangle = \langle \mu | \frac{- Z_M}{|\boldsymbol{r}|} | \nu \rangle_{\boldsymbol{r} \rightarrow M} = \left( \frac{- Z_M}{|\boldsymbol{r}|} \phi_\mu \phi_\nu \right)_{\boldsymbol{r} \rightarrow M}\]

记号说明

  • 下标 \(\boldsymbol{r} \rightarrow M\) 代表电子积分的原点取在原子 \(M\) 的坐标上.

在 PySCF 的积分引擎中,\(\langle \mu | \frac{1}{|\boldsymbol{r}|} | \nu \rangle\) 的积分选项是 int1e_rinv;但其积分原点仍然是 \((0, 0, 0)\)。为了让特定原子坐标成为原点,PySCF 的一个便利函数是 gto.Mole.with_rinv_as_nucleus;它通过传入原子序号,将积分时的原点坐标更变为当前原子的坐标;但除了特定积分以外,分子的所有性质,包括坐标,都保持不变。

[15]:
v = np.zeros((nao, nao))
for M in range(natm):
    with mol.with_rinv_as_nucleus(M):
        v += - mol.atom_charge(M) * mol.intor("int1e_rinv")
np.allclose(v, mol.intor("int1e_nuc"))
[15]:
True

RHF 能量实现参考

在这里以及以后,实现参考一节将会展示不同的实现手段;这可能包括使用 PySCF 的高级函数,或者使用我们手写的 Python 脚本;并与当前的计算结果进行对照。

这份笔记的初衷有二:其一是记录非自洽 DFT 的计算方式;其二是尽可能只使用 PySCF 的积分、泛函与基组库,但不使用高级函数来构建我们的工作;即使使用高级函数,这些高级函数也已经通过其它实现参考用底层方法得以说明。

提示

一些不太容易编写,或者与效率有很强关联的程序,我们可能只叙述其原理,但最终还是会使用 PySCF 的库函数。SCF 过程和导出量、双电子积分函数、以及 pyscf.scf.cphf.solve 函数将会是其中几个例子。

分子轨道系数 \(C_{\mu p}\)

PySCF 实现

PySCF 通过 mo_coeff 方法给出 \(C_{\mu p}\)

[16]:
np.allclose(np.abs(scf_eng.mo_coeff), np.abs(C), atol=1e-6, rtol=1e-4)
[16]:
True

pyxdh 实现

pyxdh 可以通过 C 属性给出 \(C_{\mu p}\)

[17]:
np.allclose(np.abs(scfh.C), np.abs(C), atol=1e-6, rtol=1e-4)
[17]:
True

任务 (5)

  1. 如果对上述两个代码块去除 np.abs,即不对不同方法的 \(C_{\mu p}\) 取绝对值进行比较,是否还能给出 True 的结果?为什么?

  2. 系数矩阵 \(C_{\mu p}\) 与重叠矩阵 \(S_{\mu \nu}\) 之间存在特殊的关系。请给出两者在某种矩阵乘法下给出单元矩阵的关系式。

    提示:可以利用 \(X_{\kappa \mu} S_{\kappa \lambda} X_{\lambda \nu} = \delta_{\mu \nu}\)

pyxdh 还直接给出占据轨道、非占轨道下的分子轨道系数 CoCv;他们是根据占据轨道、非占轨道的分割 sosv 给出的:

[18]:
print(scfh.Co.shape)
print(scfh.Cv.shape)
print(np.allclose(scfh.Co, scfh.C[:, scfh.so]))
print(np.allclose(scfh.Cv, scfh.C[:, scfh.sv]))
(22, 9)
(22, 13)
True
True

电子态密度 \(D_{\mu \nu}\)

\[D_{\mu \nu} = 2 C_{\mu i} C_{\nu i}\]

PySCF 实现

PySCF 通过 make_rdm1 成员函数给出:

[19]:
np.allclose(scf_eng.make_rdm1(), D)
[19]:
True

pyxdh 实现

pyxdh 可以通过 D 属性给出:

[20]:
np.allclose(scfh.D, D)
[20]:
True

Hamiltonian Core 积分 \(h_{\mu \nu}\)

\[h_{\mu \nu} = t_{\mu \nu} + v_{\mu \nu}^\mathrm{nuc}\]

PySCF 实现

PySCF 可以通过 get_hcore 成员函数给出 \(h_{\mu \nu}\)

[21]:
scf_eng.get_hcore.__func__
[21]:
<function pyscf.scf.hf.SCF.get_hcore(self, mol=None)>
[22]:
np.allclose(scf_eng.get_hcore(), HC)
[22]:
True

pyxdh 实现

pyxdh 可以通过 H_0_ao 属性给出 \(h_{\mu \nu}\)

[23]:
np.allclose(scfh.H_0_ao, HC)
[23]:
True

同时,H_0_mo 方法给出 MO 基组下的 \(h_{pq}\)

\[h_{pq} = C_{\mu p} h_{\mu \nu} C_{\nu q}\]
[24]:
np.allclose(scfh.H_0_mo, scfh.C.T @ scfh.H_0_ao @ scfh.C)
[24]:
True

库伦积分 \(J_{\mu \nu}[X_{\kappa \lambda}]\)

\[J_{\mu \nu}[R_{\kappa \lambda}] = (\mu \nu | \kappa \lambda) R_{\kappa \lambda}\]

这里的 \(R_{\mu \nu}\) 代表的是任意矩阵;尽管在 SCF 过程中使用到库伦与交换积分处使用的是密度矩阵,但库伦积分的使用方式可以更为广泛,譬如使用广义密度而非电子态密度替代 \(R_{\kappa \lambda}\)

PySCF 实现

PySCF 可以通过 get_j 成员函数实现库伦积分:

[25]:
scf_eng.get_j.__func__
[25]:
<function pyscf.scf.hf.SCF.get_j(self, mol=None, dm=None, hermi=1)>
[26]:
R = np.random.random((nao, nao))
np.allclose(
    scf_eng.get_j(dm=R),
    np.einsum("uvkl, kl -> uv", mol.intor("int2e"), R)
)
[26]:
True

交换积分 \(K_{\mu \nu}[R_{\kappa \lambda}]\)

\[K_{\mu \nu}[R_{\kappa \lambda}] = (\mu \kappa | \nu \lambda) R_{\kappa \lambda}\]

PySCF 实现

PySCF 可以通过 get_k 成员函数实现交换积分:

注意

PySCF 中,交换积分对代入的 AO 基组广义密度矩阵有对称性要求。一般来说,我们以后工作中碰到的广义密度矩阵都是对称矩阵,因此 hermi 选项可以不设置。

[27]:
scf_eng.get_k.__func__
[27]:
<function pyscf.scf.hf.SCF.get_k(self, mol=None, dm=None, hermi=1)>
[28]:
R = np.random.random((nao, nao))
[np.allclose(
    scf_eng.get_k(dm=R, hermi=hermi),
    np.einsum("ukvl, kl -> uv", mol.intor("int2e"), R)
) for hermi in [0, 1]]
[28]:
[True, False]
[29]:
R = np.random.random((nao, nao))
R += R.T
[np.allclose(
    scf_eng.get_k(dm=R, hermi=hermi),
    np.einsum("ukvl, kl -> uv", mol.intor("int2e"), R)
) for hermi in [0, 1]]
[29]:
[True, True]

Fock 矩阵 \(F_{\mu \nu}[R_{\kappa \lambda}]\)

\[F_{\mu \nu}[R_{\kappa \lambda}] = h_{\mu \nu} + J_{\mu \nu}[R_{\kappa \lambda}] - \frac{1}{2} K_{\mu \nu}[R_{\kappa \lambda}]\]

PySCF 实现

PySCF 可以通过 get_fock 成员函数实现 Fock 矩阵;其可选参数之一是代入的矩阵 \(R_{\mu \nu}\),默认下该矩阵为 \(D_{\mu \nu}\)

注意

PySCF 中,Fock 矩阵同样对代入的广义密度矩阵有对称性要求。一般来说,我们也只处理对称矩阵。

[30]:
scf_eng.get_fock.__func__
[30]:
<function pyscf.scf.hf.get_fock(mf, h1e=None, s1e=None, vhf=None, dm=None, cycle=-1, diis=None, diis_start_cycle=None, level_shift_factor=None, damp_factor=None)>
[31]:
R = np.random.random((nao, nao))
R += R.T
print(np.allclose(
    scf_eng.get_fock(dm=R),
    scf_eng.get_hcore() + scf_eng.get_j(dm=R) - 0.5 * scf_eng.get_k(dm=R)
))
print(np.allclose(scf_eng.get_fock(), F))
True
True

pyxdh 实现

pyxdh 可以通过 F_0_ao 属性给出 Fock 矩阵,但这个 Fock 矩阵是代入的是电子态密度的确定的矩阵 \(F_{\mu \nu} = F_{\mu \nu}[D_{\kappa \lambda}]\)

[32]:
np.allclose(
    scfh.F_0_ao,
    scf_eng.get_fock()
)
[32]:
True

同时,F_0_mo 属性给出代入电子态密度的 MO 基组的 \(F_{pq}\)

\[F_{pq} = C_{\mu p} F_{\mu \nu} C_{\nu q}\]
[33]:
np.allclose(scfh.F_0_mo, scfh.C.T @ scfh.F_0_ao @ scfh.C)
[33]:
True

原子核排斥能 \(E_\mathrm{nuc}\)

\[E_\mathrm{nuc} = \frac{1}{2} Z_{AB} r_{AB}^{-1}\]

PySCF 实现

PySCF 可以通过 energy_nuc 成员函数实现排斥能:

[34]:
np.allclose(scf_eng.energy_nuc(), E_nuc)
[34]:
True

电子态能量 \(E_\mathrm{elec}[R_{\mu \nu}]\)

\[E_\mathrm{elec}[R_{\mu \nu}] = (h_{\mu \nu} + \frac{1}{2} J_{\mu \nu} [R_{\kappa \lambda}] - \frac{1}{4} K_{\mu \nu} [R_{\kappa \lambda}]) R_{\mu \nu}\]

PySCF 实现

PySCF 可以通过 energy_elec 一般有两个返回值,前者是体系总能量;而后者是双电子积分能量:

[35]:
scf_eng.energy_elec.__func__
[35]:
<function pyscf.scf.hf.energy_elec(mf, dm=None, h1e=None, vhf=None)>
[36]:
R = np.random.random((nao, nao))
R += R.T
print(np.allclose(
    scf_eng.energy_elec(dm=R)[0],
    ((scf_eng.get_hcore() + 0.5 * scf_eng.get_j(dm=R) - 0.25 * scf_eng.get_k(dm=R)) * R).sum()
))
print(np.allclose(
    scf_eng.energy_elec(dm=R)[1],
    ((0.5 * scf_eng.get_j(dm=R) - 0.25 * scf_eng.get_k(dm=R)) * R).sum()
))
True
True

如果代入的 \(R_{\mu \nu}\) 是电子态密度 \(D_{\mu \nu}\),那么返回的能量将会是方才计算的双氧水电子态能量:

[37]:
np.allclose(scf_eng.energy_elec()[0], E_elec)
[37]:
True

体系总能量 \(E_\mathrm{tot}[R_{\mu \nu}]\)

\[E_\mathrm{tot}[R_{\mu \nu}] = E_\mathrm{elec}[R_{\mu \nu}] + E_\mathrm{nuc}[R_{\mu \nu}]\]

PySCF 实现

PySCF 可以通过 energy_tot 成员函数实现体系总能量:

[38]:
scf_eng.energy_tot.__func__
[38]:
<function pyscf.scf.hf.energy_tot(mf, dm=None, h1e=None, vhf=None)>
[39]:
R = np.random.random((nao, nao))
R += R.T
print(np.allclose(
    scf_eng.energy_tot(dm=R),
    scf_eng.energy_elec(dm=R)[0] + scf_eng.energy_nuc()
))
print(np.allclose(scf_eng.energy_tot(), E_tot))
True
True

pyxdh 实现

pyxdh 实例 scfh 的属性 eng 可以给出体系总能量,但不能代入任意 \(R_{\mu \nu}\) 所给出的能量:

[40]:
np.allclose(scfh.eng, E_tot)
[40]:
True

注意

PySCF 中,Fock 矩阵同样对代入的广义密度矩阵有对称性要求。一般来说,我们也只处理对称矩阵。

轨道能量 \(\varepsilon_p\)

PySCF 实现

PySCF 可以通过 mo_energy 方法给出轨道能:

[41]:
np.allclose(scf_eng.mo_energy, e)
[41]:
True

pyxdh 实现

pyxdh 可以通过 e 属性给出:

[42]:
np.allclose(scfh.e, e)
[42]:
True

类似于系数矩阵 \(C_{\mu p}\),pyxdh 也给出占据与非占的轨道能 eoev

[43]:
print(scfh.eo.shape)
print(scfh.ev.shape)
print(np.allclose(scfh.eo, scfh.e[scfh.so]))
print(np.allclose(scfh.ev, scfh.e[scfh.sv]))
(9,)
(13,)
True
True

事实上,轨道能就是 MO 基组下 Fock 矩阵 (作为对角矩阵) 的对角元:

[44]:
np.allclose(np.diag(e), scfh.F_0_mo)
[44]:
True

轨道占据数

PySCF 实现

轨道占据数是指分子轨道的电子占据数量;对于 RHF 而言,占据轨道的每根轨道占据数为 2,而非占轨道的每根轨道占据数为 0。

[45]:
scf_eng.mo_occ
[45]:
array([2., 2., 2., 2., 2., 2., 2., 2., 2., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0.])

任务参考解答

任务 (1)

任务 (1.1)
  • np 是 numpy,我们的整个张量计算都依靠 numpy 实现。

  • scipy 用于计算稍复杂的矩阵与向量问题。尽管大多数时候我们不会使用 scipy;但这里我们需要使用矩阵的分数幂次以获得 \((\mathbf{S}^{-1/2})_{\mu \nu}\)

  • gto 是 PySCF 中用于给出基组、电子积分等信息的程序包,它们服务于各种量化计算。

  • scf 是 PySCF 中计算自洽场的程序包;RHF 是其中一种自洽场,但 scf 还支持 UHF、ROHF、X2C 等自洽场计算。它是后续的各种性质和后自洽场量化方法的基础程序。

  • resource_filename 是 Python 中用于给出程序库中的资源文件路径的函数;这是一种相对 Pythonic 的做法,可以避免使用有歧义的相对路径,以及避免跨系统平台可能会产生的问题。

  • FormchkInterface 是 pyxdh 的辅助类,用于读取 Gaussian 程序所输出的 formchk 文件。在验证结果与单元测试中,该类与 resource_filename 会经常使用。

  • Mol_H2O2 是 pyxdh 的辅助类,用于生成 H2O2 分子和一些预定义的自洽场方法。以后我们的学习都将基于这个分子。

  • partial 可以用于重新定义函数的参数表。我们在 np.einsum 的使用:中转内存设置 一节中对此作过说明。

  • np.set_printoptions 是控制 numpy 输出格式的语句;表明一般情况下,浮点数使用普通小数 (而非科学计数) 表示,且显示 5 位小数,每行显示 150 个字符。

任务 (3)

任务 (3.1)
[46]:
np.allclose(X.T @ S @ X, np.eye(nao))
[46]:
True

但请不要写为下述代码,尽管仍然会给出 True 的结果:

[47]:
np.allclose(X @ S @ X, np.eye(nao))
[47]:
True

之所以这么说,是因为 \(X_{\mu \nu}\) 的定义可以不唯一;使用 scipy.linalg.fractional_matrix_power 给出的 \(\mathbf{X} = \mathbf{S}^{-1/2}\) 是对称的。但如果使用 np.linalg.choleskynp.linalg.inv 给出 \(\mathbf{X}\),即是通过 Cholesky 分解 \(\mathbf{S} = \mathbf{L} \mathbf{L}^\dagger\) 给出的 \(\mathbf{X} = \mathbf{L^\dagger}^{-1}\)

[48]:
X_cd = np.linalg.inv(np.linalg.cholesky(S).T)
np.allclose(X_cd.T @ S @ X_cd, np.eye(nao))
[48]:
True

但这时如果用错误的代码,就会给出 False 的结果:

[49]:
np.allclose(X_cd @ S @ X_cd, np.eye(nao))
[49]:
False
[50]:
np.allclose(X_cd @ S @ X_cd.T, np.eye(nao))
[50]:
False

读者可以尝试使用 Cholesky 分解给出的 X_cd \(X_{\mu \nu}\),在这个双氧水分子的例子中,使用这种 Cholesky 分解比矩阵幂次少去将近 \(3/4\) 的迭代次数,看起来更为高效。

任务 (4)

任务 (4.1)

首先,我们回顾一下 int1e_ipipovlp 的维度:

[51]:
int1e_ipipovlp.shape
[51]:
(3, 3, 22, 22)

上述张量是 \(\phi_{r w \mu} \phi_\nu\),其四个维度分别是 \(r, w, \mu, \nu\)

随后,我们对 int1e_ipipovlp 取对角元,即令 \(w = r\),其结果是 \(\phi_{r r \mu} \phi_\nu\),其维度信息是

[52]:
int1e_ipipovlp.diagonal(axis1=0, axis2=1).shape
[52]:
(22, 22, 3)

注意这三个维度是 \(\mu, \nu, r\);常识下我们可能会认为维度应该是 \(r, \mu, \nu\),但之所以 \(r\) 会在最后一个维度,是因为 numpy 默认下会将被对角化的维度向最后放置。因此,动能积分的被求和的维度应当是第 3 维度,即 axis=2。若是其他维度,则被求和的角标将会是原子轨道角标。

在 numpy 中,axis=-1 代表最后一个维度,因此 axis=-1 也是正确的;axis=-1 有时是比 axis=2 更为推荐的用法,这可能在维度不确定的张量处理时给出普遍的结果。

任务 (4.2)

提示 1 答 \(\partial_r\) 是反厄米算符。我们可以从 \(\langle \mu | \partial_r | \nu \rangle = \phi_{\mu} \phi_{r \nu}\) 矩阵的反对称性 \(\phi_{\mu} \phi_{r \nu} = - \phi_{\nu} \phi_{r \mu}\) 来验证:

[53]:
int1e_ipovlp = mol.intor("int1e_ipovlp")
np.allclose(int1e_ipovlp, -int1e_ipovlp.swapaxes(-1, -2))
[53]:
True

同时指出,\(\langle \mu | \partial_r | \nu \rangle\) 并非是零张量:

[54]:
np.linalg.norm(int1e_ipovlp)
[54]:
7.062131136920612

我们知道,动量算符是 \(- i \partial_r\) 是厄米算符,这与 \(\partial_r\) 为反厄米算符也是等价的。

之所以动量算符是厄米算符,或者等价地 \(\partial_r\) 为反厄米算符,可以参考 Levine [Lev13] (sec 7.2, p.158) 的说明。或者,可以参考下述简单的说明。根据分部积分原则,

\[\int \phi_\mu \partial_r \phi_\nu \, \mathrm{d} r = (\phi_\mu \phi_\nu) |_{r \rightarrow -\inf}^{+\inf} - \int \phi_\nu \partial_r \phi_\mu \, \mathrm{d} r\]

根据波函数的定义,\(\phi_\mu, \phi_\nu\) 应当满足 \(r \rightarrow \infty\) 时,值趋近于零的性质;因此 \((\phi_\mu \phi_\nu) |_{r \rightarrow -\inf}^{+\inf} = 0\);因此,

\[\int \phi_\mu \partial_r \phi_\nu \, \mathrm{d} r = - \int \phi_\nu \partial_r \phi_\mu \, \mathrm{d} r\]

即证明了 \(\partial_r\) 的反厄米性质。

提示 2 答 显然,我们知道动能算符 \(- \frac{1}{2} \partial_r^2\) 是厄米算符。

[55]:
int1e_kin = mol.intor("int1e_kin")
np.allclose(int1e_kin, int1e_kin.swapaxes(-1, -2))
[55]:
True

这是因为两个反厄米算符的乘积是厄米算符。普遍地,对于两个反厄米算符 \(\hat A, \hat B\)

\[\begin{split}\begin{align} \langle \mu | \hat A \hat B | \nu \rangle &= \langle \mu | \hat A | \hat B \nu \rangle = - \langle \hat B \nu | \hat A | \mu \rangle^* \\ &= - \langle \hat B \nu | \hat A \mu \rangle^* = - \langle \hat A \mu | \hat B \nu \rangle \\ &= - \langle \hat A \mu | \hat B | \nu \rangle = \langle \nu | \hat B | \hat A \mu \rangle^* \\ &= \langle \nu | \hat B \hat A | \mu \rangle^* \end{align}\end{split}\]

对于 \(\hat B = \hat A\),上式便能给出 \(\hat A^2\) 为厄米算符的结论;若问题只涉及实数,那么 \(\langle \mu | \hat A^2 | \nu \rangle = \langle \nu | \hat A^2 | \mu \rangle\),因此动能矩阵是对称矩阵。

提示 3 答 结果正确;这可以根据动能算符是厄米算符的性质便立即给出。

[56]:
int1e_ipovlpip = mol.intor("int1e_ipovlpip").reshape((3, 3, nao, nao))
np.allclose(
    0.5 * (int1e_ipovlpip.diagonal(axis1=0, axis2=1).sum(axis=-1)),
    mol.intor("int1e_kin"))
[56]:
True

原问题 答

\[\begin{split}\begin{align} \langle \partial_r \mu | \partial_w \nu \rangle &= \langle \partial_r \mu | \partial_w | \nu \rangle = - \langle \nu | \partial_w | \partial_r \mu \rangle \\ &= - \langle \nu | \partial_w \partial_r | \mu \rangle \end{align}\end{split}\]

这里利用到 \(\partial_w\) 为反厄米算符以及问题不涉及复数的性质。这里有两种继续推导的方法:其一是令 \(r = w\),因此

\[\langle \partial_r \mu | \partial_r \nu \rangle = - \langle \nu | \partial_r^2 | \mu \rangle = - \langle \mu | \partial_r^2 | \nu \rangle\]

另一种证明方法是利用对易性质 \([\partial_r, \partial_w]\),因此

\[\langle \partial_r \mu | \partial_w \nu \rangle = - \langle \nu | \partial_w \partial_r | \mu \rangle = - \langle \nu | \partial_r \partial_w | \mu \rangle\]

利用 \(\langle \mu | \hat A \hat B | \nu \rangle = \langle \nu | \hat B \hat A | \mu \rangle\) 的结论,我们可以立即得到

\[\langle \partial_r \mu | \partial_w \nu \rangle = - \langle \mu | \partial_r \partial_w | \nu \rangle = - \langle \mu | \partial_w \partial_r | \nu \rangle\]

因此,\(\partial_r \partial_w\) 尽管两个算符并不一定相同,但确实是厄米算符。因此,\(\langle \partial_r \mu | \partial_r \nu \rangle\) 是关于 \(\mu, \nu\) 对称的。

最后,代入 \(\hat T = - \frac{1}{2} \partial_r^2\),就可以得到以 \(\langle \partial_r \mu | \partial_w \nu \rangle\) 构造的动能矩阵 \(\langle \mu | \hat T | \nu \rangle\),即原题的代码了。

任务 (5)

任务 (5.1)

分子轨道系数在列上 (分子的其中一根轨道系数) 可以取相反数,因此上述代码单元中使用了绝对值进行比较。取相反数一般不会影响到各种 AO 与 MO 基组矩阵的值。

任务 (5.2)

关系式是

\[C_{\mu p} S_{\mu \nu} C_{\nu q} = \delta_{pq}\]
[57]:
np.allclose(C.T @ S @ C, np.eye(nmo))
[57]:
True

首先,我们指出,在 SCF 迭代过程中使用到的临时矩阵 Cp \(C'_{p \mu}\) (定义与性质参见 Szabo (3.175-178)) 因为是通过 \(\mathbf{F}'\) 对角化得到,因此具有以下正交矩阵的性质 (\({\mathbf{C}'}^\dagger \mathbf{C}' = \mathbf{1}\)\({\mathbf{C}'}^{-1} = {\mathbf{C}'}^\dagger\)):

[58]:
print(np.allclose(Cp.T @ Cp, np.eye(nmo)))
print(np.allclose(Cp.T, np.linalg.inv(Cp)))
True
True

因此,

\[\mathbf{C}^\dagger \mathbf{S} \mathbf{C} = (\mathbf{X} \mathbf{C}')^\dagger \mathbf{S} (\mathbf{X} \mathbf{C}') = {\mathbf{C}'}^\dagger (\mathbf{X}^\dagger \mathbf{S} \mathbf{X}) \mathbf{C}' = {\mathbf{C}'}^\dagger \mathbf{C}' = \mathbf{1}\]

将矩阵计算 \(\mathbf{C}^\dagger \mathbf{S} \mathbf{C} = \mathbf{1}\) 化为矩阵元素的运算后,便成为 \(C_{\mu p} S_{\mu \nu} C_{\nu q} = \delta_{pq}\)

参考文献

[Lev13]

Ira N. Levine. Quantum Chemistry (7th Edition). Pearson, 2013. ISBN 9780321803450. URL: https://www.amazon.com/Quantum-Chemistry-7th-Ira-Levine/dp/0321803450.

[SO96]

Attila Szabo and Neil S. Ostlund. Modern Quantum Chemistry: Introduction to Advanced Electronic Structure Theory (Dover Books on Chemistry). Dover Publications, 1996. ISBN 978-0486691862. URL: https://www.amazon.com/Modern-Quantum-Chemistry-Introduction-Electronic/dp/0486691861.

MP2 计算

XYG3 型泛函从实现上,可以看作是将普通杂化泛函上引入一部分 MP2 的能量;因此这里对 MP2 的实现过程作初步的了解。

[1]:
import numpy as np
from pyscf import scf, gto, mp

from pkg_resources import resource_filename
from pyxdh.Utilities import FormchkInterface
from pyxdh.Utilities.test_molecules import Mol_H2O2
from pyxdh.DerivOnce import GradMP2

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

import warnings
warnings.filterwarnings("ignore")

np.set_printoptions(5, linewidth=150, suppress=True)
[2]:
mol = Mol_H2O2().mol

量化软件的 MP2 计算

PySCF 计算

PySCF 的 MP2 计算可以在给出 SCF 类 scf_eng 的前提下,通过下述代码给出 mp2_eng 以实现:

[3]:
scf_eng = scf.RHF(mol)
scf_eng.kernel()
[3]:
-150.5850337808368
[4]:
mp2_eng = mp.MP2(scf_eng)
mp2_eng.kernel()[0]
[4]:
-0.26901177599951515

上述的输出是体系的相关矫正能 \(E_\mathrm{MP2, c}\);两者相加则可以得到总能量 \(E_\mathrm{MP2} = E_\mathrm{SCF} + E_\mathrm{MP2, c}\)

[5]:
scf_eng.e_tot + mp2_eng.e_corr
[5]:
-150.8540455568363

Gaussian 计算

我们可以将上述计算结果与 Gaussian 结果 (输入卡formchk 结果) 进行比对:

[6]:
ref_fchk = FormchkInterface(resource_filename("pyxdh", "Validation/gaussian/H2O2-MP2-freq.fchk"))

通过 FormchkInterface 给出的是总能量:

[7]:
ref_fchk.total_energy()
[7]:
-150.8540455443488

若要获得其中的相关能 \(E_\mathrm{MP2, c}\),需要直接读取其中的文件词条:

[8]:
ref_fchk.key_to_value("MP2 Energy") - ref_fchk.key_to_value("SCF Energy")
[8]:
-0.2690117592611898

pyxdh 计算

pyxdh 也可以进行 MP2 计算,但需要使用 DerivOnceMP2 的子类:

[9]:
config = {"scf_eng": scf_eng}
mp2h = GradMP2(config)
[10]:
mp2h.eng
[10]:
-150.8540455568363

GradMP2 继承于 GradSCF,因此 GradSCF 类的所有属性都被 GradMP2 继承。

[11]:
nmo = nao = mp2h.nmo
natm = mp2h.natm
nocc, nvir = mp2h.nocc, mp2h.nvir
so, sv, sa = mp2h.so, mp2h.sv, mp2h.sa

C, Co, Cv = mp2h.C, mp2h.Co, mp2h.Cv

MP2 相关能计算

变量定义

在今后的文档中,我们会经常地使用其中的一些矩阵。在这里我们列举出以后程序中会常用到的变量名称和意义。

  • nmo 分子电子数

  • nao 原子轨道数

  • natm 原子数

  • nocc 占据轨道数

  • nvir 未占轨道数

  • so 占据轨道分割

  • sv 未占轨道分割

  • sa 全轨道分割

[12]:
nmo = nao = mp2h.nmo
natm = mp2h.natm
nocc, nvir = mp2h.nocc, mp2h.nvir
so, sv, sa = mp2h.so, mp2h.sv, mp2h.sa
  • C 系数矩阵 \(C_{\mu p}\)

  • e 轨道能量 \(\varepsilon_p\)

  • Co 占据轨道系数矩阵 \(C_{\mu i}\)

  • Cv 未占轨道系数矩阵 \(C_{\mu a}\)

  • eo 占据轨道能量 \(\varepsilon_i\)

  • ev 未占轨道能量 \(\varepsilon_a\)

  • D 密度矩阵 \(D_{\mu \nu}\)

  • F_0_ao AO 基组 Fock 矩阵 \(F_{\mu \nu}\)

  • F_0_mo MO 基组 Fock 矩阵 \(F_{pq}\) (为对角阵)

  • H_0_ao AO 基组 Hamiltonian Core 矩阵 \(h_{\mu \nu}\)

  • H_0_mo MO 基组 Hamiltonian Core 矩阵 \(h_{pq}\)

  • eri0_ao AO 基组双电子互斥积分 \((\mu \nu | \kappa \lambda)\)

  • eri0_mo MO 基组双电子互斥积分 \((pq | rs)\)

  • mo_occ 轨道占据数

[13]:
C, Co, Cv = mp2h.C, mp2h.Co, mp2h.Cv
e, eo, ev = mp2h.e, mp2h.eo, mp2h.ev
D = mp2h.D
F_0_ao, F_0_mo = mp2h.F_0_ao, mp2h.F_0_mo
H_0_ao, H_0_mo = mp2h.H_0_ao, mp2h.H_0_mo
eri0_ao, eri0_mo = mp2h.eri0_ao, mp2h.eri0_mo
mo_occ = mp2h.mo_occ

实际 MP2 计算

事实上刚刚我们已经把 MP2 中计算量最大的部分,即 MO 基组的原子轨道 eri0_mo 已经生成出来了。我们回顾一下该矩阵是如何生成的。AO 原子轨道 eri0_ao 为四维张量 \((\mu \nu | \kappa \lambda)\);那么 MO 原子轨道 eri0_mo 的表达式是

\[(pq|rs) = C_{\mu p} C_{\nu q} (\mu \nu | \kappa \lambda) C_{\kappa r} C_{\lambda s}\]
[14]:
np.allclose(
    np.einsum("up, vq, uvkl, kr, ls -> pqrs", C, C, eri0_ao, C, C),
    eri0_mo
)
[14]:
True

在 RHF 下,MP2 计算表现为 (Szabo, (6.74))

\[E_\mathrm{MP2, c} = \frac{(ia|jb) \big( 2 (ia|jb) - (ib|ja) \big)}{\varepsilon_i - \varepsilon_a + \varepsilon_j - \varepsilon_b} = T_{ij}^{ab} t_{ij}^{ab} D_{ij}^{ab}\]

其中,

\[\begin{split}\begin{align} D_{ij}^{ab} &= \varepsilon_i - \varepsilon_a + \varepsilon_j - \varepsilon_b \\ t_{ij}^{ab} &= \frac{(ia|jb)}{D_{ij}^{ab}} \\ T_{ij}^{ab} &= 2 t_{ij}^{ab} - t_{ij}^{ba} \end{align}\end{split}\]

我们发现,实际上我们不需要全部轨道的 MO 基组张量,只需要其中的 (占据, 非占, 占据, 非占) 部分;因此,我们定义下述的张量:

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

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

  • T_iajb \(T_{ij}^{ab}\)

这些张量不能定义在全分子轨道下,因为如果推广 \(D_{ij}^{ab}\)\(D_{pq}^{rs}\),那么遇到类似于 \(D_{ij}^{ij} = 0\) 的情形,\(t_{ij}^{ij}\) 则表示为非零的 \((ii|jj)\) 与零值的 \(D_{ij}^{ij}\) 相除;因此会引起许多程序上的问题。

[15]:
D_iajb = mp2h.D_iajb
t_iajb = mp2h.t_iajb
T_iajb = mp2h.T_iajb

我们可以验证从 pyxdh 给出的这些变量与上述公式给出的结果是相同的:

[16]:
print(np.allclose(eo[:, None, None, None] - ev[None, :, None, None] + eo[None, None, :, None] - ev[None, None, None, :], D_iajb))
print(np.allclose(np.einsum("ui, va, uvkl, kj, lb -> iajb", Co, Cv, eri0_ao, Co, Cv) / D_iajb, t_iajb))
print(np.allclose(2 * t_iajb - t_iajb.swapaxes(1, 3), T_iajb))
True
True
True

在以后,我们还可能为了公式表达便利,会使用 \(g_{pq}^{rs}\) 表示 \((pq|rs)\);以及 \(G_{pq}^{rs}\) 表示 \(2 g_{pq}^{rs} - g_{pq}^{sr}\)

通过上述对变量的定义,相关能 \(E_\mathrm{MP2, c}\) 可以通过简单的张量相乘求和给出:

[17]:
np.allclose((T_iajb * t_iajb * D_iajb).sum(), mp2_eng.e_corr)
[17]:
True

DFT 格点

这一节我们学习 DFT 格点,并初步了解 PySCF 的格点生成过程。

我们知道,量化程序最重要的部分之一是处理积分。我们在之前已经了解过电子积分的导出方式;电子积分是解析地给出,我们曾经也在习题中尝试过 \(s\) 轨道重叠积分的计算。但对于 DFT 的积分,由于解析形式不像高斯函数一样容易推导,因此几乎使用的都是数值积分。

现在绝大多数量化程序在处理 DFT 问题时,都使用 Lebedev 格点进行格点积分。

[1]:
%matplotlib notebook

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

from pyxdh.Utilities.test_molecules import Mol_H2O2
import matplotlib.pyplot as plt
from mpl_toolkits.mplot3d import Axes3D

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

数值积分概念

注意

这一节作为特例,不使用 Einstein Summation Convention。

我们现在对以下函数与区间作积分:

\[\sin(x) , \quad x \in \left[ 0, \pi \right]\]

显然,根据普通的微积分原理,我们知道

\[\int_0^\pi \sin(x) \, \mathrm{d} x = \cos(x) |_{x = 0}^\pi = 2\]

积分可以看作是对微元的加和;如果这些微元都具有相等宽度 \(\Delta x\),那么上述积分还可以写为

\[\int_0^\pi \sin(x) \, \mathrm{d} x \sim \sum_{g = 0}^\frac{\pi}{\Delta x} \sin(g \Delta x) \Delta x\]

如果我们定义数列 \(\{x_g\}\),其中 \(x_g = g \Delta x\),那么上式可以简单地重新写为

\[\int_0^\pi \sin(x) \, \mathrm{d} x \sim \sum_g \sin(x_g) \Delta x\]

现在,我们定义 Delta_x \(\Delta x\),以及 x_list \(\{x_g\}\);那么我们可以立即通过上式求取数值积分:

[2]:
Delta_x = 0.00001
x_list = np.arange(0, np.pi, Delta_x)
np.sin(x_list).sum() * Delta_x
[2]:
1.9999999999930804

数值积分一般随着精度越大,即 \(\Delta x\) 越小,结果越精确;但相对地,所需要消耗的时间会更多:

[3]:
for e in np.arange(1, 8):
    time0 = time.time()
    Delta_x = 1 / 10**e
    x_list = np.arange(0, np.pi, Delta_x)
    result = np.sin(x_list).sum() * Delta_x
    print("Delta: 1e{}, Error: 1e{:6.3f}, Time: 1e{:6.3f}"
          .format(e, np.log10(np.abs(2 - result)), np.log10(time.time() - time0)))
Delta: 1e1, Error: 1e-3.345, Time: 1e-3.331
Delta: 1e2, Error: 1e-5.001, Time: 1e-4.214
Delta: 1e3, Error: 1e-7.338, Time: 1e-3.170
Delta: 1e4, Error: 1e-8.877, Time: 1e-2.523
Delta: 1e5, Error: 1e-11.160, Time: 1e-2.149
Delta: 1e6, Error: 1e-13.270, Time: 1e-1.213
Delta: 1e7, Error: 1e-14.239, Time: 1e-0.201

需要指出,由于进入第一个循环时,代码可能需要作准备,因此第一个循环所消耗的时间会相对较多。

PySCF 格点生成

在上一小节,我们已经了解了对于单一维度 \(x\) 的函数 \(f(x)\) 的数值积分。事实上,DFT 也使用类似的做法实现数值积分。通常来说,若 DFT 的交换相关核 (Kernel) 是 \(f[\rho(\boldsymbol{r})]\),那么交换相关能则表示为

\[E_\mathrm{xc} [\rho] = \int f[\rho] \rho(\boldsymbol{r}) \, \mathrm{d} \boldsymbol{r}\]

与上一节的讨论不同之处在于,这里的积分是三维积分,且被积空间是全空间而非有界区间。

处理这种积分的方式,通常是构造 Lebedev 格点,对其进行加权求和:

\[E_\mathrm{xc} [\rho] = \sum_{g} w_g f_g [\rho] \rho_g\]

其中,上式中的 \(g\) 代表三维空间上的格点,其坐标是 \(\boldsymbol{r}_g\)。因此,\(f_g [\rho] = f[\rho(\boldsymbol{r})]\) 即在格点 \(g\) 处的交换相关核的值,\(\rho_g = \rho(\boldsymbol{r})\) 即是在格点 \(g\) 处的电子密度。\(w_g\)\(g\) 处的格点积分权重,它相当于上一小节中的 \(\Delta x\);只是上一小节中,每个格点的权重都相同,但这里的 \(w_g\) 会因为 \(g\) 的不同而不同。

这一小节我们主要展示格点与格点权重;与格点积分有关的计算将放在下一节。下面我们用更为实际的例子来描述格点。现在我们拿出一直使用的双氧水分子:

[4]:
mol = Mol_H2O2().mol

在 PySCF 通过 dft.Grids 生成格点。尽管我们以后会经常使用 (75, 302) 或 (99, 590) 的格点,但在这里为了方便显示,使用 (4, 14) 格点。

[5]:
grids = dft.Grids(mol)
grids.atom_grid = (4, 14)
grids.build()
[5]:
<pyscf.dft.gen_grid.Grids at 0x7fcfd0ef2cf8>

DFT 的格点通常是以每个原子为原点的 Lebedev 格点构成的。Lebedev 格点可以看作是一种球形散布的格点。以 (4, 14) 格点为例,4 表示径向的格点分割,14 表示球面格点分割,一共 \(4 \times 14 = 56\) 个格点。由于双氧水分子一共有 4 个原子,因此总格点数为 \(56 \times 4 = 224\)

[6]:
grids.weights.size
[6]:
224

现在我们分析前 56 个格点;这 56 个格点是第一个氧原子 (坐标为 \((x, y, z) = (0, 0, 0)\)) 的格点,该氧原子也恰好处于原点上。格点的坐标可以通过 coords 方法给出:

[7]:
grids.coords[:56].shape
[7]:
(56, 3)

作为示例,其中一些格点的坐标表示如下,其单位是 Bohr 半径:

[8]:
grids.coords[:4]
[8]:
array([[0.05362, 0.     , 0.     ],
       [0.48988, 0.     , 0.     ],
       [1.80214, 0.     , 0.     ],
       [4.83583, 0.     , 0.     ]])

下面两行代码表明,所有 56 个格点与原点 (第一个氧原子中心) 的距离只会是上述四个点与原点距离的其中一个。

[9]:
r_coord = np.linalg.norm(grids.coords[:56], axis=1)
np.allclose(r_coord, r_coord[:4, None].repeat(14, axis=1).T.ravel())
[9]:
True

下面四行代码表明,所有 56 个格点与原点之间的距离缩放到单位长度 (Bohr 半径) 时,只会有 14 中球面分布。

[10]:
reshaped_coord = grids.coords[:56].reshape(14, 4, 3)
rescaled_coord = reshaped_coord / np.linalg.norm(reshaped_coord, axis=2)[:, :, None]
for i in range(3):
    print(np.allclose(rescaled_coord[:, 3], rescaled_coord[:, i]))
True
True
True

下面我们展示处于原点上的氧原子所对应的 (14, 6) 格点:

[11]:
def graph_6p(coords):
    return coords[[0, 2, 1, 3, 0, 4, 1, 5, 0, 2, 4, 3, 5, 2]].T

def graph_8p(coords):
    return coords[[0, 2, 3, 1, 0, 4, 6, 2, 6, 7, 3, 7, 5, 1, 5, 4]].T
[12]:
xs, ys, zs = grids.coords[:56].T
log_weights = np.log10(grids.weights[:56] + 1e-10)
fig = plt.figure()
ax = Axes3D(fig)
p = ax.scatter(xs, ys, zs, c=log_weights, s=50)

ax.plot(*graph_6p(grids.coords[1:24:4]), c="C0")
ax.plot(*graph_8p(grids.coords[25:56:4]), c="C0")
ax.plot(*graph_6p(grids.coords[2:24:4]), c="C1")
ax.plot(*graph_8p(grids.coords[26:56:4]), c="C1")
ax.plot(*graph_6p(grids.coords[3:24:4]), c="C2")
ax.plot(*graph_8p(grids.coords[27:56:4]), c="C2")

ax.set_xlabel("x")
ax.set_ylabel("y")
ax.set_zlabel("z")
ax.set_xlim([-3, 3])
ax.set_ylim([-3, 3])
ax.set_zlim([-3, 3])

ax.view_init(elev=15, azim=48)
fig.colorbar(p)
[12]:
<matplotlib.colorbar.Colorbar at 0x7fcf99317400>

其中,距离原点 0.05362 Bohr 的格点由于离原点太近没有表示出来;距离 0.48988, 1.80214, 4.83583 的点分别用蓝色、橙色、绿色的直线连接。对于球面上为 14 个格点的情形,这 14 个点恰好所有点分属一个立方体与一个正八面体的顶点。

格点小球上的颜色与右侧以 10 为底数的色条对应;颜色越紫,格点权重越低。在 \(x\)\(z\) 轴正方向上,由于有其它原子,因此靠近这些方向的格点权重较低。

最后我们展示整个双氧水分子的格点分布情况,其中用分子的骨架用蓝色线条连接。

[13]:
xs, ys, zs = grids.coords.T
axs, ays, azs = mol.atom_coords()[[2, 0, 1, 3]].T
log_weights = np.log10(grids.weights + 1e-10)
fig = plt.figure()
ax = fig.add_subplot(111, projection='3d')
p = ax.scatter(xs, ys, zs, c=log_weights)
ax.plot(axs, ays, azs)
ax.set_xlabel("x")
ax.set_ylabel("y")
ax.set_zlabel("z")
ax.set_xlim([-2, 4])
ax.set_ylim([-2, 4])
ax.set_zlim([-2, 4])
ax.view_init(elev=15, azim=48)
fig.colorbar(p)
[13]:
<matplotlib.colorbar.Colorbar at 0x7fcf99257470>

任务 (1)

  1. (可选) 之前我们查看的是处在双氧水分子中的氧原子的格点。现在我们查看单个的氧原子的格点,请问格点信息 (坐标、权重) 有何变化?

  2. (可选) 我们之前分析的是 (4, 14) 格点,我们知道双氧水的格点总数是 \(4 \times 4 \times 14 = 224\)。现在我们考虑 (99, 590) 格点,那么双氧水格点总数是否会是 \(4 \times 99 \times 590 = 233,640\) 个?

参考任务解答

任务 (1)

任务 (1.1) 可选

我们先构建单个氧原子的格点:

[14]:
mol_O = gto.Mole()
mol_O.atom = """
O 0. 0. 0.
"""
mol_O.build()
grids_O = dft.Grids(mol_O)
grids_O.atom_grid = (4, 14)
grids_O.build()
[14]:
<pyscf.dft.gen_grid.Grids at 0x7fcf994055f8>

随后我们依照之前的代码,绘制氧原子格点图,这个格点图的点的位置反映格点坐标、点的颜色反映经过对数缩放过的权重大小:

[15]:
xs, ys, zs = grids_O.coords[:56].T
log_weights = np.log10(grids_O.weights[:56] + 1e-10)
fig = plt.figure()
ax = Axes3D(fig)
p = ax.scatter(xs, ys, zs, c=log_weights, s=50)

ax.plot(*graph_6p(grids_O.coords[1:24:4]), c="C0")
ax.plot(*graph_8p(grids_O.coords[25:56:4]), c="C0")
ax.plot(*graph_6p(grids_O.coords[2:24:4]), c="C1")
ax.plot(*graph_8p(grids_O.coords[26:56:4]), c="C1")
ax.plot(*graph_6p(grids_O.coords[3:24:4]), c="C2")
ax.plot(*graph_8p(grids_O.coords[27:56:4]), c="C2")

ax.set_xlabel("x")
ax.set_ylabel("y")
ax.set_zlabel("z")
ax.set_xlim([-3, 3])
ax.set_ylim([-3, 3])
ax.set_zlim([-3, 3])

ax.view_init(elev=15, azim=48)
fig.colorbar(p)
[15]:
<matplotlib.colorbar.Colorbar at 0x7fcf99240630>

容易发现,上图所示的单个氧原子格点的坐标与处在双氧水中氧原子格点的坐标完全一致。

但对于格点权重上,上图的单个氧原子的格点权重有很明显的特征:绿色线所连点 (与原点距离 4.83583 Bohr 的格点) 的颜色都是相同的黄色,橙色线所连格点则对应相同的绿色,蓝色线则对应青色。这意味着,与原点距离相同的格点的权重是完全相等的。

显然,这与分子中的格点的坐标的权重分布完全不同。并且,如果格点属于氧原子,但远离任何其他原子,那么其格点权重基本不变;但若接近任何其他原子,那么格点权重就会明显下降。

原子格点权重在分子中变化的策略通常有两种,称为 Becke Scheme [Bec88] 与 Stratmann Scheme [SSF96],后者是 Gaussian 的默认的变化策略,也通常更快;而 PySCF 所默认使用的是 Becke Scheme。由于 DFT 格点权重的计算并没有超过 \(O(N^3)\) 量级且只需要计算一次,相比于 SCF 的计算时间而言几乎是可以忽略的;且两种权重变化策略所给出的结果大体相同,因此在实际计算中通常不需要纠结使用哪一种策略。若有兴趣调整格点权重变化策略,可以考虑了解以下属性:

[16]:
grids.becke_scheme
[16]:
<function pyscf.dft.gen_grid.original_becke(g)>
任务 (1.2) 可选

答案是不确定,并且通常比 233,640 个格点少。

回顾我们定义 (4, 14) 格点的代码,并用于定义 (99, 590) 格点的定义中:

[17]:
grids_default_prune = dft.Grids(mol)
grids_default_prune.atom_grid = (99, 590)
grids_default_prune.build()
[17]:
<pyscf.dft.gen_grid.Grids at 0x7fcf99257eb8>

我们随后输出格点数量:

[18]:
grids_default_prune.weights.size
[18]:
130776

可以发现,格点总数比我们预期的 233,640 个格点少了将近一半!

实际上在格点数稍多一些时,PySCF 会应用 DFT 格点的修剪策略 (prune);其默认的修剪策略应当与 NWChem 默认的修剪策略相同。我们认为,对于一个分子而言,离原子最近或者最远的格点由于没有受到化学成键区域的影响,因此对化学问题的贡献并不重要;因此这部分格点可以分布得粗略一些。相对地,在化学成键区域,即离原子距离不近不远的区域,格点的密度应当足够大以保证格点积分精度。

PySCF 允许使用三种格点修剪策略;若有兴趣调整格点修剪策略,可以考虑了解以下属性:

[19]:
grids.prune
[19]:
<function pyscf.dft.gen_grid.nwchem_prune(nuc, rads, n_ang, radii=array([0.     , 0.6614 , 2.64562, 2.7401 , 1.98421, 1.60627, 1.32281, 1.22832, 1.13384, 0.94486, 2.83459, 3.40151,
       2.83459, 2.36216, 2.0787 , 1.88973, 1.88973, 1.88973, 3.40151, 4.1574 , 3.40151, 3.02356, 2.64562, 2.55113,
       2.64562, 2.64562, 2.64562, 2.55113, 2.55113, 2.55113, 2.55113, 2.45664, 2.36216, 2.17319, 2.17319, 2.17319,
       3.59048, 4.44086, 3.77945, 3.40151, 2.92908, 2.7401 , 2.7401 , 2.55113, 2.45664, 2.55113, 2.64562, 3.02356,
       2.92908, 2.92908, 2.7401 , 2.7401 , 2.64562, 2.64562, 3.96842, 4.91329, 4.06291, 3.68497, 3.49599, 3.49599,
       3.49599, 3.49599, 3.49599, 3.49599, 3.40151, 3.30702, 3.30702, 3.30702, 3.30702, 3.30702, 3.30702, 3.30702,
       2.92908, 2.7401 , 2.55113, 2.55113, 2.45664, 2.55113, 2.55113, 2.55113, 2.83459, 3.59048, 3.40151, 3.02356,
       3.59048, 2.7401 , 3.96842, 3.40151, 4.06291, 3.68497, 3.40151, 3.40151, 3.30702, 3.30702, 3.30702, 3.30702,
       3.30702, 3.30702, 3.30702, 3.30702, 3.30702, 3.30702, 3.30702, 3.30702, 3.30702, 3.30702, 3.30702, 3.30702,
       3.30702, 3.30702, 3.30702, 3.30702, 3.30702, 3.30702, 3.30702, 3.30702, 3.30702, 3.30702, 3.30702, 3.30702,
       3.30702, 3.30702, 3.30702, 3.30702, 3.30702, 3.30702, 3.30702, 3.30702, 3.30702, 3.30702, 3.30702]))>

如果我们不采用任何修剪,那么我们可以确实地得到 \(4 \times 99 \times 590 = 233,640\) 个格点:

[20]:
grids_no_prune = dft.Grids(mol)
grids_no_prune.atom_grid = (99, 590)
grids_no_prune.prune = None
grids_no_prune.build()
grids_no_prune.weights.size
[20]:
233640

参考文献

[Bec88]

A. D. Becke. A multicenter numerical integration scheme for polyatomic molecules. J. Chem. Phys., 88(4):2547–2553, feb 1988. doi:10.1063/1.454033.

[SSF96]

R.Eric Stratmann, Gustavo E. Scuseria, and Michael J. Frisch. Achieving linear scaling in exchange-correlation density functional quadratures. Chem. Phys. Lett., 257(3-4):213–223, jul 1996. doi:10.1016/0009-2614(96)00600-8.

LDA 泛函核

这一节我们简单了解 LDA 泛函核的程序实现;这是我们的第一个 DFT 计算的文档。

LDA (Local Density Approximation) 的发展可以追溯到 19 世纪 30 年代,它不仅在发展上比 DFT 更早,也同时是 DFT 的基础。同时,LDA 的公式相对来说比较简单,因此程序上可以用较短的代码长度实现。但 LDA 的精度远远不足以处理普遍的化学问题;以后的文档我们会使用 GGA (Generalized Gradient Approximation) 作为基本框架进行计算。因此,这一节我们只是稍稍涉水 DFT 程序;用于以后文档与 pyxdh 的完整的格点积分介绍将会放在下一节的 GGA 自洽场讨论中。

这一节以及以后的文档对 DFT 的叙述都只是从程序出发,而不讨论 DFT 的理论和推导。

[1]:
import numpy as np
from pyscf import gto, lib, dft
from matplotlib import pyplot as plt
from functools import partial

from pyxdh.Utilities.test_molecules import Mol_H2O2
from pyxdh.DerivOnce import GradSCF

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

DFT 的积分按照约定俗成和一些理论支撑,通常分为交换部分 (Exchange) 与相关部分 (Correlation)。对于 LDA,交换部分是 Slater 泛函 (参考 Wikipedia 页面);相关部分没有确定的形式,一般使用 VWN5 泛函 [VWN80] (Gaussian 中可能会默认使用 VWN3 泛函,因此使用与 VWN 有关泛函时,需要特别注意这个问题)。

这一节我们的讨论对象便是 Slater 交换泛函与 VWN5 相关泛函,并且着重讨论形式较为简单的 Slater 泛函。

下述代码块生成双氧水分子与其对应的 (99, 590) 格点。

[2]:
mol = Mol_H2O2().mol
grids = Mol_H2O2().gen_grids()

同时我们定义下述两个经常使用到,并且与泛函无关的量。nao 是 AO 基组大小,而 ngrids 是格点数量。

[3]:
nao, ngrids = mol.nao, grids.weights.size
nao, ngrids
[3]:
(22, 130776)

Slater 交换泛函

Slater 交换泛函能量结果

Slater 交换泛函能量的表达式是

\[\begin{equation} E_\mathrm{x}^{\mathrm{Slater}}[\rho] = - \frac{3}{4}\left( \frac{3}{\pi} \right)^{1/3} \int\rho(\boldsymbol{r})^{4/3} \, \mathrm{d} \boldsymbol{r} \end{equation}\]

对于 Gaussian,如果要获得 Slater 泛函自洽场下双氧水分子的结果,需要使用以下关键词:

#p HFS/6-31G nosymm Integral(Grid=99590)

而在 PySCF 中,我们用如下方式定义 Slater 泛函的自洽场:

[4]:
scf_eng_s = dft.RKS(mol)
scf_eng_s.grids = grids
scf_eng_s.xc = "Slater"
scf_eng_s.kernel()
[4]:
-149.06413916042908

我们可以将 scf_eng_s 代入 pyxdh 框架中,以方便后续的一些计算代码:

[5]:
config = {"scf_eng": scf_eng_s}
scfh_s = GradSCF(config)

我们曾经在 RHF 能量参考实现 中讨论过 RHF 波函数能量;对于 LDA 而言,总能量的表达式相对于 RHF,贡献项少了 RHF 交换积分贡献,而多了交换相关泛函贡献 (若泛函是 Slater 交换泛函,则多出的部分是 Slater 泛函能量):

\[E_\mathrm{elec}[D_{\mu \nu}] = (h_{\mu \nu} + \frac{1}{2} J_{\mu \nu} [D_{\kappa \lambda}]) D_{\mu \nu} + E_\mathrm{x}^\mathrm{Slater} [\rho]\]

其中,Hamiltonian Core 与库伦积分的能量贡献 \((h_{\mu \nu} + \frac{1}{2} J_{\mu \nu} [D_{\kappa \lambda}]) D_{\mu \nu}\) 是容易计算的;因此我们很容易地倒推 Slater 泛函能量 eng_xc_s

\[E_\mathrm{x}^\mathrm{Slater} [\rho] = E_\mathrm{elec}[D_{\mu \nu}] - (h_{\mu \nu} + \frac{1}{2} J_{\mu \nu} [D_{\kappa \lambda}]) D_{\mu \nu}\]
[6]:
eng_xc_s = scf_eng_s.energy_elec()[0] - ((scfh_s.H_0_ao + 0.5 * scf_eng_s.get_j()) * scfh_s.D).sum()
eng_xc_s
[6]:
-15.553774010932813

后面几段的任务将会是重复上述 Slater 泛函能量 \(E_\mathrm{x}^\mathrm{Slater} [\rho]\)

轨道格点

记号说明

在这一节公式中,以及整个文档的代码中,

  • 下标 \(g\) 代表 DFT 格点。

  • \(w_g\) 代表 DFT 格点所具有的权重。

以后的文档出于简化公式的目的,下标 \(g\)\(w_g\) 将不会出现在公式中;但这一节会写出完整的计算公式。

回顾上一节,如果我们希望通过数值积分的方式求取 \(E_\mathrm{x}^{\mathrm{Slater}}[\rho]\),那么其表达式应当是

\[E_\mathrm{x}^{\mathrm{Slater}}[\rho] = - \frac{3}{4}\left( \frac{3}{\pi} \right)^{1/3} w_g \rho_g^{4/3}\]

其中,上式根据 Einstein Summation Convention,等式右边对 \(g\) 求和。权重格点我们可以通过 grids.weights 直接获得;那么,求取 \(E_\mathrm{x}^{\mathrm{Slater}}[\rho]\) 的第一步就是给出 \(\rho_g\)

在 PySCF 中,与 DFT 格点有关的许多程序集中在 dft.numint.NumInt 类中。我们定义以下类实例 ni

[7]:
ni = dft.numint.NumInt()

通过 ni.block_loop 成员函数,我们可以获得 ao/grid_ao 格点的原子轨道 \(\phi_{g \mu}\) 以及其对电子坐标分量的导数、mask 遮罩、weights/grid_weights 格点权重 \(w_g\)、以及 coords/grid_coords 格点坐标:

[8]:
grid_ao, _, grid_weights, grid_coords = np.zeros((4, ngrids, nao)), _, np.zeros(ngrids), np.zeros((ngrids, 3))
g_start, g_end, g_mem = 0, 0, 2000
for ao, mask, weights, coords in ni.block_loop(mol, grids, nao, 1, g_mem):
    g_end = g_start + ao.shape[1]
    g_slice = slice(g_start, g_end)
    grid_ao[:, g_slice, :] = ao
    grid_weights[g_slice] = weights
    grid_coords[g_slice] = coords
    g_start = g_end

其中,格点遮罩和格点坐标以后并不会经常使用。格点权重和坐标还可以分别通过 grids.weightsgrids.coords 直接给出;下面的代码单元验证通过 ni.block_loop 给出的权重和坐标,以及 grids 直接给出的权重和坐标是等价的:

[9]:
print(np.allclose(grid_weights, grids.weights))
print(np.allclose(grid_coords, grids.coords))
True
True

任务 (1)

  1. 请尽可能解释生成 grid_aogrid_weightsgrid_coords 代码的过程。

  2. 下面一行代码似乎能直接生成 grid_aogrid_weightsgrid_coords 三者;但作者不推荐这么使用代码。请解释原因。

    grid_ao, _, grid_weights, grid_coords = next(ni.block_loop(mol, grids, nao, 1, g_mem))
    

    提示:首先注意到 ni.block_loop 的返回量是迭代器,而 next 在 Python 中是用来返回迭代器中下一个迭代量;其次思考或通过 Hacking 程序或查看 API 文档了解 g_mem 参数的意义。

我们以后只用到不包含导数的原子轨道格点 \(\phi_{g \mu}\),因此定义下述变量 ao_0

[10]:
ao_0 = grid_ao[0]
ao_0.shape
[10]:
(130776, 22)

密度格点

我们知道,分子的电子态密度可以写作:

\[\rho (\boldsymbol{r}) = \phi_i (\boldsymbol{r}) \phi_i (\boldsymbol{r})\]

而分子轨道又可以写作

\[\phi_i (\boldsymbol{r}) = C_{\mu i} \phi_\mu (\boldsymbol{r})\]

因此,

\[\rho (\boldsymbol{r}) = C_{\mu i} C_{\nu i} \phi_\mu (\boldsymbol{r}) \phi_\nu (\boldsymbol{r}) = D_{\mu \nu} \phi_\mu (\boldsymbol{r}) \phi_\nu (\boldsymbol{r})\]

上面是以 \(\boldsymbol{r}\) 的函数来定义的;如果用格点来定义的话,则密度格点 rho_s_0

\[\rho_g = D_{\mu \nu} \phi_{g \mu} \phi_{g \nu}\]
[11]:
rho_s_0 = np.einsum("uv, gu, gv -> g", scfh_s.D, ao_0, ao_0)

密度的一个非常重要的性质是

\[n_\mathrm{elec} = \int \rho(\boldsymbol{r}) \, \mathrm{d} \boldsymbol{r}\]

其中,\(n_\mathrm{elec}\) 是电子总数,对于双氧水而言是 18。如果用格点积分来表示上式,则

\[n_\mathrm{elec} = w_g \rho_g\]
[12]:
(rho_s_0 * grids.weights).sum()
[12]:
18.00000019228845

任务 (2)

  1. 我们只说生成了密度格点,但我们其实还没有进行过量纲分析。请根据电子数格点积分的程序结果,判断 rho_s_0 \(\rho_g\) 向量中每个元素的量纲是什么。

    提示:格点权重 \(w_g\) 的量纲认为是 \([\mathrm{L}]^3\)

Slater 交换泛函能量

重新回顾 Slater 交换泛函能量的表达式:

\[E_\mathrm{x}^{\mathrm{Slater}}[\rho] = - \frac{3}{4}\left( \frac{3}{\pi} \right)^{1/3} \int \rho(\boldsymbol{r})^{4/3} \, \mathrm{d} \boldsymbol{r}\]

如果要将上式写为格点积分的表达式,则

\[E_\mathrm{x}^{\mathrm{Slater}}[\rho] = - \frac{3}{4}\left( \frac{3}{\pi} \right)^{1/3} w_g \rho_g^{4/3}\]
[13]:
(- 3/4 * (3/np.pi)**(1/3) * rho_s_0**(4/3) * grids.weights).sum()
[13]:
-15.553774010932845

该能量与方才导出的 eng_xc_s 几乎相等。

Slater 交换泛函核

Slater 交换泛函由于形式非常简单,可以直接给出交换相关泛函的能量;但一般来说,泛函是通过核 (kernel) \(f[\rho]\) 先定义,随后根据下式给出泛函能量:

\[E_\mathrm{x}^{\mathrm{Slater}}[\rho] = \int f^\mathrm{Slater} [\rho] \rho(\boldsymbol{r}) \, \mathrm{d} \boldsymbol{r}\]

容易知道,Slater 的泛函核是

\[f^\mathrm{Slater} [\rho] = - \frac{3}{4}\left( \frac{3}{\pi} \right)^{1/3} \rho^{1/3}\]

那么,对于当前的电子态密度格点 rho_s_0 \(\rho_g\),Slater 泛函核的格点 kernel_s

\[f_g = - \frac{3}{4}\left( \frac{3}{\pi} \right)^{1/3} \rho^{1/3}_g\]
[14]:
kernel_s = - 3/4 * (3/np.pi)**(1/3) * rho_s_0**(1/3)

那么 Slater 交换能则还可以写为

\[E_\mathrm{x}^{\mathrm{Slater}}[\rho] = w_g f_g \rho_g\]
[15]:
(grids.weights * kernel_s * rho_s_0).sum()
[15]:
-15.553774010932846

PySCF 程序计算 Slater 交换能

上一小段似乎比较冗余:我们完全可以不借助交换泛函核进行格点积分。但在 PySCF 中,流程化的做法是先生成交换泛函核,再通过 \(E_\mathrm{xc} [\rho] = w_g f_g \rho_g\) 给出交换相关能;这也是照顾到除了 Slater 交换能外,PySCF 还需要计算各种其它形式远远复杂得的泛函,因此同一使用泛函核来定义泛函。

在 PySCF 中,若当前的泛函是 LDA 型泛函,当代入电子态密度 \(\rho_g\)、泛函名称后,就可以得到泛函核。我们定义由 PySCF 给出的泛函核 \(f_g\)exc_s

[16]:
exc_s = ni.eval_xc("Slater", rho_s_0, deriv=0)[0]

原则上,我们可以验证,我们刚才通过 \(\rho_g\) 生成的泛函核 kernel_s 与 PySCF 给出的 exc_s 几乎相等;但实际上由于数值上的一些问题,必须要将泛函核 \(f_g\)\(\rho_g\) 相乘才能验证:

[17]:
np.allclose(exc_s, kernel_s)
[17]:
False
[18]:
np.allclose(exc_s * rho_s_0, kernel_s * rho_s_0)
[18]:
True

关于 ni.eval_xc,我们将在下一节在 GGA 的语境下作更详细的描述。

改编 LibXC 源代码给出泛函核

不同 DFT 近似的区别,一般来说只是泛函核的不同。这可以说是泛函计算的核心。这部分核心在 PySCF 中是借用第三方库计算的;其默认的库是 LibXC 库,其版权声明为 MPLv2。我们就使用这个库,说明如何将其源代码转化为这份文档中可以使用的 Python 代码,并计算得到结果。需要指出,由于默认的 LibXC 不支持泛函核的三阶导数,因此 pyxdh 统一使用备用泛函库 XCFun

根据 PySCF 对 LibXC 的 接口程序,我们知道 Slater 函数对应到 LibXC 的文件应该具有名称 XC_LDA_X;随后我们找到 LibXC 的文件 lda_x.mplutil.mpl。LibXC 现在的程序是通过将 Maple 代码转化为 C++ 代码得到;Maple 代码不管是从可读性还是与 Python 代码的相似性上都很高,因此我们直接读取 Maple 代码。

众多泛函都需要使用的参数被定义在 util.mpl;同时,我们在下面的代码中定义密度相关量 rs 与自旋密度表征度 z。注意到因为我们现在正在考虑的是闭壳层体系,因此自旋密度的表征度 \(\zeta\) 恒为零.密度相关量 rs\(\rho^{-1/3}\) 成正比。

需要注意到,因为我们所计算的密度会是极小的值,甚至是零值;但这里却对密度求了倒数,因此为了避免出现太多程序警告,我们对密度加上双浮点机器精度以上的小量。

[19]:
# system & method insensitive
Pi = np.pi
RS_FACTOR = (3/(4*Pi))**(1/3)
X_FACTOR_C = 3/8*(3/Pi)**(1/3)*4**(2/3)
# system & method sensitive
z = 0
def rs(rho): return RS_FACTOR * (rho + 1e-50)**(-1/3)

而 Slater 交换泛函核则被定义在 lda_x.mpl 中;这里的 params_a_alpha 对应了 \(X\alpha\) 中的 \(\frac{3}{2} \alpha\),它作为系数直接乘在泛函核上。事实上,如果我们说 \(X\alpha\) 方法是一个包含了相关能的泛函方法;一般取 \(\alpha = 0.7\)

\begin{equation} E_\mathrm{xc}^{X\alpha}[\rho] = - \frac{9}{8} \alpha \left( \frac{3}{\pi} \right)^{1/3}\int\rho(\boldsymbol{r})^{4/3} \, \mathrm{d} \boldsymbol{r} \end{equation}

如果我们取 \(\alpha = 2/3\),那么它仅仅代表自由电子气的交换能,即 Slater 交换能。

我们目前的任务是计算 Slater 交换能,因此设定 params_a_alpha\(\frac{3}{2} \alpha = \frac{3}{2} \frac{2}{3} = 1\)

[20]:
params_a_alpha = 1
lda_x_ax = -params_a_alpha*RS_FACTOR*X_FACTOR_C/2**(4/3)
def ker_lda_x(rs, z):
    return lda_x_ax*((1 + z)**(4/3) + (1 - z)**(4/3))/rs

现在,我们就可以验证上面自 LibXC 的 Maple 代码修改而来的函数能给出正确的泛函核 \(f_g\)

[21]:
np.allclose(ker_lda_x(rs(rho_s_0), 0), kernel_s)
[21]:
True

……以及能给出正确的泛函能量 \(E_\mathrm{x}^\mathrm{Slater} [\rho] = w_g f_g \rho_g\)

[22]:
(grids.weights * ker_lda_x(rs(rho_s_0), 0) * rho_s_0).sum()
[22]:
-15.553774010932845

Slater 交换泛函核的图像

使用 LibXC 代码的一个方便之处是可以直观地使用定义好的函数 ker_lda_x 来观察泛函。我们使泛函核对线性的密度作图,图中的单位都是原子单位;其中横坐标是密度 \(\rho_g\) 的量纲 \([\mathrm{L}]^{-3}\),即玻尔半径的三次倒数;纵坐标是 \(E_\mathrm{x}^\mathrm{Slater}\) 的量纲 \([\mathrm{M}][\mathrm{L}]^2[\mathrm{T}]^{-2}\),即 Hartree。

[23]:
scaler_rho = np.arange(0, 20, 0.01)
plt.plot(scaler_rho, ker_lda_x(rs(scaler_rho), 0))
[23]:
[<matplotlib.lines.Line2D at 0x7fbb04298b00>]
_images/qcbasic_basic_lda_70_1.png

VWN5 相关泛函

VWN5 积分是 LDA 方法中最常用的相关能计算方法。在 Gaussian 中,若打算仅仅计算 VWN5 相关能,输入卡中可以包含下述语句:

#p SVWN5/6-31G nosymm Integral(Grid=99590) IOp(3/74=1)

VWN5 的公式较为复杂,在此我们不打算从公式来给出程序实现;但会给出从 LibXC 代码转换到 Python 代码的过程。首先,我们进行一次 VWN5 的计算过程:

[24]:
scf_eng_vwn = dft.RKS(mol)
scf_eng_vwn.xc = "VWN5"
scf_eng_vwn.grids = grids
scf_eng_vwn.kernel()

config = {"scf_eng": scf_eng_vwn}
scfh_vwn = GradSCF(config)

随后给出 VWN5 自洽场下的密度格点 rho_vwn_0 \(\rho_g\) 和泛函核格点 exc_vwn \(f_g\)。需要注意到,我们并没有更换分子,因此 ao_0 \(\phi_{g \mu}\) 其实没有变化,因此并不需要重新生成原子轨道的格点;但由于我们跑了一个新的自洽场,因此密度需要重新生成。

[25]:
rho_vwn_0 = np.einsum("uv, gu, gv -> g", scfh_vwn.D, ao_0, ao_0)
exc_vwn = ni.eval_xc("VWN5", rho_vwn_0, deriv=0)[0]

我们的目标是通过 LibXC 改编的代码求出下面的相关能 \(E_\mathrm{c}^\mathrm{VWN5} [\rho]\)

[26]:
eng_xc_vwn = scf_eng_vwn.energy_elec()[0] - ((scfh_vwn.H_0_ao + 0.5 * scf_eng_vwn.get_j()) * scfh_vwn.D).sum()
eng_xc_vwn
[26]:
-1.196157442939949

根据 LibXC 源代码给出泛函核

对于 VWN5 方法,其完整的计算过程除了需要各种泛函的基础 util.mpl 文件之外,还需要 VWN 相关泛函的基础 vwn.mpl 与 VWN5 泛函本身的定义文件 lda_c_vwn.mpl

通过 util.mpl,我们还需要定义一个函数 (但事实上,由于该函数只与自旋密度的标度有关,因此在 RKS 下,该函数始终返回零值):

[27]:
def f_zeta(z): return ((1 + z)**(4/3) + (1 - z)**(4/3) - 2)/(2**(4/3) - 2)

vwn.mpl 中的定义如下 (由于 Maple 是 1-Indexing,因此我们在 Python 代码中的所有 0 索引位用 None 填充):

[28]:
A_vwn  = [None,  0.0310907, 0.01554535, -1/(6*Pi**2)]
b_vwn  = [None,  3.72744,   7.06042,    1.13107  ]
c_vwn  = [None, 12.9352,   18.0578,    13.0045   ]
x0_vwn = [None, -0.10498,  -0.32500,   -0.0047584]

A_rpa  = [None,  0.0310907,  0.01554535,  -1/(6*Pi**2)]
b_rpa  = [None, 13.0720,    20.1231,      1.06835  ]
c_rpa  = [None, 42.7198,   101.578,      11.4813   ]
x0_rpa = [None, -0.409286,  -0.743294,   -0.228344 ]

def Q(b, c): return np.sqrt(4*c - b**2)
def f1(b, c): return 2*b/Q(b, c)
def f2(b, c, x0): return b*x0/(x0**2 + b*x0 + c)
def f3(b, c, x0): return 2*(2*x0 + b)/Q(b, c)
fpp = 4/(9*(2**(1/3) - 1))

def fx(b, c, rs): return rs + b*np.sqrt(rs) + c

def f_aux(A, b, c, x0, rs): return A*(
        + np.log(rs/fx(b, c, rs))
        + (f1(b, c) - f2(b, c, x0)*f3(b, c, x0))*np.arctan(Q(b, c)/(2*np.sqrt(rs) + b))
        - f2(b, c, x0)*np.log((np.sqrt(rs) - x0)**2/fx(b, c, rs))
    )

def DMC(rs, z): return(
    + f_aux(A_vwn[2], b_vwn[2], c_vwn[2], x0_vwn[2], rs)
    - f_aux(A_vwn[1], b_vwn[1], c_vwn[1], x0_vwn[1], rs)
)

从而 lda_c_vwn.mpl 就可以给出 VWN5 的泛函格点 \(f_g\) 的函数了:

[29]:
def ker_lda_c_vwn(rs, z): return(
    + f_aux(A_vwn[1], b_vwn[1], c_vwn[1], x0_vwn[1], rs)
    + f_aux(A_vwn[3], b_vwn[3], c_vwn[3], x0_vwn[3], rs)*f_zeta(z)*(1 - z**4)/fpp
    + DMC(rs, z)*f_zeta(z)*z**4
)

最终我们可以确认由 LibXC 改编的代码与 PySCF 生成的 \(f_g\) 几乎是相等的;但由于数值问题,实际上比对的对象是 \(f_g \rho_g\)

[30]:
np.allclose(ker_lda_c_vwn(rs(rho_vwn_0), 0) * rho_vwn_0, exc_vwn * rho_vwn_0)
[30]:
True

LibXC 改编代码给出的相关能 \(E_\mathrm{c}^\mathrm{VWN5} [\rho] = w_g f_g \rho_g\)

[31]:
(grids.weights * ker_lda_c_vwn(rs(rho_vwn_0), 0) * rho_vwn_0).sum()
[31]:
-1.1961574429398458

VWN5 相关泛函核的图像

与 Slater 泛函同样地,我们可以绘制 VWN5 相关泛函的图像如下;其中横坐标是密度 \(\rho_g\) 的量纲 \([\mathrm{L}]^{-3}\),即玻尔半径的三次倒数;纵坐标是 \(E_\mathrm{x}^\mathrm{Slater}\) 的量纲 \([\mathrm{M}][\mathrm{L}]^2[\mathrm{T}]^{-2}\),即 Hartree。

[32]:
scaler_rho = np.arange(0, 20, 0.01)
plt.plot(scaler_rho, ker_lda_c_vwn(rs(scaler_rho), 0))
[32]:
[<matplotlib.lines.Line2D at 0x7fbb041cb390>]
_images/qcbasic_basic_lda_91_1.png

参考任务解答

任务 (1)

任务 (1.1)
  • Line 1:是为格点张量预留出足够的空间。

    • grid_ao 是三维张量,其中第一维度表示求导梯度,当取为 0, 1, 2, 3 时分别代表没有求导、\(\partial_x\)\(\partial_y\)\(\partial_z\)

    • 第二维度是格点。

    • 第三维度是 AO 基组。在这篇文档中,我们只使用没有求导的部分,即 grid_ao[0]ao_0 \(\phi_{g \mu}\);而被求导的部分则是 grid_ao[1:4] \(\phi_{rg \mu} = \partial_r \phi_{g \mu}\)

  • Line 2:指定每次迭代过程的格点起点指标 g_start、终点指标 g_end,以及给予分配内存的大小 g_memg_startg_end 在后续迭代过程中会变更,但 g_mem 是固定值;g_mem = 2000 意指生成轨道格点过程预留 2000 MB。

  • Line 3:对 ni.block_loop 返回的迭代器进行迭代。ni.block_loop 传入的第四个参数 1 指的是求轨道的一阶导数 \(\phi_{rg \mu}\);若传入 3 则意为求三阶导数 \(\phi_{tsrg \mu} = \partial_t \partial_s \partial_r \phi_{g \mu}\)。该值也会对 grid_ao 应当具有的维度有所影响;当传入 1grid_ao 的第一维度应当是 4,而传入 3 时则应当是 \(1 + 3 + 6 + 10 = 20\)

  • Line 4-5:根据 ni.block_loop 所给出的格点数量,向 grid_ao, grid_weights, grid_coords 等变量进行填充。

  • 后面的代码便是填入张量,并且更新下一次迭代时的格点指标起点为这次迭代时的格点指标终点。

任务 (1.2)

我们首先来看看不推荐使用的那一行代码是否能正常工作:

[33]:
g_mem = 2000
grid_ao_wrong, _, grid_weights_wrong, grid_coords_wrong = next(ni.block_loop(mol, grids, nao, 1, g_mem))
print(np.allclose(grid_weights_wrong, grids.weights))
print(np.allclose(grid_coords_wrong, grids.coords))
print(grid_ao_wrong.shape)
True
True
(4, 130776, 22)

似乎工作一切正常。但是当 g_mem 非常小 (或者分子非常大) 时,就会发现 ni.block_loop 不会一次性给出所有格点:

[34]:
g_mem = 10
grid_ao_wrong, _, grid_weights_wrong, grid_coords_wrong = next(ni.block_loop(mol, grids, nao, 1, g_mem))
print(grid_ao_wrong.shape)
(4, 7040, 22)

DFT 格点由于体积非常庞大,因此对于较大的分子或者交密集的格点,即使计算机的 RAM 再大也可能撑不住一次性在内存读入所有格点,更不提基于这些格点的运算了。所幸的是,对于任何格点积分运算,其中的一个格点的结果不会影响到另一个格点;或者说将 DFT 的总的格点进行分批 (batch) \(g = \{ g_1, g_2, \cdots, g_n \}\),那么

\[\int f(\boldsymbol{r}) \, \mathrm{d} \boldsymbol{r} = \sum_g w_g f_g = \sum_{g} \sum_{i = 1}^{n} w_{g_i} f_{g_i}\]

因此,在实际计算 DFT 格点积分时,一般地总是将格点按合理的内存空间大小分配 batch,对每个 batch 进行分别求和,最后将所有 batch 的结果求和就得到总的 DFT 格点积分了。若定义 g_mem = 10,则我们会分配内存大小使得对于每个 batch \(g_i\) 有轨道格点 \(\phi_{g_i \mu}\) 和轨道一阶导数格点 \(\phi_{rg_i \mu}\) 的内存总占用量不大于 10 MB。

任务 (2)

任务 (2.1)

\(\rho_g\) 在每个格点上的元素的量纲是 \([\mathrm{L}]^{-3}\)

这里补充说明为何 \(w_g\) 的量纲当作 \([\mathrm{L}]^{3}\)。我们回到一维函数 \(f(x)\) 的格点积分上。如果格点在 \(x\) 轴上的分布是均匀的,每个格点之间的距离为 \(\Delta x\),那么

\[\int f(x) \, \mathrm{d} x \sim \sum_g f_g \Delta x\]

此时充当权重的量便是 \(\Delta x\),其量纲在一维空间下为 \([\mathrm{L}]\)。推广到三维空间,则 \(w_g\) 的量纲应当是 \([\mathrm{L}]\) 的三次方。

参考文献

[VWN80]

S. H. Vosko, L. Wilk, and M. Nusair. Accurate spin-dependent electron liquid correlation energies for local spin density calculations: a critical analysis. Can. J. Phys., 58(8):1200–1211, aug 1980. doi:10.1139/p80-159.

GGA 自洽场计算

上一节我们已经讨论了 LDA 的自洽场计算与交换或相关能的计算。但一者,LDA 由于其精度不足以解决普遍的化学问题,因此并不是当前主流的泛函;二者,XYG3 等 xDH 型双杂化泛函所使用的基础泛函来自于 GGA;三者,在 PySCF 中 LDA 与 GGA 的实现方式比较不同。

这一节我们讨论 GGA 的自洽场计算,这会是理解后续文档的重要基础。这里所指的 GGA 允许杂化,但从程序实现的角度上,不允许 LDA 与 meta-GGA。尽管说 GGA 原则上是兼容 LDA 的,但 GGA 因为其泛函核形式中需要 \(\partial_r \rho = \rho_r\),从而有许多 LDA 泛函中没有的表达式;这在以后推导梯度时会有实际体会。因此,从程序的角度上,GGA 退化到 LDA 尽管程序框架没有变化,但代码细节上的变动将会非常巨大。我们以后也仅仅关注 GGA 的代码实现。

GGA 的种类繁多,我们在这里就以 B3LYP 举例。

[1]:
import numpy as np
import warnings
from pyscf import scf, gto, dft

from pkg_resources import resource_filename
from pyxdh.Utilities import FormchkInterface, GridHelper, KernelHelper, GridIterator
from pyxdh.Utilities.test_molecules import Mol_H2O2
from pyxdh.DerivOnce import GradSCF

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

warnings.filterwarnings("ignore")
np.set_printoptions(5, linewidth=150, suppress=True)
dft.numint.libxc = dft.xcfun
ni = dft.numint.NumInt()

量化软件的 GGA 计算

PySCF 计算

我们仍然拿双氧水分子进行计算,格点取 (99, 590)。关于分子、格点初始化的说明,以及如何用 PySCF 构建分子 mol,见 RHF 自洽场文档的说明;关于如何用 PySCF 构建格点 grids,见 格点生成部分

[2]:
mol = Mol_H2O2().mol
grids = Mol_H2O2().gen_grids()
nao = mol.nao
ngrids = grids.weights.size

那么 PySCF 对 B3LYP 的计算可以通过下述的代码实现:

[3]:
scf_eng = dft.RKS(mol)
scf_eng.xc = "B3LYPg"
scf_eng.grids = grids
scf_eng.kernel()
[3]:
-151.3775435605392

需要注意,PySCF 的泛函命名规则遵循大多数除 Gaussian 以外的其它量化软件的规则,因此默认的 B3LYP 关键词下,其中具有的 LDA 相关能贡献是 VWN5 泛函;但 Gaussian 采用的 VWN3。为了取得与 Gaussian 计算较为相近的结果,这里的泛函名称使用 B3LYPg,以使其中的 LDA 相关泛函为 VWN3。

Gaussian 计算

Gaussian 的计算结果储存在 pyxdh 库的资源文件夹下,输入卡文件是 H2O2-B3LYP-freq.gjf,输出的 formchk 文件是 H2O2-B3LYP-freq.fchk。输入卡内容是

%chk=H2O2-B3LYP-freq
#p RB3LYP/6-31G nosymm SCF(VeryTight) Freq Int(Grid=99590)

H2O2 B3LYP Frequency

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
[4]:
ref_fchk = FormchkInterface(resource_filename("pyxdh", "Validation/gaussian/H2O2-B3LYP-freq.fchk"))

Gaussian 计算给出的 B3LYP 自洽场能量用下述方法调取:

[5]:
ref_fchk.total_energy()
[5]:
-151.3775436089372

尽管我们已经使 PySCF 的自洽场计算配置尽可能接近 Gaussian,但结果上仍然有一些差别;尽管在能量上看不出来,但在求 B3LYP 的梯度时,这种差别就会变得明显。

pyxdh 计算

pyxdh 对 B3LYP 的计算与 RHF 的计算方式是相同的:

[6]:
config = {"scf_eng": scf_eng}
scfh = GradSCF(config)
scfh.eng
[6]:
-151.3775435605392

绝大多数情况下,GGA 与 HF 在 pyxdh 下可以等同地处理。

格点与泛函相关定义

我们单列一小节对格点与泛函相关的内容作定义。尽管在上一节中,我们已经对 LDA 型泛函的格点与泛函已经有所了解,但为了以后文档的统一,在这里我们系统地介绍格点与泛函记号,并且介绍 pyxdh 对格点与泛函的处理程序 GridHelperKernelHelper。这一小节中有不少变量未必需要用在 GGA 的能量计算过程中,但为了避免以后零散的说明,我们仍然对它们进行讨论。

格点积分有关的量包括格点本身的性质、轨道或密度及其梯度格点,以及泛函核格点.轨道或密度在原子坐标下的梯度我们会在将来叙述;这一小节的梯度指的是电子坐标的导数.

泛函核无关部分

记号说明

  • \(\rho\) 代表电子态密度密度

  • \(\rho_r = \partial_r \rho\)

  • \(\rho_{rw} = \partial_r \partial_w \rho\)

  • \(\gamma = \rho_r \rho_r\) 表示密度梯度量

  • 轨道与轨道的电子坐标导数记号类同,其使用范例参见 动能积分

注意

以后在公式中,将不会再出现格点记号 \(g\);但程序中仍然需要显式地考虑格点的指标。一般来说,

  • 除了轨道格点外的所有格点张量,格点指标总是在最后一维度;譬如 \(\rho_{rw} \rightarrow \rho_{rwg}\)

  • 包含 AO 轨道指标的张量,AO 轨道指标在最后维度,而格点指标在 AO 轨道指标之前;譬如 \(\phi_{rw \mu} \rightarrow \phi_{rw g \mu}\)

GridHelper 生成格点

GridHelper 可以一次性生成 xDH 型泛函二阶梯度所需要的轨道与密度梯度格点。其中的电子坐标梯度格点和其它标量有:

  • ngrid 格点数量

  • weight 格点权重

  • ao 各阶电子坐标偏导数的 AO 轨道格点

  • ao_0 轨道格点 \(\phi_\mu\)

  • ao_1 轨道格点一阶导数 \(\phi_{r \mu} = \partial_r \phi_\mu\)

  • ao_2 轨道格点二阶导数 \(\phi_{r w \mu} = \partial_r \partial_w \phi_\mu\)

  • ao_3 轨道格点三阶导数 \(\phi_{r w t \mu} = \partial_r \partial_w \partial_t \phi_\mu\)

  • ao_2T 轨道格点二阶导数,但两个坐标分量打包在一个维度中 \(\phi_{T \mu} = \partial_{T_1} \partial_{T_2} \phi_\mu\)

  • ao_3T 轨道格点三阶导数,但其中两个坐标分量打包在一个维度中 \(\phi_{T r \mu} = \partial_{T_1} \partial_{T_2} \partial_r \phi_\mu\)

  • rho_0 密度格点 \(\rho = D_{\mu \nu} \phi_\mu \phi_\nu\)

  • rho_1 密度格点一阶导数 \(\rho_r = \partial_r \rho = 2 D_{\mu \nu} \phi_{r \mu} \phi_\nu\)

  • rho_2 密度格点二阶导数 \(\rho_{rw} = \partial_r \partial_w \rho = 2 D_{\mu \nu} (\phi_{r w \mu} \phi_\nu + \phi_{r \mu} \phi_{w \nu})\)

  • rho_01 密度格点与其一阶导数的合并张量;只用于生成泛函核导数

GridHelper 的初始化可以通过下述语句实现,需要代入的量是分子结构、格点、以及电子态密度。下述 grdhGridHelper 的一个实例:

[7]:
grdh = GridHelper(mol, grids, scfh.D)

若要调用上述的轨道格点,譬如要查看轨道格点的三阶导数 \(\phi_{rwt \mu}\) 的维度信息,只要用下述代码即可:

[8]:
grdh.ao_3.shape
[8]:
(3, 3, 3, 130776, 22)

注意到 3 代表空间中的三个坐标分量 \((x, y, z)\),130,776 代表格点数量,22 代表 AO 基组数量。

任务 (1)

  1. 相比于以前我公式的推导,这里公式的记号因为去除了格点记号,因此不能单纯地从角标查看张量维度的信息了.请自行查看上述各个张量的维度信息。对于张量维度的把握是正确处理量化公式与 numpy 程序实现的第一步,也是至关重要的一步。

  2. (演示) 我们在上一节的 轨道格点 一段中已经尝试生成轨道格点。请尝试仿照上一节的文档,生成上述格点,并用 np.allclose 验证结果。

    提示:ni.block_loop(mol, grids, nao, 1, g_mem)1 只会生成 AO 轨道格点的一阶电子坐标导数;试将 1 替换为 3,就可以生成三阶导数了。

  3. 生成密度格点一阶电子坐标导数的另一个看起来更合理的做法是 \(\rho_r = D_{\mu \nu} (\phi_{r \mu} \phi_\nu + \phi_\mu \phi_{r \nu})\)。试问为何 \(\rho_r = 2 D_{\mu \nu} \phi_{r \mu} \phi_\nu\) 也是正确的?

    这是一个非常关键的问题。很多时候我们需要利用 \(\mu, \nu\) 的对称性,但不是所有 \(\mu, \nu\) 都具有对称性,也不是所有具有对称性的表达式都可以被简化。对这类问题的理解会极大帮助我们正确地推导公式并作公式与代码的简化。

GridIterator 生成格点

GridHelper 作为示范范例会在文档中经常使用。但在 上一节的习题 中,我们知道一般来说,对于稍大一些的分子或稍高一些的格点精度,一般电脑的内存就无法装下所有的 DFT 格点。因此,如果在计算实际体系时使用一次性生成所有格点的 GridHelper,很容易出现内存不够的情况。为此,分批地生成格点是非常有必要的。分批生成格点的好处在于缓解内存压力,但缺点是每次生成的格点都需要重新计算一次,即以计算时间作为代价换取内存空间。

在 pyxdh 中,分批生成格点的类为 GridIterator;这个类经常用于写实际计算用的 pyxdh 程序。其程序的使用方式非常接近 GridHelper,只是需要作为类迭代器来使用。

作为例子,我们看利用 GridIterator 生成 \(\rho_r\) 的过程。我们定义 GridIterator 的实例是 grdit 并分配 50 MB 内存用于在每个 batch 中生成轨道格点;GridIterator 生成的 \(\rho_r\) 的变量名为 rho_1

[9]:
grdit = GridIterator(mol, grids, scfh.D, deriv=3, memory=100)
rho_1 = np.zeros((3, grids.weights.size))
g_start, g_end, g_count = 0, 0, 0
for it in grdit:
    g_end += grdit.ngrid
    rho_1[:, g_start:g_end] = it.rho_1
    g_start = g_end
    g_count += 1
print(np.allclose(rho_1, grdh.rho_1))
print("Iteration times:", g_count)
True
Iteration times: 10

上面代码中,关键的两行分别是第 4 行的迭代过程,和第 6 行调用 it.rho_1。变量 it 在迭代过程中,从调用方式上可以几乎与 GridHelper 实例相同。

由于 GridIterator 调用上多少有些方便,加之双氧水分子体系也足够小,因此在文档中通常不用 GridIterator

泛函核有关部分

记号说明

  • \(f\) 代表泛函核;泛函核满足关系:在函数图景下 \(E_\mathrm{xc} = \int f[\rho] \rho(\boldsymbol{r}) \, \mathrm{d} \boldsymbol{r}\),或格点积分下,\(E_\mathrm{xc} = f \rho\)

  • \(f_\rho = \partial_\rho (f \rho)\)注意不是 \(\partial_\rho f\)这种记号可能引起歧义但足够简洁

  • \(f_\gamma = \partial_\gamma (f \rho)\)

  • \(f_{\rho \gamma} = \partial_\rho \partial_\gamma (f \rho)\),其它高阶导数同理

  • \(c_\mathrm{x}\) 代表杂化泛函中的精确交换积分贡献.

注意

所有 DFT 格点权重将会归并到泛函核向量中。举例来说,使用前一节的带格点 \(g\) 与权重向量 \(w_g\) 的两个表达式

\[w_g f_g, \quad E_\mathrm{xc} = w_g f_g \rho_g\]

在以后的文档中则将会写为

\[f, \quad E_\mathrm{xc} = f \rho\]

KernelHelper 类通过读入必要的密度格点信息,返回泛函核及其相对于电子密度、密度梯度量 \(\rho, \gamma\) 的格点向量。该类有以下成员变量:

  • c_x 泛函杂化系数 \(c_\mathrm{x}\)

  • exc 带权重的 \(f\)

  • fr 带权重的 \(f_\rho\)

  • fg 带权重的 \(f_\gamma\)

  • frr 带权重的 \(f_{\rho \rho}\)

  • frg 带权重的 \(f_{\rho \gamma}\)

  • fgg 带权重的 \(f_{\gamma \gamma}\)

  • frrr 带权重的 \(f_{\rho \rho \rho}\)

  • frrg 带权重的 \(f_{\rho \rho \gamma}\)

  • frgg 带权重的 \(f_{\rho \gamma \gamma}\)

  • fggg 带权重的 \(f_{\gamma \gamma \gamma}\)

KernelHelper 的初始化可以通过下述语句实现,需要代入的量是 GridHelperGridIterator 的实例、泛函名称以及求导级别。下述 kerhKernelHelper 的一个实例:

[10]:
kerh = KernelHelper(grdh, "B3LYPg", deriv=3)

若要调用上述的泛函核或其导数格点,譬如要查看带权重的 \(f_{\rho \gamma}\) 的维度信息,只要用下述代码即可:

[11]:
kerh.frg.shape
[11]:
(130776,)

需要注意所有泛函核格点都是一维向量;尽管看上去 \(f_{\rho \gamma}\) 有两个角标,连同格点指标一起似乎应该组成三维张量。这是因为 \(\rho, \gamma\) 对于当前体系来说是独一无无二的;不像 \(\phi_{rw \mu}\) 中,\(r, w\) 分别可能有三种取值,而 \(\mu\) 在当前的 6-31G 双氧水下有 22 中取值,因此 \(\phi_{rw \mu}\) 连同格点指标应是四维张量。

任务 (2)

  1. (演示) 我们在上一节的 Slater 交换能计算 一段中已经尝试生成轨道格点。请尝试仿照上一节的文档,生成上述带权重泛函核格点,并用 np.allclose 验证结果。

    提示:尝试使用 ni.eval_xc("B3LYPg", grdh.rho_01, deriv=3),查看器返回值。

  2. (可选) KernelHelper 在初始化时,不只可以代入 GridHelper 的实例,还可以代入 GridIterator 的实例。试用 GridIterator 实例生成完整的带权重的泛函核格点。

GGA 自洽场实现参考

前三个问题是电子数、交换相关能与交换相关势。这些可以通过函数 ni.nr_rks 生成。

[12]:
ni.nr_rks.__func__
[12]:
<function pyscf.dft.numint.nr_rks(ni, mol, grids, xc_code, dms, relativity=0, hermi=0, max_memory=2000, verbose=None)>
[13]:
xc_n, xc_e, xc_v = ni.nr_rks(mol, grids, "B3LYPg", scfh.D)

电子数 \(n_\mathrm{nelec}\)

电子数其实通过分子本身就已经被定义:

[14]:
mol.nelectron
[14]:
18

但我们可以借下述格点积分验证生成的密度是否合理:

\[n_\mathrm{elec} = \rho\]
[15]:
(grids.weights * grdh.rho_0).sum()
[15]:
18.000000186404467

我们可以验证上述结果与 PySCF 的结果相等:

[16]:
np.allclose((grids.weights * grdh.rho_0).sum(), xc_n)
[16]:
True

交换相关能 \(E_\mathrm{xc}\)

\[E_\mathrm{xc} = f \rho\]
[17]:
(kerh.exc * grdh.rho_0).sum()
[17]:
-14.506876761363431

我们可以验证上述结果与 PySCF 的结果相等:

[18]:
np.allclose((kerh.exc * grdh.rho_0).sum(), xc_e)
[18]:
True

之所以这里的代码不需要乘上 grids.weights,但计算电子数需要乘上 grids.weights,是因为 KernelHelper 已经将格点权重乘在泛函核的格点中了。在以后的文档中,一般不会出现需要显式地乘以 grids.weights 的情况。

交换相关势 \(v_{\mu \nu}^\mathrm{xc} [\rho]\)

\[v_{\mu \nu}^\mathrm{xc} [\rho] = f_\rho \phi_\mu \phi_\nu + 2 f_\gamma \rho_r (\phi_{r \mu} \phi_{\nu} + \phi_{\mu} \phi_{r \nu})\]
[19]:
np.allclose(
    + np.einsum("g, gu, gv -> uv", kerh.fr, grdh.ao_0, grdh.ao_0)
    + 2 * np.einsum("g, rg, rgu, gv -> uv", kerh.fg, grdh.rho_1, grdh.ao_1, grdh.ao_0)
    + 2 * np.einsum("g, rg, gu, rgv -> uv", kerh.fg, grdh.rho_1, grdh.ao_0, grdh.ao_1),
    xc_v
)
[19]:
True

任务 (3)

  1. 以前我们生成密度的一阶梯度 \(\rho_r\) 时提到,那时的公式与代码中的 \(\phi_{r \mu} \phi_\nu + \phi_\mu \phi_{r \nu}\) 可以简化为 \(2 \phi_{r \mu} \phi_\nu\) 进行计算。试问现在生成交换相关势时,是否也可以这么简化?为什么?

  2. (可选) 你可能已经理解不可以像生成 \(\rho_r\) 时那样简化 \(\phi_{r \mu} \phi_\nu + \phi_\mu \phi_{r \nu}\) 了,但你仍然可以依靠 \(\mu, \nu\) 角标的对称性质,对上面代码块的计算耗时优化到原先的 2/3 倍。请提出你的解决方案。

    依靠 \(\mu, \nu\) 角标的对称性质将会在以后经常使用;这不仅会提高代码效率,同时也简化公式推导过程。

  3. (可选) 我们没有给出交换相关势的推导过程.请尝试推导交换相关势,并将你的推导与上面的公式对应,以熟悉这份笔记的记号体系。

  4. 既然 \(v_{\mu \nu}^\mathrm{xc} [\rho]\) 是与密度有关的量,那么其构成中,哪些张量会具体地因与体系密度不同而变化,而那些则始终不变?你是否认为用 \(D_{\kappa \lambda}\) 替换掉方括号中的 \(\rho\) 是合理的行为?

重叠积分 \(S_{\mu \nu}\)

DFT 格点积分的一个比较有意思的应用方式是求电子积分。但需要指出,DFT 格点积分远不如 Gaussian 基组的解析积分来得快,精度上也有瑕疵。

\[S_{\mu \nu} = \langle \mu | \nu \rangle = \phi_\mu \phi_\nu\]
[20]:
np.allclose(
    mol.intor("int1e_ovlp"),
    np.einsum("g, gu, gv -> uv", grdh.weight, grdh.ao_0, grdh.ao_0),
    atol=1e-7
)
[20]:
True
[21]:
%%timeit -r 7 -n 7
mol.intor("int1e_ovlp")
739 µs ± 380 µs per loop (mean ± std. dev. of 7 runs, 7 loops each)
[22]:
%%timeit -r 7 -n 7
np.einsum("g, gu, gv -> uv", grdh.weight, grdh.ao_0, grdh.ao_0)
19.1 ms ± 1.34 ms per loop (mean ± std. dev. of 7 runs, 7 loops each)

动能积分 \(T_{\mu \nu}\)

\[T_{\mu \nu} = \langle \mu | -\frac{1}{2} \partial_r^2 | \nu \rangle = -\frac{1}{2} \phi_{\mu} \phi_{rr \nu}\]
[23]:
np.allclose(
    mol.intor("int1e_kin"),
    - 0.5 * np.einsum("g, gu, gvr -> uv", grdh.weight, grdh.ao_0, grdh.ao_2.diagonal(axis1=0, axis2=1)),
    atol=1e-6
)
[23]:
True
[24]:
%%timeit -r 7 -n 7
mol.intor("int1e_kin")
786 µs ± 415 µs per loop (mean ± std. dev. of 7 runs, 7 loops each)
[25]:
%%timeit -r 7 -n 7
- 0.5 * np.einsum("g, gu, gvr -> uv", grdh.weight, grdh.ao_0, grdh.ao_2.diagonal(axis1=0, axis2=1))
28.2 ms ± 894 µs per loop (mean ± std. dev. of 7 runs, 7 loops each)

Fock 矩阵 (GGA) \(F_{\mu \nu} [R_{\kappa \lambda}]\)

手动实现

回顾 RHF 的 Fock 矩阵

\[F_{\mu \nu}^\mathrm{HF} [R_{\kappa \lambda}] = h_{\mu \nu} + J_{\mu \nu}[R_{\kappa \lambda}] - \frac{1}{2} K_{\mu \nu}[R_{\kappa \lambda}]\]

对于 GGA 而言,其 Hamiltonian Core、库伦积分仍然相同,但交换积分前需要乘以杂化系数 \(c_\mathrm{x}\),并且要加上交换相关势。因此,

\[F_{\mu \nu} = h_{\mu \nu} + J_{\mu \nu}[R_{\kappa \lambda}] - \frac{1}{2} c_\mathrm{x} K_{\mu \nu}[R_{\kappa \lambda}] + v_{\mu \nu}^\mathrm{xc} [R_{\kappa \lambda}]\]

根据 B3LYP 的原始文献 [Bec93] 的式 (3),B3LYP 的 Exact 交换系数应当是 \(c_\mathrm{x} = 0.2\)。我们可以通过下述方式给出交换系数 cx

[26]:
cx = ni.hybrid_coeff("B3LYPg")
cx
[26]:
0.2
[27]:
np.random.seed(1)
R = np.random.random((nao, nao))
R += R.T

以此可以给出 F_0_ao_R \(F_{\mu \nu} [R_{\kappa \lambda}]\)

[28]:
F_0_ao_R = scfh.H_0_ao + scf_eng.get_j(dm=R) - 0.5 * cx * scf_eng.get_k(dm=R) + ni.nr_rks(mol, grids, "B3LYPg", R)[2]

PySCF 实现

与 RHF 相同地,可以使用 get_fock 成员函数给出任意密度下的 Fock 矩阵:

[29]:
np.allclose(scf_eng.get_fock(dm=R), F_0_ao_R)
[29]:
True

pyxdh 实现

pyxdh 的实例 scfh 不提供任意密度下的 Fock 矩阵求取方法;因此只能给出自洽场密度下的 Fock 矩阵 \(F_{\mu \nu} [D_{\kappa \lambda}]\)

[30]:
np.allclose(scfh.F_0_ao, scf_eng.get_fock())
[30]:
True

电子态能量 (GGA) \(E_\mathrm{elec}[X_{\mu \nu}]\)

回顾 RHF 的 电子态能量

\[E_\mathrm{elec}^\mathrm{HF} [R_{\mu \nu}] = (h_{\mu \nu} + \frac{1}{2} J_{\mu \nu} [R_{\kappa \lambda}] - \frac{1}{4} K_{\mu \nu} [R_{\kappa \lambda}]) R_{\mu \nu}\]

类似于 Fock 矩阵,GGA 的电子态能量的形式是

\[E_\mathrm{elec} [R_{\mu \nu}] = (h_{\mu \nu} + \frac{1}{2} J_{\mu \nu} [R_{\kappa \lambda}] - \frac{1}{4} c_\mathrm{x} K_{\mu \nu} [R_{\kappa \lambda}]) R_{\mu \nu} + f^R \rho^R\]

其中 \(f^R\), \(\rho^R\) 表示这些密度格点是由 \(R_{\mu \nu}\) 所产生的。

PySCF 实现

[31]:
eng_elec_R = scf_eng.energy_elec(dm=R)[0]
eng_elec_R
[31]:
123.10771512394984

pyxdh 实现

pyxdh 的实例 scfh 也不提供任意密度下的电子态能量,只能给出自洽场密度下的总能量;若要获取电子态能量,需要减去核与核排斥能:

[32]:
np.allclose(scfh.eng - scf_eng.energy_nuc(), scf_eng.energy_elec()[0])
[32]:
True

任务 (4)

  1. (可选) 请尝试不依靠 PySCF 的成员函数 get_jget_kni_rks 以及分子轨道系数 \(C_{\mu p}\),但可以使用 mol 的成员函数、ni.block_loop, ni.eval_xcGridHelper, KernelHelper、交换系数 \(c_\mathrm{x} = 0.2\)、以及 numpy,给出任意密度下双氧水分子 B3LYP 的体系能量 \(E_\mathrm{elec} [R_{\mu \nu}]\),并与 eng_elec_R 的结果作对比。

  2. 杂化 GGA 泛函的电子态能量,除去 \(h_{\mu \nu}\) 贡献项之外,是否可以由 \(\rho\)\(D_{\mu \nu}\) 确定而不依赖 \(C_{\mu p}\)

  3. 既然交换相关势 \(v_{\mu \nu}^\mathrm{xc} [\rho]\) 是由交换相关能 \(E_\mathrm{xc} [\rho]\) 对密度的变分 \(\rho\) 而来:

    \[v_{\mu \nu}^\mathrm{xc} [\rho] = \langle \mu | \frac{\delta E_\mathrm{xc} [\rho]}{\delta \rho} | \nu \rangle\]

    那么交换相关能是否可以从交换相关势与电子密度的乘积得来?

    \[\begin{split}\begin{align} E_\mathrm{xc} [\rho] \stackrel{?}{=} v_{\mu \nu}^\mathrm{xc} [\rho] D_{\mu \nu} &= \delta_{ij} \langle i | \frac{\delta E_\mathrm{xc} [\rho]}{\delta \rho} | j \rangle \\ &= \int \frac{\delta E_\mathrm{xc} [\rho]}{\delta \rho} \rho (\boldsymbol{r}) \, \mathrm{d} \boldsymbol{r} \\ \end{align}\end{split}\]

注意

我们在 RHF 与 GGA 自洽场两节中都提到,pyxdh 的实例无法提供任意密度下的电子态能量或 Fock 矩阵,或者说,在泛函 A 的能量表达式中代入 任意 密度。但是 pyxdh 同时也可以计算 XYG3 型泛函的能量;而 XYG3 型泛函是非自洽型泛函,需要在泛函 A 的能量表达式中代入 指定的 泛函 B 的自洽场密度。

这一定程度上是处于代码安全的角度出发而产生的结果。关于上述看似矛盾的功能描述,请参考后一节关于 XYG3 型泛函的文档。

参考任务解答

任务 (1)

任务 (1.1)
[33]:
print("{:8s}{}".format("Name", "Shape"))
print("{:8s}{}".format("------", "------"))
for name in ["weight", "ao", "ao_0", "ao_1", "ao_2", "ao_3", "ao_2T", "ao_3T", "rho_0", "rho_1", "rho_2", "rho_01"]:
    print("{:8s}{}".format(name, grdh.__dict__[name].shape))
Name    Shape
------  ------
weight  (130776,)
ao      (20, 130776, 22)
ao_0    (130776, 22)
ao_1    (3, 130776, 22)
ao_2    (3, 3, 130776, 22)
ao_3    (3, 3, 3, 130776, 22)
ao_2T   (6, 130776, 22)
ao_3T   (3, 6, 130776, 22)
rho_0   (130776,)
rho_1   (3, 130776)
rho_2   (3, 3, 130776)
rho_01  (4, 130776)
任务 (1.2) 演示

下面我们生成张量,并逐一与 GridHelper 的实例 grdh 的成员变量作比较。

格点数量 ngrid

[34]:
ngrid = grids.weights.size
ngrid == grdh.ngrid
[34]:
True

格点权重 weight

[35]:
weight = grids.weights
np.allclose(weight, grdh.weight)
[35]:
True

各阶电子坐标偏导数的 AO 轨道格点 ao

[36]:
ao = np.zeros((20, ngrid, nao))
g_start, g_end, g_mem = 0, 0, 2000
for inner_ao, _, _, _ in ni.block_loop(mol, grids, nao, 3, g_mem):
    g_end = g_start + ao.shape[1]
    ao[:, g_start:g_end, :] = inner_ao
    g_start = g_end
[37]:
np.allclose(ao, grdh.ao)
[37]:
True

轨道格点 ao_0 \(\phi_\mu\)

[38]:
ao_0 = ao[0]
np.allclose(ao_0, grdh.ao_0)
[38]:
True

轨道格点一阶导数 ao_1 \(\phi_{r \mu} = \partial_r \phi_\mu\)

[39]:
ao_1 = ao[1:4]
np.allclose(ao_1, grdh.ao_1)
[39]:
True

轨道格点二阶导数 ao_2 \(\phi_{r w \mu} = \partial_r \partial_w \phi_\mu\)

[40]:
XX, XY, XZ, YY, YZ, ZZ = range(4, 10)
ao_2 = ao[([XX, XY, XZ],
           [XY, YY, YZ],
           [XZ, YZ, ZZ],
          ), :, :]
np.allclose(ao_2, grdh.ao_2)
[40]:
True

轨道格点二阶导数,但两个坐标分量打包在一个维度中 ao_2T \(\phi_{T \mu} = \partial_{T_1} \partial_{T_2} \phi_\mu\)

[41]:
ao_2T = ao[4:10]
np.allclose(ao_2T, grdh.ao_2T)
[41]:
True

轨道格点三阶导数 ao_3 \(\phi_{r w t \mu} = \partial_r \partial_w \partial_t \phi_\mu\)

[42]:
XXX, XXY, XXZ, XYY, XYZ, XZZ, YYY, YYZ, YZZ, ZZZ = range(10, 20)
ao_3 = ao[([[XXX, XXY, XXZ], [XXY, XYY, XYZ], [XXZ, XYZ, XZZ]],
           [[XXY, XYY, XYZ], [XYY, YYY, YYZ], [XYZ, YYZ, YZZ]],
           [[XXZ, XYZ, XZZ], [XYZ, YYZ, YZZ], [XZZ, YZZ, ZZZ]],
          ), :, :]
np.allclose(ao_3, grdh.ao_3)
[42]:
True

轨道格点三阶导数,但其中两个坐标分量打包在一个维度中 ao_3T \(\phi_{T r \mu} = \partial_{T_1} \partial_{T_2} \partial_r \phi_\mu\)

[43]:
XXX, XXY, XXZ, XYY, XYZ, XZZ, YYY, YYZ, YZZ, ZZZ = range(10, 20)
ao_3T = ao[([XXX, XXY, XXZ, XYY, XYZ, XZZ],
            [XXY, XYY, XYZ, YYY, YYZ, YZZ],
            [XXZ, XYZ, XZZ, YYZ, YZZ, ZZZ],
          ), :, :]
np.allclose(ao_3T, grdh.ao_3T)
[43]:
True

密度格点 rho_0 \(\rho = D_{\mu \nu} \phi_\mu \phi_\nu\)

[44]:
rho_0 = np.einsum("uv, gu, gv -> g", scfh.D, ao_0, ao_0)
np.allclose(rho_0, grdh.rho_0)
[44]:
True

密度格点一阶导数 rho_1 \(\rho_r = 2 D_{\mu \nu} \phi_{r \mu} \phi_\nu\)

[45]:
rho_1 = 2 * np.einsum("uv, rgu, gv -> rg", scfh.D, ao_1, ao_0)
np.allclose(rho_1, grdh.rho_1)
[45]:
True

密度格点与其一阶导数的合并张量 rho_01

[46]:
rho_01 = np.vstack([rho_0[None, :], rho_1])
np.allclose(rho_1, grdh.rho_1)
[46]:
True

密度格点二阶导数 rho_2 \(\rho_{rw} = 2 D_{\mu \nu} (\phi_{r w \mu} \phi_\nu + \phi_{r \mu} \phi_{w \nu})\)

[47]:
rho_2 = 2 * (
    + np.einsum("uv, rwgu, gv -> rwg", scfh.D, ao_2, ao_0)
    + np.einsum("uv, rgu, wgv -> rwg", scfh.D, ao_1, ao_1)
)
np.allclose(rho_2, grdh.rho_2)
[47]:
True
任务 (1.3)

首先我们验证 \(\rho_r = D_{\mu \nu} (\phi_{r \mu} \phi_\nu + \phi_\mu \phi_{r \nu})\) 是正确的。我们重新定义 rho_1 变量:

[48]:
rho_1 = (
    + np.einsum("uv, rgu, gv -> rg", scfh.D, grdh.ao_1, grdh.ao_0)
    + np.einsum("uv, gu, rgv -> rg", scfh.D, grdh.ao_0, grdh.ao_1)
)
np.allclose(rho_1, grdh.rho_1)
[48]:
True

但是我们会发现,如果定义临时张量 T \(T_{r \mu \nu} = D_{\mu \nu} \phi_{r \mu} \phi_\nu\)R \(R_{r \mu \nu} = D_{\mu \nu} \phi_\mu \phi_{r \nu}\),那么这两个张量应当是不相等的。(出于内存大小考虑,程序里的 TR 已经对格点指标求和)

[49]:
T = np.einsum("uv, rgu, gv -> ruv", scfh.D, grdh.ao_1, grdh.ao_0)
R = np.einsum("uv, gu, rgv -> ruv", scfh.D, grdh.ao_0, grdh.ao_1)
np.allclose(T, R)
[49]:
False

尽管上述两个张量在最后两个维度上作转置之后结果便是一样的了,即 \(T_{r \mu \nu} = R_{r \nu \mu}\)

[50]:
np.allclose(T, R.swapaxes(-1, -2))
[50]:
True

现在暂时不使用 Einstein Summation Convention。根据上述分析,如果对于表达式

\[D_{\mu \nu} (\phi_{r \mu} \phi_\nu + \phi_\mu \phi_{r \nu})\]

不对任何指标求和而给出关于 \(r, \mu, \nu\) 或再多包含一个格点指标维度的张量,那么我们不可以将上式缩减为 \(2 D_{\mu \nu} \phi_{r \mu} \phi_\nu\)

但若对于表达式

\[\rho_r = \sum_{\mu \nu} D_{\mu \nu} (\phi_{r \mu} \phi_\nu + \phi_\mu \phi_{r \nu})\]

情况就不同了。由于处在求和过程中,因此维度相同的角标 \(\mu, \nu\) 在任意一个被求和项中可以相互交换位置。因此,

\[\rho_r = \sum_{\mu \nu} \left( D_{\mu \nu} \phi_{r \mu} \phi_\nu + \color{blue} {D_{\nu \mu} \phi_\nu \phi_{r \mu}} \right)\]

同时注意到,由于密度矩阵 \(D_{\mu \nu}\) 是对称矩阵,因此 \(D_{\mu \nu} = D_{\nu \mu}\),因此

\[\rho_r = \sum_{\mu \nu} \left( D_{\mu \nu} \phi_{r \mu} \phi_\nu + \color{blue} {D_{\mu \nu}} \phi_\nu \phi_{r \mu} \right) = 2 \sum_{\mu \nu} D_{\mu \nu} \phi_{r \mu} \phi_\nu\]

因此,我们可以利用 \(\mu, \nu\) 的对称性简化 \(\rho_r\) 的表达式,但需要注意到其前提是表达式对 \(\mu, \nu\) 同时求和,以及利用到了 \(D_{\mu \nu}\) 作为对称矩阵的性质。

任务 (2)

任务 (2.1) 演示

Exact 交换系数

根据 B3LYP 的原始文献 [Bec93] 的式 (3),B3LYP 的 Exact 交换系数应当是 \(c_\mathrm{x} = 0.2\)。在 PySCF 中,交换系数可以通过 hybrid_coeff 给出:

[51]:
cx = ni.hybrid_coeff("B3LYPg")
cx
[51]:
0.2

带权重的泛函核及其电子坐标梯度

首先我们对比一下上一节调用 eval_xc 的代码与这一节的代码:

ni.eval_xc("Slater", rho_s_0, deriv=0)[0]   # Last section
ni.eval_xc("B3LYPg", grdh.rho_01, deriv=3)  # This section

首先泛函名称改变了。

其次,上一节的 rho_s_0 是电子态密度 \(\rho\),但这一节的 grdh.rho_01 不仅有电子态密度 \(\rho\),还有密度的电子坐标分量一阶梯度 \(\rho_r\) (回顾 rho_01 应该是 \(4 \times n_\mathrm{grid} \times n_\mathrm{AO}\) 大小的张量)。上一节的泛函是 LDA 泛函,因此只需要代入密度即可;但这一节是 GGA 泛函,因此还需要代入密度梯度量 \(\gamma\),但同时 \(\gamma = \rho_r \rho_r\),因此代入密度梯度也是等价的。

最后,上一节由于只通过密度计算泛函的能量,因此只需要泛函核的格点 \(f\) 即可;但这一节还需要导出关于密度 \(\rho\) 与密度梯度量 \(\gamma\) 的格点导数,因此需要设定梯度最高为三阶梯度。

eval_xc 共返回四个变量,分别对应 0-3 阶密度与密度梯度量的导数:

[52]:
grid_exc, grid_vxc, grid_fxc, grid_kxc = ni.eval_xc("B3LYPg", grdh.rho_01, deriv=3)

零阶导数 exc \(f\)

[53]:
exc = grid_exc * grids.weights
[54]:
print(np.allclose(exc, kerh.exc))
True

一阶导数 fr \(f_\rho\), fg \(f_\gamma\)

[55]:
fr = grid_vxc[0] * grids.weights
fg = grid_vxc[1] * grids.weights
[56]:
print(np.allclose(fr, kerh.fr))
print(np.allclose(fg, kerh.fg))
True
True

二阶导数 frr \(f_{\rho \rho}\), frg \(f_{\rho \gamma}\), fgg \(f_{\gamma \gamma}\)

[57]:
frr = grid_fxc[0] * grids.weights
frg = grid_fxc[1] * grids.weights
fgg = grid_fxc[2] * grids.weights
[58]:
print(np.allclose(frr, kerh.frr))
print(np.allclose(frg, kerh.frg))
print(np.allclose(fgg, kerh.fgg))
True
True
True

三阶导数 frrr \(f_{\rho \rho \rho}\), frrg \(f_{\rho \rho \gamma}\), frgg \(f_{\rho \gamma \gamma}\), fggg \(f_{\gamma \gamma \gamma}\)

[59]:
frrr = grid_kxc[0] * grids.weights
frrg = grid_kxc[1] * grids.weights
frgg = grid_kxc[2] * grids.weights
fggg = grid_kxc[3] * grids.weights
[60]:
print(np.allclose(frrr, kerh.frrr))
print(np.allclose(frrg, kerh.frrg))
print(np.allclose(frgg, kerh.frgg))
print(np.allclose(fggg, kerh.fggg))
True
True
True
True
任务 (2.2) 可选

我们以生成当前密度下的 B3LYP 交换相关能作为例子。回顾交换相关能 eng_xc 的表达式是 \(E_\mathrm{xc} = f \rho\)

[61]:
eng_xc = 0
grdit = GridIterator(mol, grids, scfh.D, deriv=3, memory=100)
g_count = 0
for it in grdit:
    ker_it = KernelHelper(it, "B3LYPg", deriv=0)
    eng_xc += (ker_it.exc * it.rho_0).sum()
    g_count += 1
print("Iterated times:", g_count)
print("B3LYP xc energy:", eng_xc)
Iterated times: 10
B3LYP xc energy: -14.506876761363433

可以发现与后文中计算得到的交换相关能的结果几乎相等。

任务 (3)

任务 (3.1)

显然是不行的。如果我们按照下述错误的交换相关势

\[v_{\mu \nu}^\mathrm{xc, wrong} [\rho] = f_\rho \phi_\mu \phi_\nu + 4 f_\gamma \rho_r \phi_{r \mu} \phi_{\nu}\]

并与正确的交换相关势 xc_v \(v_{\mu \nu}^\mathrm{xc}\) 作对比,可以看到是无法对比成功的:

[62]:
np.allclose(
    + np.einsum("g, gu, gv -> uv", kerh.fr, grdh.ao_0, grdh.ao_0)
    + 4 * np.einsum("g, rg, rgu, gv -> uv", kerh.fg, grdh.rho_1, grdh.ao_1, grdh.ao_0),
    xc_v
)
[62]:
False

这是因为等式两边并没有对 \(\mu, \nu\) 求和,因此不能像密度求取过程一样,简单地对于某一个分项对换 \(\mu, \nu\) 而保持其它项不对换,所得结果还能相等。

任务 (3.2) 可选

尽管交换相关势

\[v_{\mu \nu}^\mathrm{xc} [\rho] = f_\rho \phi_\mu \phi_\nu + 2 f_\gamma \rho_r (\phi_{r \mu} \phi_{\nu} + \phi_{\mu} \phi_{r \nu})\]

不能通过合并分项进行简化,但我们可以利用交换相关势关于 \(\mu, \nu\) 对称的性质,进行对称性的简化:

\[v_{\mu \nu}^\mathrm{xc} [\rho] = \frac{1}{2} f_\rho \phi_\mu \phi_\nu + 2 f_\gamma \rho_r \phi_{r \mu} \phi_{\nu} + \mathrm{swap} (\mu, \nu)\]

其中,上式的 \(\mathrm{swap} (\mu, \nu)\) 是指等式右边的所有项对换 \(\mu, \nu\) 的结果;特别地,对于上述表达式而言,指代的是 \(\frac{1}{2} f_\rho \phi_\nu \phi_\mu + 2 f_\gamma \rho_r \phi_{r \nu} \phi_{\mu}\)

现在我们定义两个函数,他们都返回交换相关势 \(v_{\mu \nu}^\mathrm{xc} [\rho]\),只是 vxc_no_swap 不使用 \(\mathrm{swap} (\mu, \nu)\),而 vxc_with_swap 使用了 \(\mathrm{swap} (\mu, \nu)\)。定义函数而非变量的目的只是为了方便时间效率测评。

[63]:
def vxc_no_swap():
    return (
        + np.einsum("g, gu, gv -> uv", kerh.fr, grdh.ao_0, grdh.ao_0)
        + 2 * np.einsum("g, rg, rgu, gv -> uv", kerh.fg, grdh.rho_1, grdh.ao_1, grdh.ao_0)
        + 2 * np.einsum("g, rg, gu, rgv -> uv", kerh.fg, grdh.rho_1, grdh.ao_0, grdh.ao_1)
    )

def vxc_with_swap():
    ret = (
        + 0.5 * np.einsum("g, gu, gv -> uv", kerh.fr, grdh.ao_0, grdh.ao_0)
        + 2 * np.einsum("g, rg, rgu, gv -> uv", kerh.fg, grdh.rho_1, grdh.ao_1, grdh.ao_0)
    )
    return ret + ret.swapaxes(-1, -2)

上述两个做法都可以得到正确的交换相关势 xc_v \(v_{\mu \nu}^\mathrm{xc} [\rho]\)

[64]:
print(np.allclose(vxc_no_swap(), xc_v))
print(np.allclose(vxc_with_swap(), xc_v))
True
True

但是从时间消耗上,后者的时间消耗大约是前者的 \(2/3\)

[65]:
%%timeit -r 7 -n 7
vxc_no_swap()
83.2 ms ± 4.69 ms per loop (mean ± std. dev. of 7 runs, 7 loops each)
[66]:
%%timeit -r 7 -n 7
vxc_with_swap()
51.6 ms ± 4.05 ms per loop (mean ± std. dev. of 7 runs, 7 loops each)

这是因为后者少去一次张量缩并 \(f_\gamma \rho_r \phi_{\mu} \phi_{r \nu}\)。张量缩并的耗时比张量求和、标量与张量乘积的时间都要多得多。

任务 (3.3) 可选

在这里我们暂时不使用 Einstein Summation Convention 以及格点积分,而纯粹地讨论数学推导。这个问题并不是非常显然或者易于回答的。

错误推导

下面我们介绍一种错误推导方法。蓝色高亮表示推导过程中发生变化的项,红色高亮表示推导错误的部分。为了与程序的部分使用的记号比较接近,这里定义 \(F[\rho, \gamma] = f[\rho, \gamma] \rho\),因此交换相关能可以表示为 \(E_\mathrm{xc} = \int F[\rho, \gamma] \, \mathrm{d} \boldsymbol{r}\)

\[\begin{split}\begin{align} \color{red}{\phi_{\mu} \boldsymbol(r) \frac{\delta (F[\rho, \gamma])}{\delta \rho (r)} \phi_{\nu} (r)} &= \frac{\partial F}{\partial \rho} \phi_{\mu} \boldsymbol(r) \phi_{\nu} \boldsymbol(r) + \frac{\partial F}{\partial \gamma} \color{red}{\frac{\delta \color{blue}{\gamma}}{\delta \rho (r)}} \big( \phi_{\mu} \boldsymbol(r) \phi_{\nu} \boldsymbol(r) \big) \\ &= \frac{\partial F}{\partial \rho} \phi_{\mu} \boldsymbol(r) \phi_{\nu} \boldsymbol(r) + \frac{\partial F}{\partial \gamma} \frac{\color{blue}{\delta (\nabla \rho \cdot \nabla \rho)}}{\delta \rho (r)} \big( \phi_{\mu} \boldsymbol(r) \phi_{\nu} \boldsymbol(r) \big) \\ &= \frac{\partial F}{\partial \rho} \phi_{\mu} \boldsymbol(r) \phi_{\nu} \boldsymbol(r) + 2 \frac{\partial F}{\partial \gamma} \nabla \rho \boldsymbol(r) \cdot \color{blue}{\frac{\delta \nabla \rho}{\delta \rho (r)}} \big( \phi_{\mu} \boldsymbol(r) \phi_{\nu} \boldsymbol(r) \big) \\ &= \frac{\partial F}{\partial \rho} \phi_{\mu} \boldsymbol(r) \phi_{\nu} \boldsymbol(r) + 2 \frac{\partial F}{\partial \gamma} \nabla \rho \boldsymbol(r) \cdot \color{red}{\nabla} \big( \phi_{\mu} \boldsymbol(r) \phi_{\nu} \boldsymbol(r) \big) \\ &= \frac{\partial F}{\partial \rho} \phi_{\mu} \boldsymbol(r) \phi_{\nu} \boldsymbol(r) + 2 \frac{\partial F}{\partial \gamma} \nabla \rho \boldsymbol(r) \cdot \big( \nabla \phi_{\mu} \boldsymbol(r) \phi_{\nu} \boldsymbol(r) + \phi_{\mu} \boldsymbol(r) \nabla \phi_{\nu} \boldsymbol(r) \big) \end{align}\end{split}\]

如果现在我们让等式左边为交换相关势 \(v_{\mu \nu}^\mathrm{xc} [\rho]\)\(\frac{\partial F}{\partial \rho}\) 为格点 \(f_\rho\)\(\frac{\partial F}{\partial \gamma}\) 为格点 \(f_\gamma\)\(\nabla \rho (\boldsymbol{r})\) 为格点 \(\rho_r\)\(\phi_\mu (\boldsymbol{r})\) 为格点 \(\phi_\mu\)\(\nabla \phi_\mu (\boldsymbol{r})\) 为格点 \(\phi_{r \mu}\),那么我们就立即得到了

\[v_{\mu \nu}^\mathrm{xc} [\rho] = f_\rho \phi_\mu \phi_\nu + \sum_{r} 2 f_\gamma \rho_r (\phi_{r \mu} \phi_{\nu} + \phi_{\mu} \phi_{r \nu})\]

这里补充一点。对于张量的梯度点积 \(\nabla \rho (\boldsymbol{r}) \cdot \nabla \phi_\mu (\boldsymbol{r})\),如果我们令电子坐标分量 \(r \in \{ x, y, z \}\),同时注意到粗斜体的电子坐标 \(\boldsymbol{r} = (x, y, z)\) (粗斜体的 \(\boldsymbol{r}\) 与斜体的 \(r\) 是两个无关的记号),那么根据点积的定义,

\[\nabla \rho (\boldsymbol{r}) \cdot \nabla \phi_\mu (\boldsymbol{r}) = \sum_{r} \frac{\partial \rho (\boldsymbol{r})}{\partial r} \frac{\partial \phi_\mu (\boldsymbol{r})}{\partial r}\]

上面的推导过程其实很有意思,因为看上去都非常合理,并且结果也是正确的。但是,上面的推导在两个关键步骤上错了。第一个问题在前两处红色项上标出:泛函的变分应当在积分的环境下定义。第二个问题在第三处红色项标出:尽管从结果上是正确的,但将 \(\delta \nabla \rho / \delta \rho\) 等同于梯度符号 \(\nabla\) 是完全错误的。下面介绍更合理的两种推导方案,推导过程中的一些细节可以参考 维基百科页面

为了简化记号,定义 \(\phi (\boldsymbol{r}) = \phi_\mu (\boldsymbol{r}) \phi_\nu (\boldsymbol{r})\)

根据泛函的定义推导

\[\begin{split}\begin{align} v_{\mu \nu}^\mathrm{xc} [\rho] &= \int \frac{\delta F[\rho, \nabla \rho \cdot \nabla \rho]}{\delta \rho} \phi \, \mathrm{d} \boldsymbol{r} \\ &= \int \lim_{\varepsilon \rightarrow 0} \frac{\mathrm{d}}{\mathrm{d} \varepsilon} \big[ F[\rho + \varepsilon \phi, \nabla (\rho + \varepsilon \phi) \cdot \nabla (\rho + \varepsilon \phi)] - F[\rho, \nabla \rho \cdot \nabla \rho] \big] \, \mathrm{d} \boldsymbol{r} \\ &= \int \frac{\partial F}{\partial \rho} \lim_{\varepsilon \rightarrow 0} \frac{\mathrm{d}}{\mathrm{d} \varepsilon} (\rho + \varepsilon \phi) \, \mathrm{d} \boldsymbol{r} + \int \frac{\partial F}{\partial \gamma} \lim_{\varepsilon \rightarrow 0} \frac{\mathrm{d}}{\mathrm{d} \varepsilon} (\nabla (\rho + \varepsilon \phi) \cdot \nabla (\rho + \varepsilon \phi)) \, \mathrm{d} \boldsymbol{r} \\ &= \int \frac{\partial F}{\partial \rho} \phi \, \mathrm{d} \boldsymbol{r} + \int \frac{\partial F}{\partial \gamma} 2 \nabla \rho \cdot \nabla \phi \, \mathrm{d} \boldsymbol{r} \\ &= \int \left[ \frac{\partial F}{\partial \rho} \phi_\mu \phi_\nu + 2 \frac{\partial F}{\partial \gamma} \nabla \rho \cdot (\nabla \phi_\mu \phi_\nu + \phi_\mu \nabla \phi_\nu) \right] \, \mathrm{d} \boldsymbol{r} \end{align}\end{split}\]

利用泛函变分等式

另一种做法是利用以下表达式 (可以参考维基百科,也可以参考 Parr [PW89] 式 (A.13)):

\[\begin{split}\begin{align} \frac{\delta F[\rho, \nabla \rho]}{\delta \rho (\boldsymbol{r})} &= \frac{\partial F}{\partial \rho} - \nabla \cdot \frac{\partial F}{\partial \nabla \rho} \\ &= \frac{\partial F}{\partial \rho} - \nabla \cdot (\frac{\partial F}{\partial \gamma} \frac{\partial \gamma}{\partial \nabla \rho}) \\ &= \frac{\partial F}{\partial \rho} - 2 \nabla \cdot (\frac{\partial F}{\partial \gamma} \nabla \rho) \end{align}\end{split}\]

之所以上式不用在积分环境中写出,应当是因为对于任何泛函变分的积分,上述表达式总是成立的。因此,

\[\begin{split}\begin{align} v_{\mu \nu}^\mathrm{xc} [\rho] &= \int \frac{\delta F[\rho, \nabla \rho]}{\delta \rho (\boldsymbol{r})} \phi \, \mathrm{d} \boldsymbol{r} \\ &= \int \frac{\partial F}{\partial \rho} \phi \, \mathrm{d} \boldsymbol{r} - \int 2 \phi \nabla \cdot (\frac{\partial F}{\partial \gamma} \nabla \rho) \, \mathrm{d} \boldsymbol{r} \\ &= \int \frac{\partial F}{\partial \rho} \phi \, \mathrm{d} \boldsymbol{r} - 2 \color{blue}{\int \nabla \cdot (\phi \frac{\partial F}{\partial \gamma} \nabla \rho) \, \mathrm{d} \boldsymbol{r}} + 2 \int \nabla \phi \cdot (\frac{\partial F}{\partial \gamma} \nabla \rho) \, \mathrm{d} \boldsymbol{r} \end{align}\end{split}\]

这很像是微积分中的分部积分 (乘法导数的逆运用);而根据 Gauss 定理 (参考 维基百科 Divergence theorem),

\[\int \nabla \cdot (\phi \frac{\partial F}{\partial \gamma} \nabla \rho) \, \mathrm{d} \boldsymbol{r} = \oint \phi \frac{\partial F}{\partial \gamma} \nabla \rho \cdot \boldsymbol{n} \, \mathrm{d} S\]

其中,上式中的 \(\boldsymbol{n}\) 是球面积分中,被积球面的单位法向量,且方向远离被积球体。上式的等式右是环路积分。我们知道等式左边的被积对象是全三维空间;如果我们将符号写得更为严谨一些,

\[\lim_{\Omega \rightarrow \mathbb{R}^3} \int_\Omega \nabla \cdot (\phi \frac{\partial F}{\partial \gamma} \nabla \rho) \, \mathrm{d} \boldsymbol{r} = \lim_{\Omega \rightarrow \mathbb{R}^3} \oint_{\Omega_S} \phi \frac{\partial F}{\partial \gamma} \nabla \rho \cdot \boldsymbol{n} \, \mathrm{d} S\]

其中 \(\Omega\) 是任意单连通的 (但应包含双氧水分子几乎所有电子云的) 区域。上述等式右边的项一般认为是零;这可以应当可以用波函数 (\(\phi = \phi_\mu \phi_nu\) 两个轨道波函数的乘积,密度量 \(\nabla \rho\) 也与波函数有关) 在无穷远处的渐进性质、以及交换相关泛函量 \(\partial_\gamma F\) 的渐进性质严格证明,但作者还没有能力进行证明。

回到 \(v_{\mu \nu}^\mathrm{xc} [\rho]\) 的推导,通过上面的讨论知道其中的蓝色项积分的值应当为零,因此:

\[\begin{split}\begin{align} v_{\mu \nu}^\mathrm{xc} [\rho] &= \int \frac{\partial F}{\partial \rho} \phi \, \mathrm{d} \boldsymbol{r} + 2 \int \nabla \phi \cdot (\frac{\partial F}{\partial \gamma} \nabla \rho) \, \mathrm{d} \boldsymbol{r} \\ &= \int \left[ \frac{\partial F}{\partial \rho} \phi_\mu \phi_\nu + 2 \frac{\partial F}{\partial \gamma} \nabla \rho \cdot (\nabla \phi_\mu \phi_\nu + \phi_\mu \nabla \phi_\nu) \right] \, \mathrm{d} \boldsymbol{r} \end{align}\end{split}\]
任务 (3.4)

\(f_\rho\), \(f_\gamma\)\(\rho_r\) 会随着密度的变化而变化。作者认为用 \(D_{\kappa \lambda}\) 替换掉方括号中的 \(\rho\) 是合理的行为。

任务 (4)

任务 (4.1) 可选

电子密度 R \(R_{\mu \nu}\) 定义如下:

[67]:
np.random.seed(1)
R = np.random.random((nao, nao))
R += R.T

我们希望得到的结果是 eng_elec_R \(E_\mathrm{xc} [R_{\mu \nu}]\)

[68]:
eng_elec_R = scf_eng.energy_elec(dm=R)[0]
eng_elec_R
[68]:
123.10771512394973

由于后面会有很多机会使用 GridHelperKernelHelper,这里就不使用它们;我们顺便借此巩固 PySCF 的 DFT 格点程序。

首先,我们生成 H \(h_{\mu \nu} = t_{\mu \nu} + v_{\mu \nu}^\mathrm{nuc}\), J_R \(J_{\mu \nu} [R_{\kappa \lambda}] = (\mu \nu | \kappa \lambda) R_{\kappa \lambda}\), K_R \(K_{\mu \nu} [R_{\kappa \lambda}] = (\mu \kappa | \nu \lambda) R_{\kappa \lambda}\),并定义 eri0 \((\mu \nu | \kappa \lambda)\)

[69]:
eri0 = mol.intor("int2e")
H = mol.intor("int1e_kin") + mol.intor("int1e_nuc")
J_R = np.einsum("uvkl, kl -> uv", eri0, R)
K_R = np.einsum("ukvl, kl -> uv", eri0, R)

随后我们要构建原子轨道格点。由于我们只是要计算交换相关能,因此只需要 GGA 泛函所必需的 \(\rho_r^R\) 的组成部分,即 ao_0 \(\phi_\mu\)ao_1 \(\phi_{r \mu}\);因此只需要至多 deriv=1 的原子轨道导数。

[70]:
ao = np.zeros((4, grids.weights.size, mol.nao))
ni = dft.numint.NumInt()
g_start = 0
for inner_ao, _, _, _ in ni.block_loop(mol, grids, mol.nao, deriv=1, max_memory=50):
    ao[:, g_start:g_start+inner_ao.shape[-2]] = inner_ao
    g_start += inner_ao.shape[-2]
ao_0 = ao[0]
ao_1 = ao[1:4]

那么密度格点 rho_0_R \(\rho^R = R_{\mu \nu} \phi_\mu \phi_\nu\)rho_1_R \(\rho_r^R = 2 R_{\mu \nu} \phi_{r \mu} \phi_\nu\) 就可以如下给出:

[71]:
rho_0_R = np.einsum("uv, gu, gv -> g", R, ao_0, ao_0)
rho_1_R = 2 * np.einsum("uv, rgu, gv -> rg", R, ao_1, ao_0)

随后我们计算格点 exc_R \(f^R\)。注意到这是零阶导数项,因此在 eval_xc 中设定 deriv=0 即可。代入 eval_xc 的密度是 \(4 \times n_\mathrm{ngrid} \times n_\mathrm{AO}\) 大小的 \(\rho^R\)\(\rho_r^R\) 的合并张量 rho_01_R。同时需要注意,根据这篇文档的定义,格点 \(f^R\) 应当已经乘以格点权重。

[72]:
rho_01_R = np.vstack([rho_0_R, rho_1_R])
exc_R = ni.eval_xc("B3LYPg", rho_01_R, deriv=0)[0]
exc_R *= grids.weights

最后,我们通过下式给出密度 \(R_{\mu \nu}\) 下的电子态能量 eng_elec_task_R

\[E_\mathrm{elec} [R_{\mu \nu}] = (h_{\mu \nu} + \frac{1}{2} J_{\mu \nu} [R_{\kappa \lambda}] - \frac{1}{4} c_\mathrm{x} K_{\mu \nu} [R_{\kappa \lambda}]) R_{\mu \nu} + f^R \rho^R\]
[73]:
cx = ni.hybrid_coeff("B3LYPg")  # B3LYP cx = 0.2
eng_elec_task_R = (
    + ((H + 0.5 * J_R - 0.25 * cx * K_R) * R).sum()
    + (exc_R * rho_0_R).sum()
)
eng_elec_task_R
[73]:
123.1077151246414

我们可以与 eng_elec_R 的结果作比较:

[74]:
np.allclose(eng_elec_task_R, eng_elec_R)
[74]:
True
任务 (4.2)

可以。杂化泛函确实是密度的泛函,而不需要是轨道的泛函。需要指出,轨道系数 \(C_{\mu p}\) 一般认为具有比 \(D_{\mu \nu}\) 更大的信息,因为 (对于闭壳层) \(D_{\mu \nu} = 2 C_{\mu i} C_{\nu i}\),因此密度不包含任何非占据轨道的信息。

之所以提出这个问题,是因为作者很长时间都困扰于杂化泛函中的交换积分 \(K_{\mu \nu} [R_{\kappa \lambda}]\) 是否可以被密度量表示。作者现在的理解是,既然交换积分可以被写作密度矩阵 \(R_{\kappa \lambda}\) 的泛函,那么它应当也是 \(\rho\) 的泛函;只是具体形式如何,作者也不清楚。

上面的措辞十分模糊;为了对这个问题有更清晰的表达,我们先考虑使用格点积分生成在密度 \(R_{\mu \nu}\) 下库伦积分 \(J^R\) (不考虑复共轭)。这一部分解答仍然使用 Einstein Summation Convention。

库伦积分值的格点积分导出

我们先借用上一小题的 R \(R_{\mu \nu}\) 和库伦积分矩阵 J_R \(J_{\mu \nu} [R_{\kappa \lambda}]\),得到正确的库伦积分值:

\[J^R = \frac{1}{2} R_{\mu \nu} J_{\mu \nu} [R_{\kappa \lambda}]\]
[75]:
0.5 * (J_R * R).sum()
[75]:
953.225897393552

现在我们从更为根本的方式出发:

\[\begin{split}\begin{align} J^R &= (ii|jj) = \frac{1}{2} \iint \phi_i (\boldsymbol{r}) \phi_i (\boldsymbol{r}) \frac{1}{\Vert \boldsymbol{r} - \boldsymbol{r}' \Vert_2} \phi_j (\boldsymbol{r}') \phi_j (\boldsymbol{r}') \, \mathrm{d} \boldsymbol{r} \, \mathrm{d} \boldsymbol{r}' \\ &= \frac{1}{2} \iint D_{\mu \nu} \phi_\mu (\boldsymbol{r}) \phi_\nu (\boldsymbol{r}) \frac{1}{\Vert \boldsymbol{r} - \boldsymbol{r}' \Vert_2} \phi_\kappa (\boldsymbol{r}') \phi_\lambda (\boldsymbol{r}') D_{\kappa \lambda} \, \mathrm{d} \boldsymbol{r} \, \mathrm{d} \boldsymbol{r}' \\ &= \frac{1}{2} \iint \rho^R (\boldsymbol{r}) \frac{1}{\Vert \boldsymbol{r} - \boldsymbol{r}' \Vert_2} \rho^R (\boldsymbol{r}') \, \mathrm{d} \boldsymbol{r} \, \mathrm{d} \boldsymbol{r}' \end{align}\end{split}\]

注意到这里的积分不再是对单个的电子坐标 \(\boldsymbol{r}\),而是要同时对 \(\boldsymbol{r}'\) 进行积分。这里破例使用格点下标 \(g\) 与权重记号 \(w_g\) 并与电子坐标 \(\boldsymbol{r}\) 关联;同时格点下标 \(G\) 与权重记号 \(w_G\) 与电子坐标 \(\boldsymbol{r}'\) 关联。我们同时定义 \(d_{gG} = \Vert \boldsymbol{r}_g - \boldsymbol{r}'_G \Vert_2\),那么库伦积分值用格点积分表示为

\[J^R = \frac{1}{2} w_g w_G \rho_g^R \rho_G^R \frac{1}{d_{gG}}\]

现在我们着手计算上述库伦积分。由于生成 \(d_{gG}\) 的过程非常消耗内存,我们暂且将格点 grids_corse 的精度降到 (30, 110)。需要注意这是非常低精度的格点,只能作定性分析。

[76]:
grids_corse = Mol_H2O2().gen_grids(30, 110)
ngrid_corse = grids_corse.weights.size
ngrid_corse
[76]:
9864

下面定义格点坐标与格点权重 weights_corse \(w_g\)

[77]:
coords_corse = grids_corse.coords
weights_corse = grids_corse.weights

通过格点坐标则可以定义格点距离 dist_corse \(d_{gG}\);为了避免相同格点距离为零而导致 \(1 / d_{gG}\) 没有意义,定义当 \(g = G\)\(d_{gG} = +\infty\)

[78]:
dist_corse = np.linalg.norm(coords_corse[:, None] - coords_corse, axis=-1)
dist_corse += np.diag(np.ones(ngrid_corse) * np.inf)

随后我们生成原子轨道格点 ao_0_corse \(\phi_{g \mu}\) 与密度格点 rho_0_corse \(\rho^R_g\)

[79]:
ao_0_corse = np.zeros((ngrid_corse, mol.nao))
g_start = 0
for inner_ao, _, _, _ in ni.block_loop(mol, grids_corse, mol.nao, deriv=0, max_memory=50):
    ao_0_corse[g_start:g_start+inner_ao.shape[-2]] = inner_ao
    g_start += inner_ao.shape[-2]
rho_0_corse = np.einsum("uv, gu, gv -> g", R, ao_0_corse, ao_0_corse)

最后我们可以求得库伦积分值 \(J^R = \frac{1}{2} w_g w_G \rho_g^R \rho_G^R / d_{gG}\)

[80]:
0.5 * np.einsum("g, G, g, G, gG ->", weights_corse, weights_corse, rho_0_corse, rho_0_corse, 1 / dist_corse)
[80]:
945.167184682469

由于这是粗的格点,因此我们无法将上述值与解析积分的值作精确的对比 (\(\sim 953.23\));但两者大致接近。

交换积分值的格点积分导出

使用格点积分进行交换积分值计算的策略是相同的。我们仍然先借用上一小题的 R \(R_{\mu \nu}\) 和交换积分矩阵 K_R \(K_{\mu \nu} [R_{\kappa \lambda}]\),得到正确的交换积分值:

\[K^R = \frac{1}{2} R_{\mu \nu} K_{\mu \nu} [R_{\kappa \lambda}]\]
[81]:
0.5 * (K_R * R).sum()
[81]:
999.3581121411252

但若我们着手考虑格点积分的计算方式:

\[\begin{split}\begin{align} K^R &= (ij|ij) = \frac{1}{2} \iint \phi_i (\boldsymbol{r}) \phi_j (\boldsymbol{r}) \frac{1}{\Vert \boldsymbol{r} - \boldsymbol{r}' \Vert_2} \phi_i (\boldsymbol{r}') \phi_j (\boldsymbol{r}') \, \mathrm{d} \boldsymbol{r} \, \mathrm{d} \boldsymbol{r}' \\ &= \frac{1}{2} \iint D_{\mu \nu} \phi_\mu (\boldsymbol{r}) \phi_\nu (\boldsymbol{r}') \frac{1}{\Vert \boldsymbol{r} - \boldsymbol{r}' \Vert_2} \phi_\kappa (\boldsymbol{r}) \phi_\lambda (\boldsymbol{r}') D_{\kappa \lambda} \, \mathrm{d} \boldsymbol{r} \, \mathrm{d} \boldsymbol{r}' \end{align}\end{split}\]

我们发现几乎很难再化简为 \(\rho^R (\boldsymbol{r})\) 的形式了。取而代之地,通常可以定义下述非定域密度量

\[\rho^{1, R} (\boldsymbol{r}, \boldsymbol{r}') = D_{\mu \nu} \phi_\mu (\boldsymbol{r}) \phi_\nu (\boldsymbol{r}')\]

那么交换积分值可以是

\[\begin{split}\begin{align} K^R &= \frac{1}{2} \iint \rho^{1, R} (\boldsymbol{r}, \boldsymbol{r}') \frac{1}{\Vert \boldsymbol{r} - \boldsymbol{r}' \Vert_2} \rho^{1, R} (\boldsymbol{r}, \boldsymbol{r}') \, \mathrm{d} \boldsymbol{r} \, \mathrm{d} \boldsymbol{r}' \\ &= \frac{1}{2} w_g w_G (\rho^{1, R}_{gG})^2 \frac{1}{d_{gG}} \end{align}\end{split}\]

我们定义非定域密度量 rho_0_nlc_corse \(\rho^{1, R}_{gG} = D_{\mu \nu} \phi_{g \mu} \phi_{G \nu}\)

[82]:
rho_0_nlc_corse = np.einsum("uv, gu, Gv -> gG", R, ao_0_corse, ao_0_corse)
rho_0_nlc_corse.shape
[82]:
(9864, 9864)

那么交换积分值 \(K^R\) 计算可得

[83]:
np.einsum("g, G, gG, gG, gG ->", weights_corse, weights_corse, rho_0_nlc_corse, rho_0_nlc_corse, 1 / dist_corse)
[83]:
array(1982.57724)

显然这个值与解析结果 (\(\sim 999.36\)) 有些差距,但定性上基本是正确的。

总结

在 DFT 教材中 (譬如 Parr [PW89] p.40 式 (2.5.26)),交换能确实没有写作 \(\rho(\boldsymbol{r})\) 而只能写作类似于 \(\rho^1(\boldsymbol{r}, \boldsymbol{r}')\) 的形式;但即使如此,生成交换积分也不需要引入多余的轨道信息,单纯的密度信息应当就足够了。从这个角度上,HF 或者杂化泛函可以看作是严格地在 Hohenberg-Kohn 框架下的近似,即电子态能量仅仅是密度的泛函。

但一方面,不少学者认为 HF 或者杂化 GGA 泛函不属于 Kohn-Sham 框架,为此提出 Generalized Kohn-Sham 框架以及多种符合 Kohn-Sham 轨道的优化 (Optimized Effective Potential) 框架。另一方面,对于双杂化泛函、RPA 型泛函或者一部分 meta-GGA 泛函而言,其能量泛函的表达式已经包括不仅仅密度、还包含轨道的信息;尽管通常也认为,轨道可以看作密度的泛函,但从形式上已经与最初的 Hohenberg-Kohn (也许) 期望的 DFT 近似相差甚远。

“密度的泛函”,可以是极为宽泛的概念,也可以是极为狭隘的概念。在回答题目最初的用词中,“轨道的泛函”指的是在程序中不需要明确写出 \(C_{\mu p}\) 的泛函。其实这句话一方面肯定 HF 和杂化 GGA 泛函是纯粹的密度泛函,另一方面否定了双杂化泛函是纯粹的密度泛函;即使从程序的角度上措辞是严谨的,但这才是真正不伦不类的说法。但不论怎样,作者认为,我们期待的是能更好地解释问题的、在可容忍的代价下给出更为精准结果的泛函;对于是否是“纯粹的密度泛函”这样的标签,尽管确实这会帮助我们更好地厘清问题与泛函发展方向,但作用也仅限于此。

限于作者的能力与知识面,这里不能再讨论更多了。

任务 (4.3)

不能。我们拿自洽场密度来讨论这个问题。显然,xc_v \(v_{\mu \nu}^\mathrm{xc} [\rho]\) 与密度矩阵 D \(D_{\mu \nu}\) 的乘积求和

[84]:
(xc_v * scfh.D).sum()
[84]:
-18.597820201880936

xc_e \(E_\mathrm{xc} [\rho]\) 是不相等的:

[85]:
xc_e
[85]:
-14.506876761363406

这里指出,一般来说

\[E[\rho] \color{red}{\stackrel{?}{=}} \int \frac{\delta E[\rho]}{\delta \rho(\boldsymbol{r})} \rho(\boldsymbol{r}) \, \mathrm{d} \boldsymbol{r}\]

\(\stackrel{?}{=}\) 会替换为 \(\neq\)

这是作者作为初学者很长一段时间没有搞清楚的问题。现在定义积分泛函

\[E[\rho] = \int F[\rho] \, \mathrm{d} \boldsymbol{r}\]

其实一个很简单的例子是若 \(E[\rho]\) 是库伦积分 \(J[\rho]\),即

\[F[\rho] = \frac{1}{2} \int \frac{\rho(\boldsymbol{r}) \rho(\boldsymbol{r}')}{\Vert \boldsymbol{r} - \boldsymbol{r}' \Vert_2} \, \mathrm{d} \boldsymbol{r}'\]

在这种情况下,通常可以写库伦积分的变分为 (Parr, p.248, (A.11))

\[\frac{\delta J[\rho]}{\delta \rho(\boldsymbol{r})} = \int \frac{\rho(\boldsymbol{r}')}{\Vert \boldsymbol{r} - \boldsymbol{r}' \Vert_2} \, \mathrm{d} \boldsymbol{r}'\]

因此,

\[\int \frac{\delta J[\rho]}{\delta \rho(\boldsymbol{r})} \rho(\boldsymbol{r}) \, \mathrm{d} \boldsymbol{r} = \int \rho(\boldsymbol{r}) \, \mathrm{d} \boldsymbol{r} \int \frac{\rho(\boldsymbol{r}')}{\Vert \boldsymbol{r} - \boldsymbol{r}' \Vert_2} \, \mathrm{d} \boldsymbol{r}' = \int 2 F[\rho] \, \mathrm{d} \boldsymbol{r} = 2 J[\rho]\]

这是一个非常直观的例子,因为泛函变分 \(\delta_\rho J[\rho]\) 与密度 \(\rho\) 乘积的积分得到的是正好 2 倍的 \(J[\rho]\)

作者认为,普遍地来说,若 \(\frac{\delta F[\rho]}{\delta \rho}\)\(\rho\) 无关,那么泛函变分与密度乘积的积分会还原泛函的值本身。譬如,令 \(F[\rho]\) 是核排斥势能 \(V^\mathrm{nuc} [\rho]\),那么应当有

\[V^\mathrm{nuc} [\rho] \stackrel{\triangle}{=} \int v^\mathrm{nuc} (\boldsymbol{r}) \rho(\boldsymbol{r}) \, \mathrm{d} \boldsymbol{r} = \int \frac{\delta V^\mathrm{nuc} [\rho]}{\delta \rho(\boldsymbol{r})} \rho(\boldsymbol{r}) \, \mathrm{d} \boldsymbol{r}\]

因为恰好下式成立:

\[\frac{\delta V^\mathrm{nuc} [\rho]}{\delta \rho(\boldsymbol{r})} = v^\mathrm{nuc} (\boldsymbol{r})\]

参考文献

[Bec93] (1,2)

Axel D. Becke. Density-functional thermochemistry. III. the role of exact exchange. J. Chem. Phys., 98(7):5648–5652, apr 1993. doi:10.1063/1.464913.

[PW89] (1,2)

Robert G. Parr and Yang Weitao. Density-Functional Theory of Atoms and Molecules (International Series of Monographs on Chemistry, No. 16). Oxford University Press, 1989. ISBN 9780195042795. URL: https://www.amazon.com/Density-Functional-Molecules-International-Monographs-Chemistry/dp/01950427942.

非自洽 GGA 密度泛函

这一节我们讨论非自洽 GGA 密度泛函;它也将是 XYG3 型泛函的铺垫。

非自洽密度泛函将泛函 A 的密度代入泛函 B 的能量表达式中,得到体系的能量。

非自洽泛函在密度泛函领域中,并不是主流。在 19 世纪 90 年代,曾经为了互补 HF 自洽场的相关效应与 GGA 的 SIE (Self-Interaction Error) 效应两者的不足,以 HF-DFT 的名义有所发展;但普遍来说,能量或分子结构的表现上,非自洽 GGA 泛函仍然没有显著地优于 GGA 泛函。不仅如此,非自洽泛函的梯度性质的公式形式与自洽泛函的形式有不少区别,增加了一些额外的计算量。

相比于 B2PLYP 泛函是基于自洽的 GGA 密度泛函构建而言,XYG3 泛函正是基于非自洽 GGA 密度泛函构建的。从程序的角度上,由于双杂化本身引入的计算量比较大,因此非自洽泛函本身的额外的计算量相比而言近乎于是可以忽略的了。这在以后的文档中会有实际的体会。

尽管从应用的角度来说,非自洽泛函本身的意义并不大;但如果我们相信密度泛函近似的误差可以分为密度误差与能量误差,那么非自洽泛函可以是密度与能量误差分析的有力工具。譬如说,若我们获得某些分子体系的 Full-CI 密度,并将它代入近似泛函中,就可以对泛函能量表达式的误差进行分析了。

[1]:
from pyscf import scf, gto, dft
import numpy as np
from functools import partial
import warnings

from pyxdh.Utilities.test_molecules import Mol_H2O2
from pyxdh.DerivOnce import GradNCDFT

warnings.filterwarnings("ignore")
np.einsum = partial(np.einsum, optimize=["greedy", 1024 ** 3 * 2 / 8])
np.set_printoptions(5, linewidth=150, suppress=True)
[2]:
mol = Mol_H2O2().mol
grids = Mol_H2O2().gen_grids()

量化软件的 HF-B3LYP 计算

PySCF 实现

我们拿 HF-B3LYP 作为非自洽泛函作为范例来考察。HF-B3LYP 的计算过程是,首先计算 HF 自洽场 scf_eng

[3]:
scf_eng = scf.RHF(mol)
scf_eng.conv_tol = 1e-11
scf_eng.conv_tol_grad = 1e-9
scf_eng.kernel()
[3]:
-150.58503378083847

随后,我们构建用于计算 B3LYP 能量的类 nc_eng;注意 不要执行实际的计算,即不要使用 kernel 或使用 run 成员函数。

[4]:
nc_eng = dft.RKS(mol)
nc_eng.grids = grids

我们将 HF 自洽场 scf_eng 的密度代入 B3LYP nc_eng 的能量中:

\[E_\mathrm{HF-B3LYP} = E_\mathrm{B3LYP} [D_{\mu \nu}^\mathrm{HF}]\]
[5]:
nc_eng.energy_tot(dm=scf_eng.make_rdm1())
[5]:
-150.27716895192074

上述能量便是 HF-B3LYP 的总能量了。比较遗憾的是,几乎没有其它程序提供结果验证 HF-B3LYP 是否正确。

pyxdh 实现

pyxdh 使用 GradNCDFTDipoleNCDFT 实现非自洽泛函能量的计算。我们不妨使用 GradNCDFT

[6]:
from pyxdh.DerivOnce import GradNCDFT

GradNCDFTGradSCF 的子类,因此它们的初始化、调用方式基本上是相同的;只是在初始化时,需要引入自洽泛函 scf_eng 和未经过 kernelrun 方法进行计算的非自洽泛函 nc_eng

[7]:
config = {
    "scf_eng": scf_eng,
    "nc_eng": nc_eng
}
nch = GradNCDFT(config)

与普通的自洽场一样,通过 eng 属性就可以获得 HF-B3LYP 能量了。

[8]:
nch.eng
[8]:
-150.27716895192074

实现参考

非自洽 Fock 矩阵 \(F_{\mu \nu}^\mathrm{n}\), \(F_{pq}^\mathrm{n}\)

记号说明

从当前部分开始,

  • 上下标 \(\mathrm{s}\) 代表自洽泛函;但通常省略不写;

  • 上下标 \(\mathrm{n}\) 代表非自洽泛函。

在以后,我们经常会需要使用到非自洽泛函所对应的 Fock 矩阵。这里指出,自洽泛函 (对于 HF-B3LYP 而言自洽泛函是 HF) 的 MO 基组 Fock 矩阵 \(F_{pq}\) 应当是对角化的矩阵,且对角元与自洽泛函 (HF) 轨道能满足 \(F_{pp} = \varepsilon_p\) 的关系。对于 GradNCDFT 的实例,调取 MO 基组的 Fock 矩阵的方式与 GradSCF 实例的调取方式相同,即 nch.F_0_mo。我们验证其是否是对称矩阵,且对角元是否就是 HF 轨道能:

[9]:
np.allclose(nch.F_0_mo, np.diag(nch.e))
[9]:
True

但非自洽 Fock 矩阵是通过 B3LYP 泛函生成的 Fock 矩阵,其在 MO 基组下的表示一般地不是对称矩阵。非自洽 Fock 矩阵的 PySCF 生成方式是对非自洽泛函类 nc_eng 代入自洽泛函 scf_eng 的密度矩阵 \(D_{\mu \nu}\) 得到;其中 F_0_ao_nc 表示原子轨道基组的 \(F_{\mu \nu}^\mathrm{n}\),而 F_0_mo_nc 表示分子轨道基组的 \(F_{pq}^\mathrm{n}\)

\[F_{pq}^\mathrm{n} = C_{\mu p} F_{\mu \nu}^\mathrm{n} C_{\nu p}\]
[10]:
F_0_ao_nc = nc_eng.get_fock(dm=scf_eng.make_rdm1())
C = scf_eng.mo_coeff
F_0_mo_nc = C.T @ F_0_ao_nc @ C

我们可以简单观察一下非自洽的 MO 基组 Fock 矩阵。首先我们通过 pyxdh 给出占据与非占轨道的分割 so, sv

[11]:
so, sv = nch.so, nch.sv

占据-占据部分的 \(F_{ij}^\mathrm{n}\) 表示为

[12]:
F_0_mo_nc[so, so]
[12]:
array([[-18.70465,   0.00009,  -0.04849,  -0.06047,  -0.00156,   0.02533,   0.01121,  -0.00991,   0.00898],
       [  0.00009, -18.66858,  -0.04947,   0.05645,   0.03169,   0.00482,   0.01978,  -0.00393,  -0.00309],
       [ -0.04849,  -0.04947,  -1.13497,  -0.0001 ,  -0.02707,  -0.02778,  -0.04806,   0.01615,  -0.01083],
       [ -0.06047,   0.05645,  -0.0001 ,  -0.82973,   0.03003,  -0.02548,   0.00621,   0.00707,  -0.02126],
       [ -0.00156,   0.03169,  -0.02707,   0.03003,  -0.47708,   0.01432,  -0.01259,  -0.00464,   0.00115],
       [  0.02533,   0.00482,  -0.02778,  -0.02548,   0.01432,  -0.45708,   0.02529,   0.01502,   0.0033 ],
       [  0.01121,   0.01978,  -0.04806,   0.00621,  -0.01259,   0.02529,  -0.41389,   0.00197,  -0.03418],
       [ -0.00991,  -0.00393,   0.01615,   0.00707,  -0.00464,   0.01502,   0.00197,  -0.26219,  -0.00388],
       [  0.00898,  -0.00309,  -0.01083,  -0.02126,   0.00115,   0.0033 ,  -0.03418,  -0.00388,  -0.26544]])

我们发现这是一个对称但并不是对角化的矩阵。有意思的是,其对角元与轨道能的值大体相近。我们列举出占据轨道能的值:

[13]:
scf_eng.mo_energy[so]
[13]:
array([-20.67103, -20.63571,  -1.58083,  -1.23933,  -0.76882,  -0.72551,  -0.58961,  -0.52943,  -0.5192 ])

对于非占-非占部分 \(F_{ab}^\mathrm{n}\) 来说也应当类似。而非占-占据部分 \(F_{ai}^\mathrm{n}\) 的值为

[14]:
F_0_mo_nc[sv, so]
[14]:
array([[ 0.03385, -0.01491, -0.00629,  0.01663,  0.00275, -0.00696, -0.00754,  0.00298, -0.00071],
       [ 0.01201,  0.0227 ,  0.00014, -0.00452, -0.00243, -0.00666,  0.00388, -0.00234, -0.00188],
       [ 0.00741,  0.01734, -0.00733,  0.00218, -0.0175 , -0.00175,  0.00629, -0.00331, -0.00352],
       [ 0.01849, -0.01636,  0.00826,  0.00928,  0.00255,  0.00243,  0.00097,  0.00174,  0.0006 ],
       [-0.00415,  0.02197,  0.00169,  0.01539, -0.00569, -0.00636,  0.00983, -0.00407,  0.00402],
       [-0.00323, -0.02753,  0.00781, -0.01028, -0.0118 , -0.00144, -0.00516, -0.00309, -0.0014 ],
       [-0.03135, -0.00558,  0.00415,  0.00697,  0.00632, -0.00457, -0.01114, -0.00306, -0.00469],
       [ 0.03239,  0.00018,  0.00073, -0.00973,  0.0027 , -0.00659,  0.01685,  0.00647,  0.00443],
       [-0.00862, -0.00485, -0.0134 ,  0.00327,  0.00597, -0.00452, -0.01746,  0.01562,  0.00135],
       [ 0.03555,  0.02291, -0.01811,  0.00674, -0.00828,  0.01476, -0.0427 , -0.00309, -0.01121],
       [-0.01729, -0.01734,  0.00822,  0.00745, -0.00033,  0.00609, -0.00038, -0.00131,  0.01795],
       [-0.01999,  0.09043,  0.00079, -0.00644,  0.00902, -0.00174,  0.01827, -0.00173,  0.00276],
       [ 0.0803 ,  0.01993,  0.0007 ,  0.00458,  0.00867,  0.00631,  0.03445, -0.0076 ,  0.01666]])

这部分的值都比较小,但并非是零。我们以后会知道 \(F_{ai}^\mathrm{n}\) 将会对非自洽泛函的梯度性质的计算有至关重要的贡献。

现在我们用 pyxdh 的实例 nch 生成非自洽 Fock 矩阵。nch 本身一般只会调用自洽泛函的结果与性质;但若要调用非自洽泛函,则先使用属性 nc_deriv,随后再调取相应的变量。我们验证使用 pyxdh 调用的 AO 基组非自洽 Fock 矩阵与 PySCF 生成的 F_0_ao_nc 结果相等:

[15]:
np.allclose(nch.nc_deriv.F_0_ao, F_0_ao_nc)
[15]:
True

B2PLYP 能量计算

在这一节中,我们将讨论 B2PLYP 泛函的能量计算。这将作为框架更大的 XYG3 型泛函文档的铺垫。

[1]:
import numpy as np
from pyscf import scf, gto, mp, dft

from pkg_resources import resource_filename
from pyxdh.Utilities import FormchkInterface
from pyxdh.Utilities.test_molecules import Mol_H2O2
from pyxdh.DerivOnce import GradMP2

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

import warnings
warnings.filterwarnings("ignore")

np.set_printoptions(5, linewidth=150, suppress=True)
[2]:
mol = Mol_H2O2().mol
grids = Mol_H2O2().gen_grids()

量化软件的 B2PLYP 能量计算

Gaussian 计算

输入卡formchk 结果

[3]:
ref_fchk = FormchkInterface(resource_filename("pyxdh", "Validation/gaussian/H2O2-B2PLYP-freq.fchk"))
ref_fchk.total_energy()
[3]:
-151.2039968828007

pyxdh 计算

在 pyxdh 中,B2PLYP 泛函的处理方式与 MP2 完全相同,即将 PySCF 的自洽场计算实例代入 GradMP2 中即可,但有两个区别。其一是代入的自洽场实例由 scf.RHF 改成 dft.RKS;其二是需要引入 PT2 型 (从程序角度上看可以与 MP2 等同,就如同 \(E_\mathrm{x, exact}\) 作为精确交换能可以等同于 HF 交换能一样) 相关能 \(E_\mathrm{c, PT2}\) 的系数 \(c_\mathrm{c}\)

我们回顾 B2PLYP [Gri06] 的泛函定义。根据原文的公式 (1),

\[E_\mathrm{xc, B2PLYP} = (1 - a_\mathrm{x}) E_\mathrm{x, B88} + a_x E_\mathrm{x, exact} + b E_\mathrm{c, LYP} + c E_\mathrm{c, PT2}\]

其中,

\[a_\mathrm{x} = 0.53, \quad c = 0.27, \quad b = 1 - c = 0.73\]

但上述泛函并非用于自洽场计算的泛函。其自洽场计算所使用的泛函 (Self-consistent, \(E_\mathrm{xc}\)) 是

\[E_\mathrm{xc, B2PLYP, SCF} = 0.53 E_\mathrm{x, exact} + 0.47 E_\mathrm{x, B88} + 0.73 E_\mathrm{c, LYP}\]

而在能量计算过程中,再补上其 PT2 贡献部分 \(0.27 E_\mathrm{c, PT2}\)

pyxdh 计算首先需要代入自洽场的 PySCF 类实例;这个类实例 scf_eng 定义为

[4]:
scf_eng = dft.RKS(mol)
scf_eng.grids = grids
scf_eng.xc = "0.53*HF + 0.47*B88, 0.73*LYP"
scf_eng.kernel()
[4]:
-151.11160929386716

显然,上述能量与 Gaussian 给出的能量不太一样,因为这是没有补上 PT2 贡献的能量。现在我们代入 GradMP2 中:

[5]:
config = {
    "scf_eng": scf_eng,
    "cc": 0.27
}
b2ph = GradMP2(config)

其中,配置字典 config 不仅包含了 PySCF 的自洽场计算实例,还包含了 \(c_\mathrm{c} = 0.27\)。与 MP2 计算一样地,通过 eng 属性可以给出 B2PLYP 体系总能量:

[6]:
b2ph.eng
[6]:
-151.20399686033448
[7]:
np.allclose(b2ph.eng, ref_fchk.total_energy())
[7]:
True

PySCF 计算

尽管说 PySCF 没有默认的双杂化泛函的计算方式,API 文档或说明文档也没有提及 PySCF 是否可以计算双杂化泛函;但若了解双杂化泛函的形式,我们应当发现 PySCF 计算双杂化泛函的方式与 MP2 几乎完全相同;有所区别之处也仅仅在于 PT2 部分的系数,以及用的是 DFT 自洽场而不是 HF 自洽场。因此,PySCF 可以计算双杂化泛函的能量 而不需要借助其他工具。不过需要指出,PySCF 目前不支持对双杂化泛函的梯度性质计算。

自洽场我们已经通过 scf_eng 给出了;剩下的是计算 PT2 部分。仿照 MP2 的计算代码,

[8]:
mp2_eng = mp.MP2(scf_eng)
mp2_eng.run()
[8]:
<pyscf.mp.mp2.MP2 at 0x7fad1b55feb8>

不过给出最后的结果 不能 使用下述代码:

[9]:
mp2_eng.e_tot
[9]:
-151.45378546596837

上述代码给出的是 \(E_\mathrm{xc, B2PLYP, SCF} + E_\mathrm{c, PT2}\),而不是 \(E_\mathrm{xc, B2PLYP, SCF} + 0.27 E_\mathrm{c, PT2}\)。因此,B2PLYP 总能量应当通过下述代码给出:

[10]:
scf_eng.e_tot + 0.27 * mp2_eng.e_corr
[10]:
-151.20399686033448

可以验证上述结果与 Gaussian 的结果相等:

[11]:
np.allclose(scf_eng.e_tot + 0.27 * mp2_eng.e_corr, ref_fchk.total_energy())
[11]:
True

参考文献

[Gri06]

Stefan Grimme. Semiempirical hybrid density functional with perturbative second-order correlation. J. Chem. Phys., 124(3):034108, jan 2006. doi:10.1063/1.2148954.

XYG3 型密度泛函

这一节我们讨论 XYG3 型密度泛函 (XYG3 type of Double Hybrid density functional, xDH)。后续文档的目标就是推导 XYG3 型泛函的一阶梯度与二阶梯度。

[1]:
from pyscf import scf, gto, dft, mp
import numpy as np
from functools import partial
import warnings

from pkg_resources import resource_filename
from pyxdh.Utilities import FormchkInterface
from pyxdh.Utilities.test_molecules import Mol_H2O2
from pyxdh.DerivOnce import GradXDH

warnings.filterwarnings("ignore")
np.einsum = partial(np.einsum, optimize=["greedy", 1024 ** 3 * 2 / 8])
np.set_printoptions(5, linewidth=150, suppress=True)
[2]:
mol = Mol_H2O2().mol
grids = Mol_H2O2().gen_grids()

量化软件的 XYG3 计算

Gaussian 计算

在一个内部版本的 Gaussian 中,我们可以获得 H2O2 分子的 XYG3 下,6-31G 基组、(99, 590) 格点的非冻核近似的计算结果;这个结果的输入卡与 formchk 文件也已经在 pyxdh 的库中。调取方式如下:

[3]:
ref_fchk = FormchkInterface(resource_filename("pyxdh", "Validation/gaussian/H2O2-XYG3-force.fchk"))
ref_fchk.total_energy()
[3]:
-151.1962822786802

PySCF 计算

为了实现 XYG3 的计算,我们需要对 B2PLYP 与非自洽泛函的计算过程有大致印象;但我们将为了方便后文,重新定义记号。

记号说明

为了记号简便,在一篇文档讨论确定的泛函时 (这篇文档讨论 XYG3 泛函),会将泛函名称忽略不写。

  • XYG3 包含 PT2 与普通非自洽泛函的贡献部分,分别记作 \(c_\mathrm{c} E_\mathrm{c, PT2}\)\(E_\mathrm{xc, n}\)。其中,\(c_\mathrm{c}\) 是 PT2 贡献的系数。

  • 普通非自洽泛函 \(E_\mathrm{xc, n}\) 可以分为 HF 型交换能 \(c_\mathrm{x}^\mathrm{n} E_\mathrm{x, exact}\) 与纯 GGA 交换相关能 \(E_\mathrm{GGA, n}\)

  • 相应地,自洽泛函 \(E_\mathrm{xc}\) 可以分为 HF 型交换能 \(c_\mathrm{x} E_\mathrm{x, exact}\) 与纯 GGA 交换相关能 \(E_\mathrm{GGA}\)

对于 XYG3 而言,自洽泛函 scf_eng 是 B3LYP:

[4]:
scf_eng = dft.RKS(mol)
scf_eng.conv_tol = 1e-11
scf_eng.conv_tol_grad = 1e-9
scf_eng.xc = "B3LYPg"
scf_eng.grids = grids
scf_eng.kernel()
[4]:
-151.37754356054216

其非自洽泛函的形式根据文献 [ZXG09] 式 (12),为

\[E_\mathrm{xc, xDH} = E_\mathrm{xc, LDA} + c_1 (E_\mathrm{x, exact} - E_\mathrm{x, LDA}) + c_2 \Delta E_\mathrm{x, GGA} + c_3 (E_\mathrm{c, PT2} - E_\mathrm{c, LDA}) + c_4 \Delta E_\mathrm{c, GGA}\]

上式是一个普遍的双杂化泛函的框架。对于 XYG3 泛函本身而言,

\[c_1 = c_\mathrm{x}^\mathrm{n} = 0.8033, \quad c_2 = 0.2107, \quad c_3 = c_\mathrm{c} = 0.3211\]

因此有

\[\begin{split}\begin{align} E_\mathrm{xc, XYG3} &= 0.3211 E_\mathrm{c, PT2} + E_\mathrm{xc, n} \\ E_\mathrm{xc, n} &= 0.8033 E_\mathrm{x, exact} + E_\mathrm{GGA, n} \\ E_\mathrm{GGA, n} &= 0.2107 E_\mathrm{x, B88} - 0.0140 E_\mathrm{x, LDA} + 0.6789 E_\mathrm{c, LYP} \end{align}\end{split}\]

通过上述关系,我们可以定义与 \(E_\mathrm{xc, n}\) 有关的 nc_eng

[5]:
nc_eng = dft.RKS(mol)
nc_eng.xc = "0.8033*HF - 0.0140*LDA + 0.2107*B88, 0.6789*LYP"
nc_eng.grids = grids

以及与 \(E_\mathrm{c, PT2}\) 有关的 mp2_eng 和 PT2 相关系数 c_c \(\mathrm{c_c}\)

[6]:
c_c = 0.3211
mp2_eng = mp.MP2(scf_eng)
mp2_eng.run()
[6]:
<pyscf.mp.mp2.MP2 at 0x7f7f3f05d828>

我们将非自洽普通交换相关能量与 PT2 部分能量求和,得到 XYG3 能量:

[7]:
e_XYG3 = nc_eng.energy_tot(dm=scf_eng.make_rdm1()) + c_c * mp2_eng.e_corr
e_XYG3
[7]:
-151.1962818850459

我们可以与 Gaussian 计算结果进行比较:

[8]:
np.allclose(e_XYG3, ref_fchk.total_energy())
[8]:
True

pyxdh 计算

pyxdh 计算 XYG3 的单点能与 HF-B3LYP、B2PLYP 仍然是很类似的。但我们需要注意到,我们同时需要像 HF-B3LYP 一样给出自洽泛函部分与非自洽泛函部分;同时需要指定 PT2 相关系数 \(c_\mathrm{c} = 0.3211\)

[9]:
config = {
    "scf_eng": scf_eng,
    "nc_eng": nc_eng,
    "cc": 0.3211
}
xyg3h = GradXDH(config)

随后就可以立即获得 XYG3 的总能量了:

[10]:
xyg3h.eng
[10]:
-151.1962818850459

显然上述的 XYG3 确实是基本正确的:

[11]:
np.allclose(xyg3h.eng, ref_fchk.total_energy())
[11]:
True

参考文献

[ZXG09]

Y. Zhang, X. Xu, and W. A. Goddard. Doubly hybrid density functional for accurate descriptions of nonbond interactions, thermochemistry, and thermochemical kinetics. Proc. Natl. Acad. Sci. U.S.A., 106(13):4963–4968, mar 2009. doi:10.1073/pnas.0901093106.

单元课题:XYG3 能量计算

在结束这一单元前,我们通过完成一个比较完整的项目,回顾公式记号与最为基础的程序实现。这个比较完整的项目就是计算 XYG3 能量。

这个课题要求,除了 电子积分、轨道与泛函格点 外,尽可能只使用 不超过 numpy 的工具。

我们以后可能会使用一些程序上的技巧、以及 pyxdh 中所提供的一些便利工具来缩短文档和代码的篇幅;但作者认为,若要成为程序开发者,需要对一些必要的底层方法进行了解。这是作者编写这份课题的初衷。

作者认为,这份课题的所有代码未必需要亲手写一遍;这篇文档的代码前都会有导引,若看到导引就能知道代码大致是怎么写的 (调用哪个函数、或者能查阅到以前阅读过的哪篇文档的哪一小节、或者能正确地查阅到 PySCF 的 API 文档),并且能不通过程序验证、正确地说出每个变量的维度,我认为就达成了作者期望读者阅读文档的目的了。

程序流程导引

在设计程序之前,我们要知道 XYG3 的能量是如何给出的。

  • 首先,我们需要跑一次 B3LYP 得到其密度矩阵 \(D_{\mu \nu}\) 与轨道系数 \(C_{\mu p}\)

  • 其次,我们将密度矩阵代入 XYG3 的 GGA 分项进行计算,得到其能量的 GGA 部分;

  • 最后,将 B3LYP 得到的轨道系数代入 PT2 计算,得到 XYG3 能量的 PT2 部分。

程序分为以下几个模块:

  1. 初始化

    • 引入库 (PySCF)

    • 定义分子 (PySCF)

    • 定义格点 (PySCF)

  2. 无需自洽场密度或轨道系数就能计算的变量 (自洽场无关)

    • 原子轨道积分 (PySCF)

    • 轨道格点与格点权重 (PySCF)

    • 原子核排斥能

  3. 需要代入自洽场密度或轨道系数的变量 (自洽场相关)

    • 库伦、交换积分

    • 密度格点与泛函格点 (PySCF)

    • 交换相关势与 Fock 矩阵

    • GGA 能量

    • SCF 循环

    • XYG3 的 GGA 分项能量

    • XYG3 的 PT2 分项能量

    • XYG3 总能量

我们假定内存空间总是足够的。上述标记 PySCF 的部分是指我们允许在这些代码中使用 PySCF 程序,其它部分一概不允许 (包括不允许使用 pyxdh)。依据这些提示,读者应当能大致构思出程序框架,并能在 3 天时间以内从头写一个 XYG3 能量计算程序。

我们下面给出参考程序。

初始化部分

引入库

在引入 Python 库时,我们要考虑到以下方面:

  • 我们需要使用到 PySCF 中的分子定义与电子积分引擎 gto、DFT 计算与泛函格点引擎 dft

  • np.einsum 的优化选项 optimize 需要常开

  • numpy 的输出稍简洁一些,这里使用 5 位小数输出

  • 为了减少输出,因此不输出 Python 的 warning 信息

[1]:
import numpy as np
import scipy
import warnings
from pyscf import gto, dft
from functools import partial

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

定义分子

如以前一样,我们定义如下的双氧水分子,基组为 6-31G:

[2]:
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()
[2]:
<pyscf.gto.mole.Mole at 0x7ff62c093978>

我们以后可能会非常经常地使用占据、非占轨道数量与分割 (slice),以及原子数量:

  • natm 原子数

  • nao 原子轨道数,nmo 分子轨道数,一般来说在量化程序中,两者相等

  • nocc 占据轨道数,nvir 非占轨道数

  • so, sv, sa 分别代表占据、非占、全轨道的分割

[3]:
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)

定义格点

我们定义下述 (99, 590) 格点:

[4]:
grids = dft.Grids(mol)
grids.atom_grid = (99, 590)
grids.becke_scheme = dft.gen_grid.stratmann
grids.build()
[4]:
<pyscf.dft.gen_grid.Grids at 0x7ff62f314780>

我们也会经常使用格点数量 ngrids

[5]:
ngrid = grids.weights.size

自洽场无关部分

原子轨道积分

我们定义下述经常使用的原子轨道积分:

  • T 动能积分 \(t_{\mu \nu} = \langle \mu | - \frac{1}{2} \nabla^2 | \nu \rangle\)

  • Vnuc 外势能积分 \(v^\mathrm{nuc}_{\mu \nu} = \langle \mu | - \frac{Z_M}{| \boldsymbol{r} |} | \nu \rangle_{\boldsymbol{r} \rightarrow \boldsymbol{M}}\)

  • H_0_ao Hamiltonian Core 积分 \(h_{\mu \nu} = t_{\mu \nu} + v^\mathrm{nuc}_{\mu \nu}\)

  • S_0_ao 重叠积分 \(S_{\mu \nu} = \langle \mu | \nu \rangle\)

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

[6]:
T = mol.intor("int1e_kin")
Vnuc = mol.intor("int1e_nuc")
H_0_ao = T + Vnuc
S_0_ao = mol.intor("int1e_ovlp")
eri0_ao = mol.intor("int2e")
X = np.linalg.inv(np.linalg.cholesky(S_0_ao).T)

轨道格点与权重

格点积分过程中会经常使用 PySCF 的 NumInt,在此我们用 ni 来表示 NumInt 的一个实例:

[7]:
ni = dft.numint.NumInt()

我们定义权重格点 weight \(w\);它仅用于与泛函格点乘积,在公式中不会出现:

[8]:
weight = grids.weights

在计算 DFT 能量的过程中,我们至多使用轨道对电子坐标的一阶梯度。我们将会生成下述轨道格点:

  • ao_0 \(\phi_{\mu}\)

  • ao_1 \(\phi_{r \mu}\)

[9]:
ao = np.zeros((4, ngrid, nao))  # 4 refers to (noderiv, x_deriv, y_deriv, z_deriv)
g_start = 0
for inner_ao, _, _, _ in ni.block_loop(mol, grids, nao, deriv=1, max_memory=2000):
    ao[:, g_start:g_start+inner_ao.shape[-2]] = inner_ao
    g_start += inner_ao.shape[-2]
ao_0 = ao[0]
ao_1 = ao[1:4]

原子核排斥能

回顾到原子核排斥能 \(E_\mathrm{nuc}\) 的表达式为

\[E_\mathrm{nuc} = \frac{1}{2} \frac{Z_A Z_B}{r_{AB}}\]

其中,

\[\begin{split}\begin{split}\begin{equation} r_{AB} = \begin{cases} \Vert \boldsymbol{A} - \boldsymbol{B} \Vert_2 & A \neq B \\ + \infty & A = B \end{cases} \end{equation}\end{split}\end{split}\]

我们定义如下变量

  • Z_A \(Z_A\) 原子核电荷数

  • A_t \(A_t\) 原子坐标分量

  • r_AB \(r_{AB}\) 原子间距离矩阵 (对角元设定为正无穷)

  • E_nuc \(E_\mathrm{nuc}\) 原子核排斥能

[10]:
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()
E_nuc
[10]:
37.884674408641274

自洽场相关部分

库伦、交换积分

回顾到对于任意对称的、原子轨道下的密度矩阵 R \(R_{\mu \nu}\),库伦积分与交换积分可以表示为

\[\begin{split}\begin{align} J_{\mu \nu} [R_{\kappa \lambda}] &= (\mu \nu | \kappa \lambda) R_{\kappa \lambda} \\ K_{\mu \nu} [R_{\kappa \lambda}] &= (\mu \kappa | \nu \lambda) R_{\kappa \lambda} \end{align}\end{split}\]

尽管以前一般来说,在讨论库伦与交换积分、以及它们对 Fock 矩阵的贡献时,会使用自洽场给出的密度矩阵;但我们希望手写一个自洽场迭代过程,因此需要将库伦、交换积分写成如下定义的代入密度、输出矩阵的函数 gen_Jgen_K 的形式:

[11]:
def gen_J(R):
    return np.einsum("uvkl, kl -> uv", eri0_ao, R)

def gen_K(R):
    return np.einsum("ukvl, kl -> uv", eri0_ao, R)

尽管上述计算中,我们还用到了 ERI 积分 eri0_ao \((\mu \nu | \kappa \lambda)\),但由于 ERI 积分是由分子决定而不依赖于密度或泛函形式,因此在文档中我们可以不将 ERI 积分作为传入参数;类似地还有原子轨道格点。

密度与泛函格点

密度 \(\rho\) 与密度梯度格点 \(\rho_r\) 格点需要通过密度矩阵与轨道格点获得:

\[\begin{split}\begin{align} \rho &= \phi_\mu \phi_\nu D_{\mu \nu} \\ \rho_r &= 2 \phi_{r \mu} \phi_\nu D_{\mu \nu} \end{align}\end{split}\]

我们仍然用函数定义上述过程,函数名为 gen_rho_grid,输入密度矩阵,输出密度 rho_0 \(\rho\)、密度梯度 rho_1 \(\rho_r\)rho_01rho_01 是密度与密度梯度的合并张量,仅仅用于生成泛函格点。

[12]:
def gen_rho_grid(D):
    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_01 = np.concatenate(([rho_0], rho_1), axis=0)
    return rho_0, rho_1, rho_01

泛函的格点需要通过 PySCF 的 numint.eval_xc 函数给出,它需要我们输入泛函名称 (这个例子是 "B3LYPg")、轨道格点 rho_01,依次输出杂化系数 cx \(c_\mathrm{x}\)、泛函核 exc \(f\)、两个泛函核一阶导数 fr \(f_\rho\)fg \(f_\gamma\)。需要注意,

  • 我们不追求代码优化,因此不论是否需要泛函核一阶导数,我们都计算之。因此,numint.eval_xc 的第三个参数 deriv 始终设为 1

  • 这里再强调,出于公式简化的目的,输出的 \(f\), \(f_\rho\), \(f_\gamma\) 是已经被乘以了格点加权过的值;因此,在设计生成泛函格点权重的函数时,还需要将看似无关的格点权重引入。

[13]:
def gen_kernel_grid(xc_code, rho_01):
    cx = ni.hybrid_coeff(xc_code)
    exc, (fr, fg, _, _), _, _ = ni.eval_xc(xc_code, rho_01, deriv=1)
    exc *= weight
    fr *= weight
    fg *= weight
    return cx, exc, fr, fg

交换相关势与 Fock 矩阵

回顾交换相关势 \(v_{\mu \nu}^\mathrm{xc} [\rho]\) 的计算:

\[v_{\mu \nu}^\mathrm{xc} [\rho] = f_\rho \phi_\mu \phi_\nu + 2 f_\gamma \rho_r (\phi_{r \mu} \phi_{\nu} + \phi_{\mu} \phi_{r \nu})\]

为了计算交换相关势,我们需要利用到所有生成密度与泛函格点的工具;因此,在函数 gen_vxc 的函数中,我们需要代入泛函名称与密度矩阵:

[14]:
def gen_vxc(xc_code, D):
    rho_0, rho_1, rho_01 = gen_rho_grid(D)
    _, _, fr, fg = gen_kernel_grid(xc_code, rho_01)
    vxc = (
        + np.einsum("g, gu, gv -> uv", fr, ao_0, ao_0)
        + 2 * np.einsum("g, rg, rgu, gv -> uv", fg, rho_1, ao_1, ao_0)
        + 2 * np.einsum("g, rg, gu, rgv -> uv", fg, rho_1, ao_0 ,ao_1)
    )
    return vxc

我们又知道,Fock 矩阵可以通过如下方式构造 (若 \(\rho\) 对应的密度矩阵是 \(D_{\mu \nu}\)):

\[F_{\mu \nu} [D_{\kappa \lambda}] = h_{\mu \nu} + J_{\mu \nu} [D_{\kappa \lambda}] - \frac{c_\mathrm{x}}{2} K_{\mu \nu} [D_{\kappa \lambda}] + v_{\mu \nu}^\mathrm{xc} [\rho]\]

因此,Fock 矩阵的生成函数 gen_F_0_ao 可以表示为

[15]:
def gen_F_0_ao(xc_code, D):
    cx = ni.hybrid_coeff(xc_code)
    F_0_ao = H_0_ao + gen_J(D) - 0.5 * cx * gen_K(D) + gen_vxc(xc_code, D)
    return F_0_ao

GGA 能量

回顾 GGA 总能量表达式为 (利用到交换相关能 \(E_\mathrm{xc, GGA} = f \rho\))

\[E_\mathrm{GGA} [D_{\kappa \lambda}] = h_{\mu \nu} D_{\mu \nu} + \frac{1}{2} J_{\mu \nu} [D_{\kappa \lambda}] D_{\mu \nu} - \frac{c_\mathrm{x}}{4} K_{\mu \nu} [D_{\kappa \lambda}] D_{\mu \nu} + f \rho\]

我们定义交换相关能 \(E_\mathrm{GGA}\) 的计算过程为 gen_energy_elec

[16]:
def gen_energy_elec(xc_code, D):
    cx = ni.hybrid_coeff(xc_code)
    rho_0, _, rho_01 = gen_rho_grid(D)
    cx, exc, _, _ = gen_kernel_grid(xc_code, rho_01)
    energy_elec = ((H_0_ao + 0.5 * gen_J(D) - 0.25 * cx * gen_K(D)) * D).sum()
    energy_elec += (exc * rho_0).sum()
    return energy_elec

SCF 循环

通过前面的准备,我们已经可以实现 B3LYP 的自洽场循环了。

我们在以前的笔记中定义过 \(X_{\mu \nu}\);其满足 \(X_{\mu \kappa} S_{\mu \nu} X_{\nu \lambda} = \delta_{\kappa \lambda}\)\(\mathbf{X}^\dagger \mathbf{S} \mathbf{X} = \mathbf{1}\)。但若使用 scipy.linalg.eigh (API 文档),我们可以直接求解 \(\mathbf{F} \mathbf{C} = \mathbf{S} \mathbf{C} \mathbf{\varepsilon}\),避免手动定义 \(X_{\mu \nu}\) 并简化代码。

下述的自洽场过程的密度初猜是零。如果我们按照 Szabo 书中的 SCF 循环过程,应当会遇到密度矩阵剧烈振荡从而无法收敛的情况。为此,我们引入下述代码

D = 0.3 * D + 0.7 * D_old

它意味着每次迭代只更新 30% 的密度,剩余的 70% 密度仍然保留上一次迭代的结果;或者说我们让密度的更新过程更为保守。依靠这个小技巧,我们可以成功地在 100 步循环以内收敛密度,并且不会让密度振荡。

通过 SCF 过程,我们可以获得 B3LYP 下的轨道系数 C \(C_{\mu p}\)、轨道能 e \(\varepsilon_p\)、密度矩阵 D \(D_{\mu \nu}\)。尽管我们也计算出了 B3LYP 收敛后的 Fock 矩阵与能量,但这些对 XYG3 能量的计算没有贡献。

[17]:
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_0_ao = gen_F_0_ao("B3LYPg", D)
    e, C = scipy.linalg.eigh(F_0_ao, S_0_ao)  # 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("B3LYPg", D)
E_tot = E_elec + E_nuc

print("SCF Converged in          ", count, " loops")
print("Electronic energy (B3LYP) ", E_elec, " a.u.")
print("Total energy      (B3LYP) ", E_tot, " a.u.")
SCF Converged in           54  loops
Electronic energy (B3LYP)  -189.2622179191188  a.u.
Total energy      (B3LYP)  -151.37754351047752  a.u.

XYG3 的 GGA 分项能量

由此,我们可以通过代入 B3LYP 密度,计算 XYG3 泛函的 GGA 部分能量 E_xyg3_gga \(E_\mathrm{XYG3, GGA}\)

[18]:
E_xyg3_gga = gen_energy_elec("0.8033*HF - 0.0140*LDA + 0.2107*B88, 0.6789*LYP", D)
E_xyg3_gga
[18]:
-188.94500780243624

XYG3 的 PT2 分项能量

PT2 的能量计算与 MP2 能量计算完全一致,除了需要乘以相关系数 \(c_\mathrm{c}\);对于 XYG3 而言,cc \(c_\mathrm{c} = 0.3211\)

\[E_\mathrm{XYG3, PT2} = T_{ij}^{ab} t_{ij}^{ab} D_{ij}^{ab}\]

其中,

\[\begin{split}\begin{align} (pq|rs) &= (\mu \nu | \kappa \lambda) C_{\mu p} C_{\nu q} C_{\kappa r} C_{\lambda s} \\ D_{ij}^{ab} &= \varepsilon_i + \varepsilon_j - \varepsilon_a - \varepsilon_b \\ t_{ij}^{ab} &= (ia|jb) / D_{ij}^{ab} \\ T_{ij}^{ab} &= c_\mathrm{c} (2 t_{ij}^{ab} - t_{ij}^{ba}) \end{align}\end{split}\]

我们分别用 eri0_mo, D_iajb, t_iajb, T_iajb 表示 \((pq|rs)\), \(D_{ij}^{ab}\), \(t_{ij}^{ab}\), \(T_{ij}^{ab}\)。需要注意,以后的记号中,\(T_{ij}^{ab}\) 会表示经过 \(c_\mathrm{c}\) 相乘后的张量;这会在后面的文档中再强调。这么做的目的是让 PT2 的梯度推导表达式与 MP2 的表达式基本一致。

所有张量在程序中的角标顺序是 \(i, a, j, b\);这可能与公式中让人直接联想到的顺序不太相同。

[19]:
cc = 0.3211
eri0_mo = np.einsum("uvkl, up, vq, kr, ls", eri0_ao, C, C, C, C)
D_iajb = e[so, None, None, None] - e[None, sv, None, None] + e[None, None, so, None] - e[None, None, None, sv]
t_iajb = eri0_mo[so, sv, so, sv] / D_iajb
T_iajb = cc * (2 * t_iajb - t_iajb.swapaxes(-1, -3))

由此,XYG3 的 PT2 分项能量 E_xyg3_pt2 \(E_\mathrm{XYG3, PT2}\) 则为

[20]:
E_xyg3_pt2 = (T_iajb * t_iajb * D_iajb).sum()
E_xyg3_pt2
[20]:
-0.13594842432740734

XYG3 总能量

至此,我们已经求得所有 XYG3 的能量分项。现在我们只要将下述三个分项:GGA、PT2、原子核排斥能的结果相加就可以了:

\[E_\mathrm{XYG3} = E_\mathrm{XYG3, GGA} + E_\mathrm{XYG3, PT2} + E_\mathrm{nuc}\]
[21]:
E_xyg3 = E_xyg3_gga + E_xyg3_pt2 + E_nuc
E_xyg3
[21]:
-151.19628181812237

关于这个能量是否正确,可以看看上一篇文档的内容。

数值梯度必要背景:序

这一部分中,我们会讨论数值梯度 (导数) 的问题,包括数值导数的计算方式、以及导数与化学性质之间的关联。整个 pyxdh 程序的目前所解决的问题,是对 XYG3 型泛函的能量作各种导数计算;因此这部分内容将是对后续的 pyxdh 程序作铺垫。

一元函数数值导数简要介绍

在这一节中,我们将会简单地介绍在我们工作中可能会使用到的数值导数方法。

在这里我们不引入复杂的化学问题,几乎单纯地从非常基础的数学角度来阐释问题。但作为仅仅关心 XYG3 二阶梯度的文档,这一节将会是后面几节的基础与铺垫。在这份课题中,数值导数的意义是可以用来验证解析导数的正确性。

[1]:
%matplotlib notebook

import numpy as np
from functools import partial
from pyscf import gto, scf
import matplotlib.pyplot as plt

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

我们在这一节中,只讨论一元函数的导数问题。关于更高维度的向量数值导数、我们会结合具体的分子来进行说明。

用于讨论的 Morse 函数

我们现在拿一个化学中经常使用的 Morse 势能函数来举例子。其中的参数仅仅是用于演示用途,而没有实际的物理意义。

\[f(x) = \left( 1 - e^{1 - x} \right)^2 - 1\]
[2]:
def f(x):
    return (1 - np.exp(1 - x))**2 - 1

该函数的精确 (解析) 一阶导数可以表示为 f_p(x) \(f'(x)\)

\[f'(x) = \frac{\partial f(x)}{\partial x} = 2 e^{1 - x} \left( 1 - e^{1 - x} \right)\]
[3]:
def f_p(x):
    return 2 * np.exp(1 - x) * (1 - np.exp(1 - x))

该函数 \(f(x)\)\(x = 1\) 处取到极小值 (即 \(f'(1) = 0\))。函数显示为蓝色曲线,一阶导数显示为橙色曲线:

[4]:
xlist = np.arange(0, 5, 0.01)
ylist, yplist = f(xlist), f_p(xlist)

fig, ax = plt.subplots(figsize=(4.5, 3))
ax.plot([0, 5], [0, 0], color="black")
ax.plot(xlist, ylist, label="$f(x)$")
ax.plot(xlist, yplist, label="$f'(x)$")
ax.set_xlim(0, 5), ax.set_ylim(-1, 1)
ax.set_title(r"Plot of $f(x) = x^2$")
ax.set_xlabel(r"$x$")
ax.legend(), ax.grid()
fig.tight_layout()

三点差分一阶数值导数

我们首先从最简单的情况开始讨论。对于数值导数而言,最简单的情形是三点差分。

对于一阶导数,如果我们希望得到 \(x = 3\) 的导数即 \(f'(3)\),并且已经知道了整个曲线的形状,我们可以用下面图示的方法:

[5]:
xlist = np.arange(2, 4, 0.01)
ylist = f(xlist)
ytlist = f_p(3) * (xlist - 3) + f(3)

fig, ax = plt.subplots(figsize=(4.5, 3))
ax.plot(xlist, ylist, label="$f(x)$")
ax.plot(xlist, ytlist, label="tangent of $(3, f(3))$", linestyle=":")
ax.scatter([2, 3, 4], [f(2), f(3), f(4)], c="C2")
ax.plot([2, 4], [f(2), f(4)], c="C2", label="segment when $h = 1$")
ax.set_xlim(1.8, 4.2), ax.set_ylim(-0.7,-0.0)
ax.set_title(r"Plot of tangent of $(3, f(3))$")
ax.set_xlabel(r"$x$")
ax.legend(), ax.grid()
fig.tight_layout()

上面绿色的三个点与线段,可以用来估计 \(f(x)\)\(x = 3\) 处的导数 (点 \((3, f(3))\) 处曲线切线的斜率)。我们定义函数 f_d3(x, h) \(f^\mathrm{d} (x, h)\)

\[f^\mathrm{d} (x, h) = \frac{f(x + h) - f(x - h)}{2h}\]
[6]:
def f_d3(x, h):
    return (f(x + h) - f(x - h)) / (2 * h)

我们说,该函数的意义是取绿色线段的斜率。对于上图而言,\(h\) 的取值是 \(1\),那么绿色线段左右两个点就是 \((1 - h, f(1 - h)) = (2, f(2))\)\((1 + h, f(1 + h)) = (4, f(4))\)。这两个线段端点的斜率即

\[f^\mathrm{d} (3, 1) = \frac{f(4) - f(2)}{4 - 2} \simeq 0.2517\]
[7]:
f_d3(3, 1)
[7]:
0.2516641072736052

我们可以估计地认为,函数 \(f(x)\)\(x = 3\) 处切线的斜率 (数值导数) 接近于 \(0.2517\)。从肉眼上,我们也确实会觉得绿色的线段 (估计的导数) 与橙色的切线 (解析的导数) 是非常接近的。实际上,解析的导数是 \(f'(3) \simeq 0.2340\)

[8]:
f_p(3)
[8]:
0.23403928869575705

我们会说,若 \(h\) 的值越小,或者说绿色的点之间的距离越小,那么我们所求得的斜率 (数值导数) 会越精确。事实上,

\[\lim_{h \rightarrow 0} f^\mathrm{d} (x, h) = f'(x)\]

并且数学分析或数值分析可以告诉我们,\(|f^\mathrm{d} (x, h) - f'(x)| = o(h)\),因此函数 \(f^\mathrm{d}(x, h)\)\(f'(x)\) 的逼近精度随 \(h\) 越小而线性地更精确。

但事实上,从程序的结果而言并非如此。我们可以对 \(x = 3\) 时,\(|f^\mathrm{d} (3, h) - f'(3)|\) 的值作图,发现当 \(h = 10^{-5}\) 左右时,才能获得比较好的数值导数的精度 (\(10^{-10}\) 左右的精度)。因此,一般而言,用于求数值导数的差分值 \(h\)

  • \(h\) 较大的情况,数值导数的精度受制于 \(|f^\mathrm{d} (x, h) - f'(x)|\)

  • \(h\) 较小的情况,数值导数的精度受制于计算机本身的精度 (机器精度);若计算机可以提供相当高的机器精度,那么 \(h\) 较小时也可以得到良好的数值精度。

但我们一般在运算时只使用双浮点数,因此我们我们也不能选择太过小的 \(h\) 值来计算数值导数。选取一个合适的 \(h\) 值会是之后始终会遇到的问题。

[9]:
hlist = np.logspace(-17, 1, 1000)
ylist = np.abs(f_d3(3, hlist) - f_p(3))

fig, ax = plt.subplots(figsize=(4.5, 3))
ax.plot(hlist, ylist)
ax.set_xscale("log"), ax.set_yscale("log")
ax.set_xlabel(r"$h$")
ax.set_title("Log plot of $|f^\mathrm{d} (3, h) - f'(3)|$")
fig.tight_layout()

我们指出,尽管三点差分声称是“三点”,但一阶梯度的计算只使用了两个点。但它被称为三点差分,是由于其数学推导而致使的。

五点差分一阶数值导数

五点差分并不像三点差分一样,有直观的几何解释。我们仍然用 \(f^\mathrm{d} (x, h)\) 记号表示数值导数,但程序的函数写为 f_d5(x, h)

\[f^\mathrm{d} (x, h) = \frac{f(x - 2h) - 8 f(x - h) + 8 f(x + h) - f(x + 2h)}{12 h}\]
[10]:
def f_d5(x, h):
    return (f(x - 2 * h) - 8 * f(x - h) + 8 * f(x + h) - f(x + 2 * h)) / (12 * h)

我们仍然先拿 \(x = 3, h = 1\) 的情况来讨论。我们看到,\(f'(3) \simeq 0.2340\),而计算代价低的三点差分大约是 \(0.2517\),代价较高的五点差分大约是 \(0.2552\)

[11]:
f_p(3), f_d3(3, 1), f_d5(3, 1)
[11]:
(0.23403928869575705, 0.2516641072736052, 0.25524346096060413)

可见代价更高的五点差分并不一定会给出更好的结果。但是五点差分有可能达到更低的逼近精度:在 \(x = 3, h = 10^{-3}\) 时,五点差分的数值精度可以达到 \(10^{-13}\) 量级。

[12]:
f_d3(3, 1e-3) - f_p(3), f_d5(3, 1e-3) - f_p(3)
[12]:
(2.0690889585006644e-08, -1.3128387266192476e-14)

我们可以绘制 \(x = 3\) 时,不同的逼近参数 \(h\) 下,三点差分与五点差分的比较:

[13]:
hlist = np.logspace(-17, 1, 1000)
y3list = np.abs(f_d3(3, hlist) - f_p(3))
y5list = np.abs(f_d5(3, hlist) - f_p(3))

fig, ax = plt.subplots(figsize=(4.5, 3))
ax.plot(hlist, y3list, label="3-point stencil")
ax.plot(hlist, y5list, label="5-point stencil")
ax.set_xscale("log"), ax.set_yscale("log")
ax.set_xlabel(r"$h$")
ax.set_title("Log plot of $|f^\mathrm{d} (3, h) - f'(3)|$")
ax.legend()
fig.tight_layout()

一般而言,

  • 五点差分的计算代价相对三点差分来说更大。对于一元函数的一阶导数而言,五点差分的代价是三点差分的两倍;但随着函数的未知元数量增大,其计算量还会进一步增大。

  • 五点差分在逼近参数 \(h\) 较大时,未必有更好的逼近表现。

  • 五点差分从数学的角度来讲,\(|f^\mathrm{d} (x, h) - f'(x)| = o(h^2)\);因此函数 \(f^\mathrm{d}(x, h)\)\(f'(x)\) 的逼近精度随 \(h\) 越小而平方地更精确 (三点差分是线性地更精确)。因此,若逼近精度不受制于机器精度,那么在 \(h\) 较小的情况下,五点差分可以更快地逼近到解析导数。

  • 但五点差分也更容易受制于机器精度影响,因此 \(h\) 太小时,误差也未必比三点差分更小。在当前的例子中,\(h\) 较小的情况下,五点与三点差分的误差近似相等;但对于以后实际的例子,太小的 \(h\) 很可能带来更为灾难性的结果。

  • 希望用较大的 \(h\) 来逼近时,可以选用五点差分;用较小的 \(h\) 逼近时,选用三点差分。

  • 尽管称为五点差分,但实际上只使用了四个点。这也是由于五点差分的推导所导出的结果。

在以后,我们通常只使用三点查分来解决问题。

三点差分二阶数值导数

现在我我们考虑二阶导数的计算。我们首先指出,二阶导数 f_pp(x) \(f''(x)\)

\[f''(x) = 4 e^{2 - 2x} - 2 e^{1 - x}\]
[14]:
def f_pp(x):
    return 4 * np.exp(2 - 2 * x) - 2 * np.exp(1 - x)

二阶数值导数的一种做法,可以是通过一阶数值导数 \(f^\mathrm{d} (x, h)\) 再求一次数值导数获得 (连续两次对函数 \(f(x)\) 的数值导数),其公式可以表达为 f_dd(x, h) \(f^\mathrm{dd} (x, h)\)

\[f^\mathrm{dd} (x, h) = \frac{f(x - h) - 2 f(x) + f(x + h)}{h^2}\]
[15]:
def f_dd(x, h):
    return (f(x - h) - 2 * f(x) + f(x + h)) / h**2

但还有一种做法:如果我们已知一阶解析导数,那么可以通过对解析导数再求一次数值导数获得 (仅一次数值导数),其公式可以表达为 f_pd(x, h) \(f'{}^\mathrm{d} (x, h)\)

\[f'{}^\mathrm{d} (x, h) = \frac{f'(x + h) - f'(x - h)}{2h}\]
[16]:
def f_pd(x, h):
    return (f_p(x + h) - f_p(x - h)) / (2 * h)

我们仍然可以绘制数值导数与解析导数的在 \(x = 3\) 时,不同的逼近参数 \(h\) 的误差图:

[17]:
hlist = np.logspace(-9, 1, 1000)
dlist = np.abs(f_d3(3, hlist) - f_p(3))
ddlist = np.abs(f_dd(3, hlist) - f_pp(3))
pdlist = np.abs(f_pd(3, hlist) - f_pp(3))

fig, ax = plt.subplots(figsize=(4.5, 3))
ax.plot(hlist, dlist, label=r"$f^\mathrm{d} (x, h)$")
ax.plot(hlist, ddlist, label=r"$f^\mathrm{dd} (x, h)$")
ax.plot(hlist, pdlist, label=r"$f'{}^\mathrm{d} (x, h)$")
ax.set_xscale("log"), ax.set_yscale("log")
ax.set_xlabel(r"$h$")
ax.set_title("Error of 3-Point Stencil Derivatives")
ax.legend()
fig.tight_layout()

我们在上图展示了三个数值导数的误差:

  • \(f(x)\) 的两次数值导数 (橙色曲线) 与对 \(f'(x)\) 的一次数值导数 (绿色曲线) 在稍大的 \(h\) 值 (\(h > 10^{-3}\)) 时,误差相近;但两次数值导数 (橙色) 应机器精度导致的误差来得更快;

  • 一般来说,我们很难判断不同函数的数值导数的误差大小 (拿当前的例子而言,我们不能判断对 \(f(x)\) 还是对 \(f'(x)\) 的数值导数误差是哪个更大),但由于机器精度导致的数值导数振荡的 \(h\) 值都在 \(10^{-4.5}\) 左右。

基于上面的原因,我们以后不太会使用两次数值导数,并且通常 \(h\) 的值设在 \(3 \times 10^{-4}\)

向量 (张量) 数值导数与 RHF 核坐标数值梯度

上一节我们讨论了一元函数的数值导数,了解了三点差分法。三点差分法使用带逼近参数 \(h\) 的表达式 \(f^\mathrm{d} (x, h)\) 来逼近函数 \(f'(x)\)

\[f^\mathrm{d} (x, h) = \frac{f(x + h) - f(x - h)}{2h} \simeq f'(x)\]

其中,若不受机器精度影响,\(h\) 越小则 \(f^\mathrm{d} (x, h)\) 越能逼近 \(f'(x)\)

我们这一节,会用实际的化学中的例子,来说明向量,或者任意张量的一阶数值梯度的求取方式。

最简单的例子会是核坐标梯度。其比较直接的物理意义是,分子的受自身结构所受张力方向的相反值。一个分子并非在任何构型下都能稳定存在;一般来说,分子会处于稳定构型附近,而处在稳定构型的分子近乎不受自身结构张力,因此核坐标梯度接近零。

这个量可以用来进行分子动力学模拟,或结构优化。在本文段的最后,我们会拿几何结构优化 (必要条件是分子的核坐标梯度取到零值) 来举例,表明梯度计算在化学计算中的意义。

用法提醒

下文会使用最简单的梯度下降法来进行结构优化;但这显然是非常糟糕的、仅作为演示所用的几何结构优化方案。请使用其它软件或量化库来进行真正有用的计算。

提醒

下文使用的分子会是氨分子。

在其它文档中,我们通常会使用的是变形的双氧水分子;这是因为变形双氧水分子的轨道能级不太简并,且原子数量大于 3,因此比较容易描述后文出现的 U 矩阵的行为、以及容易区别原子的维度与坐标的维度。

但变形双氧水分子的结构优化通常并不容易成功。因此,这篇文档会拿比较容易作结构优化的氨分子来作为例子。

[1]:
%matplotlib notebook

from pyscf import gto, scf, lib
import numpy as np
import matplotlib.pyplot as plt
from pyxdh.Utilities import FormchkInterface, NucCoordDerivGenerator, NumericDiff

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

氨分子量化程序的结构优化

Gaussian 的几何结构优化

对于氨分子结构优化,我们可以使用下述的 Gaussian 输入卡 进行计算:

[2]:
with open("assets/NH3-opt.gjf", "r") as f:
    print(f.read())
%chk=NH3-opt
#p RHF/6-31G opt

NH3 Optimization

0 1
N  0.            0.   0.
H  0.            1.  -0.2
H  0.8660254038 -0.5 -0.2
H -0.8660254038 -0.5 -0.2

提醒

关于几何结构,需要留意单位的问题。我们不管是在 Gaussian 还是 PySCF 中,构建分子时,出于约定俗成,坐标 (作为长度) 的单位默认都是 Angstrom。但不管是在程序中,还是在运算的推导过程中,出于便利程度考虑,普遍使用的都是 Bohr 单位 (等同于原子单位 a.u.)。

后文若不作更多说明,我们将会统一使用 Bohr 单位。因此,上述未经过结构优化的氨分子的坐标若化为 Bohr 单位,则表示为 coord_orig;其中,lib.param.BOHR 是 PySCF 中内置的 Bohr 与 Angstrom 转换数。

[3]:
natm = 4
coord_orig = 1 / lib.param.BOHR * np.array(
    [[ 0.          ,  0. ,  0. ],
     [ 0.          ,  1. , -0.2],
     [ 0.8660254038, -0.5, -0.2],
     [-0.8660254038, -0.5, -0.2]])
coord_orig
[3]:
array([[ 0.     ,  0.     ,  0.     ],
       [ 0.     ,  1.88973, -0.37795],
       [ 1.63655, -0.94486, -0.37795],
       [-1.63655, -0.94486, -0.37795]])

而 Gaussian 输出的优化完毕的分子结构,可以通过 .fch 文件 给出 (在 Bohr 单位下):

[4]:
coord_g09 = FormchkInterface("assets/NH3-opt.fch").key_to_value("Current cartesian coordinates").reshape(4, 3)
coord_g09
[4]:
array([[ 0.     ,  0.     ,  0.11243],
       [ 0.     ,  1.83562, -0.26234],
       [ 1.5897 , -0.91781, -0.26234],
       [-1.5897 , -0.91781, -0.26234]])

该分子在当前计算条件下,N-H 键长与 H-N-H 键角分别为 (单位分别为 Bohr,度)

[5]:
bond_NH = np.linalg.norm(coord_g09[1] - coord_g09[0])
angle_HNH = np.arccos(np.dot(coord_g09[1] - coord_g09[0], coord_g09[2] - coord_g09[0]) / bond_NH**2) * 180 / np.pi
print("N-H Bond Length {:9.5f} Bohr".format(bond_NH))
print("H-N-H Angle     {:9.5f} Degree".format(angle_HNH))
N-H Bond Length   1.87349 Bohr
H-N-H Angle     116.10224 Degree

PySCF 梯度下降的几何结构优化

我们整个文档的目的是求取 xDH 型泛函的梯度。比较相似的问题会是求取 RHF 的梯度。借助这个梯度,我们可以用最基本的梯度下降法,来求取与上面相同的最优化的几何结构。

首先,我们定义一个函数 gen_NH3,该函数将会读入 Bohr 半径单位的原子坐标,输出 PySCF 的分子实例。

[6]:
def gen_NH3(coord):
    """
    Generate NH3 molecule (with basis 6-31G)
    """
    mol = gto.Mole()
    mol.atom = """
    N  0.            0.   0.
    H  0.            1.  -0.2
    H  0.8660254038 -0.5 -0.2
    H -0.8660254038 -0.5 -0.2
    """
    mol.basis = "6-31G"
    mol.verbose = 0
    mol.build()
    mol.set_geom_(coord * lib.param.BOHR)
    return mol.build()

梯度下降的大致过程是,我们首先认为,分子的自洽场能量 \(E\) 可以写为分子坐标 \(A_t\) 的函数。那么分子能量的梯度可以表示为

\[E^{A_t} = \frac{\partial E(A_t)}{\partial A_t}\]

关于分子结构的一些记号,请先参考 分子结构 一节的内容

记号说明

  • 能量记号右上方的角标,表示对能量对该角标的全导数。

但其它情况下,右上方的角标一般表示 Skeleton 导数而非全导数。Skeleton 导数将会在下一章中进行描述。

\(E^{A_t}\) 就称为能量对分子坐标的梯度 (gradient) 或导数 (derivative)。对于当前的分子,其维度是 \(4 \times 3\);即标量值对矢量或矩阵的导数,该导数的维度即等价于原先矢量的维度。该梯度量储存在自洽场的梯度实例 scf_grad 的成员变量 de 中。

[7]:
scf_eng = scf.RHF(gen_NH3(coord_orig)).run()
scf_grad = scf_eng.nuc_grad_method().run()
scf_grad.de
[7]:
array([[-0.     ,  0.     ,  0.01145],
       [ 0.     ,  0.0265 , -0.00382],
       [ 0.02295, -0.01325, -0.00382],
       [-0.02295, -0.01325, -0.00382]])

获得这个梯度的意义是,如果分子的坐标沿着梯度的方向移动,那么其分子能量应会下降,从而更加接近能量的局域极小点。如果我们定义 (标量) 下降率 lr \(\mathtt{lr}\),那么我们能期待

\[\exists \mathtt{lr} > 0, \, \mathrm{s.t.} \, E(A_t - \mathtt{lr} \times E^{A_t}) < E(A_t)\]

那么,如果我们选定一个下降率 \(\mathtt{lr}\),每次用梯度矫正的分子坐标 \(A_t - \mathtt{lr} \times E^{A_t}\) 替换校正前的坐标 \(A_t\),一般地总可以优化到能量的极小点。这个过程就是梯度下降。

但其中存在两个问题:

  • 这里的 \(\mathtt{lr}\) 仅仅是存在,满足上述条件的 \(\mathtt{lr}\) 并不见得容易获得;

  • 矢量区别于标量的一个要点是梯度具有方向性,因此并不能保证沿着梯度下降就会是最好的局域优化方法。

上述的两个问题我们不会作太多展开,但可以对第一个问题作定性描述。我们可以绘制能量 \(E(A_t - \mathtt{lr} \times E^{A_t})\) 随着 \(\mathtt{lr}\) 而变化的曲线 (当 \(\mathtt{lr}\) 恰好为零时,则就是我们一开始给的分子坐标下的能量 \(E(A_t)\)):

[8]:
lr_list = np.arange(0, 4, 0.1)
eng_list = [scf.RHF(gen_NH3(coord_orig - lr * scf_grad.de)).run().e_tot for lr in lr_list]
[9]:
fig, ax = plt.subplots(figsize=(4.5, 3))
ax.plot(lr_list, eng_list)
ax.set_title("Energy plot on gradient trajectory")
ax.set_xlabel(r"$\mathtt{lr}$")
ax.set_ylabel(r"$E(A_t - \mathtt{lr} \times E^\mathtt{A_t})$")
fig.tight_layout()

因此,我们会发现对于当前的例子而言,\(\mathtt{lr}\) 在 2 左右时将会给出较小的能量,但对当前情况来说,\(0 < \mathtt{lr} < 4\) 时能给出相对更低的能量值。

经过测试,我们可以设定 \(\mathtt{lr} = 1\),以进行梯度下降的几何结构优化。其优化过程和优化曲线如下:

[10]:
coord = coord_orig.copy()
lr = 1
eng_threshold = 1e-8
eng_list = []
for _ in range(128):
    scf_eng = scf.RHF(gen_NH3(coord)).run()
    eng_list.append(scf_eng.e_tot)
    if len(eng_list) > 6 and np.abs(eng_list[-1] - eng_list[-4]) < eng_threshold:
        break
    grad_NH3 = scf_eng.nuc_grad_method().run().de
    coord -= lr * grad_NH3

我们最终得到的梯度下降得到的最低能量是

[11]:
eng_list[-1]
[11]:
-56.16552129763208

以及最终优化得到的原子坐标表示如下:

[12]:
coord
[12]:
array([[-0.     ,  0.     , -0.00446],
       [ 0.     ,  1.836  , -0.37646],
       [ 1.59002, -0.918  , -0.37646],
       [-1.59002, -0.918  , -0.37646]])

该分子的键长与键角如下:

[13]:
bond_NH = np.linalg.norm(coord[1] - coord[0])
angle_HNH = np.arccos(np.dot(coord[1] - coord[0], coord[2] - coord[0]) / bond_NH**2) * 180 / np.pi
print("N-H Bond Length {:9.5f} Bohr".format(bond_NH))
print("H-N-H Angle     {:9.5f} Degree".format(angle_HNH))
N-H Bond Length   1.87330 Bohr
H-N-H Angle     116.15804 Degree

对比上面 Gaussian 的输出,可以发现这两者的几何结构非常相近;几何结构的优化是成功的。

数值梯度计算

能量的数值梯度计算

我们先简单回顾一下初始构型 coord_orig 下的原子坐标梯度:

[14]:
scf_eng = scf.RHF(gen_NH3(coord_orig)).run()
scf_grad = scf_eng.nuc_grad_method().run()
scf_grad.de
[14]:
array([[-0.     ,  0.     ,  0.01145],
       [ 0.     ,  0.0265 , -0.00382],
       [ 0.02295, -0.01325, -0.00382],
       [-0.02295, -0.01325, -0.00382]])

作为例子,我们指出上述梯度矩阵的第 0 行 (氮原子) 第 2 列 (\(z\) 坐标分量) 的值,通过数值梯度也能得到。利用三点差分公式,我们定义逼近参数 \(h = 10^{-4}\) Bohr,那么我们通过三点差分,通过这两个分子计算氮原子 \(z\) 分量上的梯度:

[15]:
def eng_deriv(coord):
    coord_m1 = coord.copy()
    coord_m1[0, 2] -= 1e-4                           # set coord_m1 as (x-h)
    coord_p1 = coord.copy()
    coord_p1[0, 2] += 1e-4                           # set coord_p1 as (x+h)
    eng_m1 = scf.RHF(gen_NH3(coord_m1)).run().e_tot  # get f(x-h)
    eng_p1 = scf.RHF(gen_NH3(coord_p1)).run().e_tot  # get f(x+h)
    return (eng_p1 - eng_m1) / 2e-4                  # return (f(x+h) - f(x-h)) / (2h)
eng_deriv(coord_orig)
[15]:
0.011452969950198622

我们看到上述的数值差分结果与量化程序中给出的解析梯度是近乎相等的:

[16]:
scf_grad.de[0, 2]
[16]:
0.011453010014419451

以此类推,我们就可以给出所有原子的所有坐标分量的能量数值梯度了;将这些数值梯度拼成矩阵,就是量化程序所输出的解析梯度 \(E^{A_t}\) 了。下面的程序 num_grad 就是输入任意氨分子,在逼近参数 \(h = 10^{-4}\) Bohr 的情况下给出能量对核坐标的三点差分梯度:

[17]:
def num_grad(coord):
    eng_grad = np.empty((natm, 3))
    for A in range(natm):
        for t in range(3):
            coord_m1 = coord.copy()
            coord_m1[A, t] -= 1e-4                           # set coord_m1 as (x - h)
            coord_p1 = coord.copy()
            coord_p1[A, t] += 1e-4                           # set coord_p1 as (x + h)
            eng_m1 = scf.RHF(gen_NH3(coord_m1)).run().e_tot  # get f(x-h)
            eng_p1 = scf.RHF(gen_NH3(coord_p1)).run().e_tot  # get f(x+h)
            eng_grad[A, t] = (eng_p1 - eng_m1) / 2e-4        # return (f(x+h) - f(x-h)) / (2h)
    return eng_grad
num_grad(coord_orig)
[17]:
array([[ 0.     ,  0.     ,  0.01145],
       [-0.     ,  0.0265 , -0.00382],
       [ 0.02295, -0.01325, -0.00382],
       [-0.02295, -0.01325, -0.00382]])

该数值梯度近乎与 PySCF 给出的解析梯度相等。

因此,我们能预期,借助数值梯度,在稍低一些的收敛条件下,也可以对分子进行结构优化;但数值梯度的消耗相当大,因此进行梯度下降结构优化的时间也会相当长,就不作代码演示了。

pyxdh 数值核坐标梯度程序

事实上,pyxdh 提供了方便求取核坐标的数值梯度程序。我们就拿处在 coord_orig 构型的分子的一阶导数来举例说明 pyxdh 程序的用法。这个程序将会在以后经常用于核验各种解析梯度与数值梯度之间是否相称。

我们首先生成核坐标梯度生成器 NucCoordDerivGenerator 的实例 generator;它必须的输入参数是

  • 分子构型,在这里通过生成 NH3 的分子结构 gen_NH3(coord_orig) 给出。它必须是 PySCF 的 gto.Mole 类实例。

  • 用于给出计算实例的函数。“计算实例”是指用于预先储存所有分子的某一种计算过程。在当前的例子而言,我们会计算分子的能量,即输入 gto.Mole 实例 mol_,输出 scf.RHF 实例 scf.RHF(mol_).run();函数的形式是 lambda 函数。随着计算方式的不同,该函数也可以返回不同类型的实例。

[18]:
generator = NucCoordDerivGenerator(gen_NH3(coord_orig), lambda mol_: scf.RHF(mol_).run())

generator 实例化过程中,会通过上述输入函数,预先计算好所有三点差分所需要的计算实例。这些计算实例将会储存在 generator.objects 成员变量中。譬如对于当前 NH3 分子的核坐标梯度,被求导的矩阵大小是 \(4 \times 3 = 12\),因此 generator.objects 的第一维度是 12。而又由于我们使用三点差分,因此每个被求导量我们都需要求上下浮动 \(h\) 的逼近参数的情形;因此每个被求导量的数值导数需要两个计算实例,即第二维度为 2:

[19]:
generator.objects.shape
[19]:
(12, 2)

而计算实例的类型与方才给 NucCoordDerivGenerator 实例化时调入的第二个参数 (lambda 函数) lambda mol_: scf.RHF(mol_).run() 的返回类型相同:

[20]:
type(generator.objects[0, 0])
[20]:
pyscf.scf.hf.RHF

但上述实例仅仅是生成了用于三点差分的计算实例的集合 generator.objects;真正计算三点差分的实例是通过实例化类 NumericDiff 生成的;它必须输入的参数是

  • 梯度生成器;在当前的例子中,是 generator

  • 从计算实例导出具体数值的函数;在当前的例子中,我们需要从 scf.RHF 实例 mf 中,给出分子的能量信息,即 mf.e_tot 成员变量。

看起来,将生成三点差分的计算实例 (类 NucCoordDerivGenerator 的实例)、和具体计算三点差分的过程的拆分 (类 NumericDiff 的实例),似乎是冗余的;但我们以后在具体验证 XYG3 型泛函导数性质时,将会发现非常有用。

[21]:
diff = NumericDiff(generator, lambda mf: mf.e_tot)

上述三点差分实例 diff 可以用 diff.derivative 成员属性给出;我们可以发现,下述的数值导数与上面我们求过的数值导数近乎相等。

[22]:
diff.derivative.reshape(natm, 3)
[22]:
array([[ 0.     ,  0.     ,  0.01145],
       [ 0.     ,  0.0265 , -0.00382],
       [ 0.02295, -0.01325, -0.00382],
       [-0.02295, -0.01325, -0.00382]])

这里仅仅是介绍了使用 pyxdh 作核坐标能量梯度的计算方式。事实上,由于 pyxdh 的编写过程中还经常处理电场导数、以及对矩阵的导数,因此 pyxdh 的数值导数还有其它的用法。我们会在后面的偶极矩文段中介绍电场导数类 DipoleDerivGenerator,以及在 Hessian、极化率或红外相关文段中介绍对矩阵的导数。

RHF 偶极矩数值梯度

上一节,我们讨论了分子能量对核坐标的数值梯度,以此得到了分子自身结构所受张力,并用其进行了氨分子的几何结构优化。这一节,我们讨论氨分子的偶极矩计算。

偶极矩从定义上来讲,可以看作对于任意位置的参考正电荷而言,分子中的其余电荷与该参考电荷的距离与电量的乘积,其方向与电荷坐标核参考电荷坐标有关。它也等同于在外加电荷所产生的微扰电场下,分子的能量的变化值的表征。

这可能会是一个很笼统的说明,我们在后面的文段中会作更多说明。

[1]:
%matplotlib notebook

from pyscf import gto, scf, lib, dft
import numpy as np
import matplotlib.pyplot as plt
from pyxdh.Utilities import FormchkInterface, DipoleDerivGenerator, NumericDiff, GridHelper
from IPython.display import Image
import warnings

np.set_printoptions(5, linewidth=150, suppress=True)
warnings.filterwarnings("ignore")

5b4a6aacd6d744ad9f92c1d6e12e1ac6

偶极矩计算

PySCF 的偶极矩计算

我们首先定义当前的氨分子分子信息:

[2]:
mol = gto.Mole()
mol.atom = """
N  0.            0.  -0.0
H  0.            1.  -0.5
H  0.8660254038 -0.5 -0.5
H -0.8660254038 -0.5 -0.5
"""
mol.basis = "6-31G"
mol.verbose = 0
mol.build()
[2]:
<pyscf.gto.mole.Mole at 0x7fb4a817b370>

这个氨分子 RHF/6-31G 的计算实例记为 scf_eng

[3]:
scf_eng = scf.RHF(mol).run()

通过这个计算实例,我们可以直接计算氨分子的偶极矩 \(d_{t}\) (单位为原子单位):

[4]:
scf_eng.dip_moment(unit="a.u.")
Dipole moment(X, Y, Z, A.U.): -0.00000,  0.00000, -1.00579
[4]:
array([-0.     ,  0.     , -1.00579])

RHF 偶极矩的解析计算

事实上,RHF 的偶极矩计算极为方便。我们先不讨论该偶极矩是如何导出的,只给出结果。

首先,我们定义变量 H_1_ao 是积分 \(t_{\mu \nu}\)

\[t_{\mu \nu} = \langle \mu | t | \nu \rangle = \int \mu(\boldsymbol{r}) t \nu(\boldsymbol{r}) \, \mathrm{d} \boldsymbol{r}\]

上式中,\(t\) 表示电子三维坐标的分量,而 \(\boldsymbol{r}\) 表示电子坐标。在这里,\(t\)\(\boldsymbol{r}\) 的其中一个坐标分量。该变量的维度是 \((t, \mu, \nu)\)

[5]:
H_1_ao = mol.intor("int1e_r")
H_1_ao.shape
[5]:
(3, 15, 15)

我们再令

  • D 为该分子的密度 \(D_{\mu \nu}\);

  • Z_A 为该分子的原子电荷 \(Z_A\)

  • A_t 为该分子的核坐标 \(A_t\)

这些量的定义也可以参考 分子结构 一节的内容。

[6]:
D = scf_eng.make_rdm1()
Z_A = mol.atom_charges()
A_t = mol.atom_coords()

那么 RHF 的偶极矩计算会显得异常简单:

\[d_t = - t_{\mu \nu} D_{\mu \nu} + Z_A A_t\]
[7]:
- np.einsum("tuv, uv -> t", H_1_ao, D) + np.einsum("A, At -> t", Z_A, A_t)
[7]:
array([-0.     ,  0.     , -1.00579])

我们似乎就把 RHF 偶极矩问题解决了,并且在计算与理解过程中没有用到任何与梯度有关的信息。不过我们将会在后文渐渐阐释。

偶极矩作为物理计算量

化学地解释氨分子偶极矩

一般而言,化学中解释氨分子的偶极矩,是通过原子的电正性、电负性来解释的。我们知道氮原子是较强电负性,而氢原子是中性偏正。因此,若定义偶极方向是从负电荷向正电荷方向,那么我们可以依照化学键,构建三个从氮原子到氢原子的偶极矢量 (下图的橙色箭头)。这三个偶极矢量的加和就得到了总偶极矢量 (绿色箭头)。

[8]:
Image(filename="assets/num_dip_4.png", width=300)
[8]:
_images/numdiff_num_dip_25_0.png

上述图景也可以解释为,三个氢原子构成的正电中心与氮原子构成的负电中心产生了偶极。

提醒

化学中一般都会将偶极矩定义为从正电荷指向负电荷。这篇文档、以及量化程序所使用的定义是相反的 (可能是物理上定义的),即负电荷指向正电荷。

我们刚才在程序中给出的偶极矩是沿 \(z\) 轴向下的,这也符合上述图像的表述。

但该定义是基于无法确切定义的电正性、电负性所给出的。它的具体大小的计算,将不能依赖于上述图景。

任务 (1)

依据上述图景,讨论铵离子 (\(\mathsf{NH_4^+}\)) 的偶极矩。

点电荷相对于参考电荷的偶极矩

我们先讨论最为基础的问题。考虑只有两个点电荷的体系。我们认为,如果定义 \(q_1\) 为参考电荷,则 \(q_2\) 相对于 \(q_1\) 所对偶极矩的贡献大小是 \(\boldsymbol{d} = q_2 \boldsymbol{r}\),其中 \(\boldsymbol{r}\) 是参考电荷 \(q_1\) 指向电荷 \(q_2\) 的长度矢量:

[9]:
Image(filename="assets/num_dip_1.png", width=400)
[9]:
_images/numdiff_num_dip_32_0.png

通常来说,我们在讨论偶极矩时,会说只有 \(q_1 = q_2\) 的对等偶极子情形时,才称之为偶极矩。因此,若要计算偶极矩,则需要先分别求出正负电荷中心,再求出电荷中心间距,从而乘以电荷得到偶极矩。

但在计算化学的分子时,正负电荷中心由于电子云本身的弥散性不易求;并且很有可能因为分子本身带电 (或分子的某个部分,譬如分子中原子核总是正电,电子云总是负电),从而正负电荷量不能对等,不构成合理的偶极子。因此,在实际的计算中,我们并不强求 \(q_1 = q_2\),而使用上图的定义来计算 \(q_2\) 单个电荷的偶极矩贡献。

但这种定义方式可能相当奇怪。我们再回顾 \(q_2\) 单个电荷的偶极计算过程:

\[\boldsymbol{d} = q_2 \boldsymbol{r}\]

我们发现:

  • 偶极矩只与 \(q_2\) 电荷的电量有关,而与参考电荷 \(q_1\) 完全无关;

  • 偶极矩与 \(\boldsymbol{r}\) 有关,因此意味着参考电荷 \(q_1\) 的位置也决定了偶极矩的大小。

这似乎与我们平时的认知不太相契合。我们一般总是认为,任何结构的偶极矩都是一个确定的、不随坐标平移而改变的量;但事实并非如此。我们下面来计算氨分子中原子核 (不包含电子云) 对偶极矩的贡献大小,来说明上述问题。

原子核对偶极矩的贡献

我们现在讨论方才定义的氨分子中,N 原子与 3 个 H 原子的原子核对偶极矩贡献。我们将原子核当作点电荷看待。下述矩阵的第 0 行是 N 原子的坐标,第 1, 2, 3 行是 H 原子的坐标 (单位 Bohr)。

[10]:
A_t
[10]:
array([[ 0.     ,  0.     ,  0.     ],
       [ 0.     ,  1.88973, -0.94486],
       [ 1.63655, -0.94486, -0.94486],
       [-1.63655, -0.94486, -0.94486]])

今后我们始终假定参考电荷 (即上面提到的 \(q_1\)) 的坐标处在 \((0, 0, 0)\) 坐标点上 (电荷电量目前可以是任意的,但后文会作补充说明)。那么根据上面的表达式,可知对于每个原子,其所贡献的偶极矩贡献是:

\[d_\mathrm{atom, A} = Z_A A_t\]
[11]:
np.einsum("A, At -> At", Z_A, A_t)
[11]:
array([[ 0.     ,  0.     ,  0.     ],
       [ 0.     ,  1.88973, -0.94486],
       [ 1.63655, -0.94486, -0.94486],
       [-1.63655, -0.94486, -0.94486]])

将上述的矩阵按列相加,就得到了总的原子对偶极矩作的贡献:

[12]:
np.einsum("A, At -> t", Z_A, A_t)
[12]:
array([ 0.     ,  0.     , -2.83459])

我们注意到,RHF 的偶极矩公式是 \(d_t = - t_{\mu \nu} D_{\mu \nu} + Z_A A_t\),因此上面的过程就是求取了第二项 \(Z_A A_t\)

但关于上述结果,我们需要作补充的说明。我们刚才讨论的情况如下图,参考电荷 (绿色圆点) 是在原点即氮原子 (蓝色圆点) 上:

[13]:
Image(filename="assets/num_dip_2.png", width=400)
[13]:
_images/numdiff_num_dip_44_0.png

但如果现在我们将分子下移而不更改参考电荷坐标,譬如我们降低氨分子 1 Angstrom,那么情形就会非常不一样:

[14]:
Image(filename="assets/num_dip_3.png", width=300)
[14]:
_images/numdiff_num_dip_46_0.png

我们定义该分子的 PySCF gto.Mole 实例是 mol_m

[15]:
mol_m = gto.Mole()
mol_m.atom = """
N  0.            0.  -1.
H  0.            1.  -1.5
H  0.8660254038 -0.5 -1.5
H -0.8660254038 -0.5 -1.5
"""
mol_m.basis = "6-31G"
mol_m.verbose = 0
mol_m.build()
[15]:
<pyscf.gto.mole.Mole at 0x7fb4743bfdf0>

我们注意到,分子的总偶极矩和对于这两个分子而言是相等的 (恰好因为氨分子是中性分子因此该结论成立):

[16]:
print("Dipole of original molecule: ", scf.RHF(mol).run().dip_moment())
print("Dipole of moved molecule:    ", scf.RHF(mol_m).run().dip_moment())
Dipole moment(X, Y, Z, Debye):  0.00000,  0.00000, -2.55646
Dipole of original molecule:  [ 0.       0.      -2.55646]
Dipole moment(X, Y, Z, Debye): -0.00000,  0.00000, -2.55646
Dipole of moved molecule:     [-0.       0.      -2.55646]

但向下移动过的分子,其每个原子核对总偶极矩的贡献值并不同:

[17]:
np.einsum("A, At -> At", mol_m.atom_charges(), mol_m.atom_coords())
[17]:
array([[  0.     ,   0.     , -13.22808],
       [  0.     ,   1.88973,  -2.83459],
       [  1.63655,  -0.94486,  -2.83459],
       [ -1.63655,  -0.94486,  -2.83459]])

特别是我们注意到了氮原子:在没有移动的分子中,氮原子对总偶极矩的贡献是零值;但在向下移动后的分子中,氮原子产生了巨大的贡献。

相应地,向下移动过的分子的总原子核贡献也与未移动过的分子相差巨大:

[18]:
np.einsum("A, At -> t", mol_m.atom_charges(), mol_m.atom_coords())
[18]:
array([  0.     ,   0.     , -21.73185])

这部分差距将会在后面电子云的贡献有所补偿。

提醒

我们方才提到,分子总偶极矩可以拆分为原子核贡献项 \(Z_A A_t\) 与电子云贡献项 \(- t_{\mu \nu} D_{\mu \nu}\)。这两项 之和 在分子为中性即不带正负电荷的情况下,是不受分子平移的影响而变化的。关于这点我们不作详细说明。

但需要注意,对于电荷并不平衡的 离子 (例如铵离子 \(\mathsf{MH_4^+}\)),量化计算所给出的偶极矩确实会 受分子平移而变化。关于离子是否有具有物理意义的偶极矩,暂还不在我们这篇文档的讨论范畴中。

任务 (2)

请使用 PySCF 的量化程序,给出铵离子 \(\mathsf{NH_4^+}\) 偶极矩的大小。请问偶极矩大小是否受分子平移而变化?程序的结果是否支持任务 (1) 的结论?

电子云密度对偶极矩的贡献

我们再次回顾到对于点电荷 \(q\) 单个电荷相对于参考电荷的偶极贡献为:

\[\boldsymbol{d} = q \boldsymbol{r}\]

但注意到这是点电荷的计算。对于电子弥散的电子云而言,我们有的是电子密度 rho_0 \(\rho(r)\)。那么,我们对全空间的坐标微元 \(\mathrm{d} \boldsymbol{r}\) 的电荷大小 \(- \rho(r) \mathrm{d} \boldsymbol{r}\)\(\boldsymbol{r}\) 乘积的全积分:

\[\boldsymbol{d} = - \int \rho(r) \boldsymbol{r} \, \mathrm{d} \boldsymbol{r}\]

之所以上式有负号,是因为作为电子,电荷是负的。

对上述连续空间的积分,我们很容易地想到可以离散我们用以前介绍过的方法生成氨分子的 (99, 590) 格点并生成密度,并作上述积分。

我们定义一些变量:

  • weights \(w_g\) 格点权重;

  • rho_0 \(\rho_g\) 密度格点;

  • grid.coords \(t_g\) 格点坐标,但维度是 \((g, t)\) 即 (格点数量,三个坐标分量)。

[19]:
grid = dft.Grids(mol)
grid.atom_grid = (99, 590)
grid.build()
gh = GridHelper(mol, grid, scf_eng.make_rdm1())
weights = grid.weights
rho_0 = gh.rho_0

那么,上述连续积分可以化为格点积分:

\[d_t = - \int \rho(r) t \, \mathrm{d} \boldsymbol{r} = - w_g \rho_g t_g\]
[20]:
-(weights * rho_0 * grid.coords.T).sum(axis=1)
[20]:
array([-0.    ,  0.    ,  1.8288])

这就是电子云对偶极矩的贡献了。将该贡献与上面的原子核坐标贡献相加,就能得到总分子的偶极矩了:

[21]:
-(weights * rho_0 * grid.coords.T).sum(axis=1) + np.einsum("A, At -> t", Z_A, A_t)
[21]:
array([-0.     ,  0.     , -1.00579])

可以看到,这与 PySCF 所给出的总偶极矩近乎于是相等的:

[22]:
scf_eng.dip_moment(unit="A.U.")
Dipole moment(X, Y, Z, A.U.): -0.00000,  0.00000, -1.00579
[22]:
array([-0.     ,  0.     , -1.00579])

但我们还需要注意到,上述的格点积分事实上上可以用解析积分替代。注意到

\[d_t = - \int \rho(r) t \, \mathrm{d} \boldsymbol{r} = - \langle \mu | t | \nu \rangle D_{\mu \nu} = - t_{\mu \nu} D_{\mu \nu}\]

上述公式也是在文段较早处已经实现过的:

[23]:
- np.einsum("tuv, uv -> t", H_1_ao, D)
[23]:
array([-0.    ,  0.    ,  1.8288])

同时,我们需要指出,如果分子的坐标发生平移,即使分子结构没有变化,电子云对偶极矩的贡献仍然是会变的;但与核坐标的贡献作加和,这部分变化就被抵消了。

偶极矩作为梯度计算量

我们事实上已经成功地推导出了 RHF 下的偶极矩了。但上述的推导在 XYG3 型泛函或 MP2 方法中,则会遇到问题:作为非变分的方法,如何定义电子云密度?

事实上这个问题并不是显然的,我们会放在以后讨论。既然不能轻松地给出电子云密度,那么就不能依据上述方式推导偶极矩。那么除了上述方式推导之外,还有什么方式可以获得偶极矩?我们可以通过对能量的电场梯度获得。

外加点电荷电场与分子构成的电势能

我们之前指出,对于处于原点的参考点电荷 \(q_1\) 与点电荷 \(q_2\),且前后者坐标所构成的向量是 \(\boldsymbol{r}\);那么 \(q_2\) 的偶极矩是通过 \(q_1, q_2\) 形成的电势能 \(E\)\(q_1\) 所散发的电场 \(\boldsymbol{F}\) 的比值获得的:(这里的电势能 \(E\) 有可能与后文的分子能量混淆,读者可能需要自行区分这两者)

\[\boldsymbol{d} = \frac{E}{\boldsymbol{F}} = \frac{k q_1 q_2 / |r|}{k q_1 \boldsymbol{r} / |r|^3} = q_2 \boldsymbol{r}\]

如果将点电荷 \(q_2\) 换成氨分子,那么 \(q_1\) 所散发的电场 \(\boldsymbol{F}\) 仍然是已知的,但电荷与分子所构成的电势能在我们看来还并非是已知的。下面的目标将是生成参考电荷与氨分子所形成的电势能。

但直接计算电势能会比较困难;我们转而将参考点电荷所产生的额外电势能放到分子总能量中计算。我们指出,分子的 RHF 总能量计算包含 Hamiltonian Core 能量、J 积分、K 积分和核坐标互斥能。点电荷所产生的额外势能可以直接加到 Hamiltonian Core 中:

\[\hat h = - \frac{1}{2} \nabla_\boldsymbol{r}^2 + \hat v_\mathrm{nuc} + \frac{\boldsymbol{F} \cdot \boldsymbol{d}}{\rho(\boldsymbol{r})}\]

以该 Hamiltonian Core 代入自洽场计算中,就可以得到该分子在某种外电场 \(\boldsymbol{F}\) 下的分子总能量了。将此能量减去不受外场微扰的分子总能量,就可以得到参考电荷 \(q_1\) 下所产生的静电势能,并除以微扰电场 \(\boldsymbol{F}\) 得到偶极矩了。

上式中分母部分的 \(\rho(\boldsymbol{r})\) 表示氨分子密度;若对于点电荷情形,它接近于 \(q_2\) 的意义。因此,

\[\frac{\boldsymbol{F} \cdot \boldsymbol{d}}{\rho(\boldsymbol{r})} = \boldsymbol{F} \cdot \boldsymbol{r}\]

这“某种”外场 \(\boldsymbol{F}\) 原则上是任意的,我们之后认为分子的总能量 \(E\) 是关于该外场的函数。我们现在就举一个具体的例子。现在我们假定 \(\boldsymbol{F} = (0, 0, 10^{-4})\),那么该分子的能量可以通过如下方式求取:

[24]:
scf_eng_p1 = scf.RHF(mol)
scf_eng_p1.get_hcore = lambda mol_: scf.rhf.get_hcore(mol_) - 1e-4 * mol_.intor("int1e_r")[2]
scf_eng_p1.run()
scf_eng_p1.e_tot
[24]:
-56.13139652223548

上面的第二行代码是较为重要的代码:

  • PySCF 的 Hamiltonian Core 是可以更改的,其默认函数签名是输入一个 gto.Mole 实例;因此,我们重新定义 get_hcore 为一个 lambda 函数;

  • 其中,scf.rhf.get_hcore 是默认的 Hamiltonian Core,即动能部分与原子核静电吸引势部分;

  • 负号是指参考电荷取正电荷,但电子云是负电荷,因此这就需要取负号;

  • 1e-4 表明外加电场强度是 \(10^{-4}\)

  • mol_.intor("int1e_r")[2] 代表的是 \(\langle \mu | z | \nu \rangle\),即原子轨道在 \(z\) 作为算符下的期望。之所以选用 \(z\) 轴,是因为 \(\boldsymbol{F} = (0, 0, 10^{-4})\) 中有值分量是 \(z\) 轴分量;因此,\(\boldsymbol{F} \cdot \boldsymbol{r} = 10^{-4} z\)

上面我们求出了在外场下的自洽场能量。我们注意到该能量与没有外电场的情况近乎一致,但有着细微的差别:

[25]:
scf_eng.e_tot
[25]:
-56.13157937104964

我们将两者能量的差近似为处于原点的参考电荷与分子共同产生的静电势能;将其除以外电场大小 \(\boldsymbol{F}\) (的 \(z\) 轴分量 \(10^{-4}\)),就能得到 \(z\) 轴分量的电子云偶极矩的近似值:

[26]:
(scf_eng_p1.e_tot - scf_eng.e_tot) / 1e-4
[26]:
1.8284881416263943

下面是解析计算所给出的电子云对偶极矩的贡献大小,我们会发现上面的计算结果非常接近下面的值:

[27]:
- np.einsum("tuv, uv -> t", H_1_ao, D)[2]
[27]:
1.8288017499152802

但需要留意,上面的过程很像三点差分,但实际上并非如此;因此,上面的计算精度尚还不够。下面我们就介绍如何使用三点差分计算偶极矩。

任务 (3)

我们刚才提到,我们假定的 \(\boldsymbol{F}\)\(z\) 轴分量大小是 \(10^{-4}\);它是用原子单位描述的,请指出其量纲。

三点差分计算偶极矩的电子云部分贡献

首先,我们指出,分子的能量可以写成受外加势场 \(\boldsymbol{F}\) 的函数 \(E(F_t)\),其中 \(t\) 代表的是电场 \(\boldsymbol{F}\)\(t\) 坐标分量。因此,实际上 \(E(F_t)\) 表示的是一个关于 \(t\) 的三维向量。

我们首先定义一个程序函数 gen_eng_from_field,它将输入坐标分量 \(t\) t 与外电场 \(F_t\) 的大小 (原子单位) f,输出在外场下的能量:

[28]:
def gen_eng_from_field(t, f):
    mf = scf.RHF(mol)
    mf.get_hcore = lambda mol_: scf.rhf.get_hcore(mol_) - f * mol_.intor("int1e_r")[t]
    mf.run()
    return mf.e_tot

拿这个函数,我们可以重复刚才的能量值:

[29]:
gen_eng_from_field(2, 1e-4)
[29]:
-56.13139652223548

这是在 \(F_z = 10^{-4}\) 的情况下。若 \(F_z = -10^{-4}\),则体系能量为

[30]:
gen_eng_from_field(2, -1e-4)
[30]:
-56.131762282587246

我们回顾三点差分公式:

\[f^\mathrm{d} (x, h) = \frac{f(x + h) - f(x - h)}{2h} \simeq f'(x)\]

在这里,我们将 \(x\) 看作是没有外加参考点电荷电场的情况即 \(x = 0\),而 \(h\) 是偏离零电场的场强 \(F_z\)。那么,电子云对偶极矩的 \(z\) 轴方向的贡献值可以通过下述三点差分给出:

[31]:
(gen_eng_from_field(2, 1e-4) - gen_eng_from_field(2, -1e-4)) / 2e-4
[31]:
1.8288017592738015

类似地,我们也可以求出 \(x\) 方向与 \(y\) 方向电子云对偶极矩的贡献:

[32]:
print("x: ", (gen_eng_from_field(0, 1e-4) - gen_eng_from_field(0, -1e-4)) / 2e-4)
print("y: ", (gen_eng_from_field(1, 1e-4) - gen_eng_from_field(1, -1e-4)) / 2e-4)
x:  -1.4210854715202004e-10
y:  -6.352252057695296e-08

可见对于氨分子而言,其电子云在 \(x, y\) 轴方向上对偶极矩贡献为零。

pyxdh 的偶极矩程序帮手

pyxdh 的程序自带了对偶极矩求导的部分。首先,我们构建偶极矩计算三点差分时所使用的所有计算实例的类 DipoleDerivGenerator 实例 generator。它的实例化需要一个函数,该函数的输入与之前提到的 gen_eng_from_field 一样,输入需要计算的偶极矩的坐标分量与外加电场强度。功能也类似,但一般来说返回的是计算对象为了便利期间,尽量不要是作为最终的能量。

[33]:
def mf_func(t, f):
    mf = scf.RHF(mol)
    mf.get_hcore = lambda mol_: scf.rhf.get_hcore(mol_) - f * mol_.intor("int1e_r")[t]
    return mf.run()

generator = DipoleDerivGenerator(mf_func)

随后我们就像上一篇文档所述,用 NumDiff 类的实例 diff 来计算梯度:

[34]:
diff = NumericDiff(generator, lambda mf: mf.e_tot)
[35]:
diff.derivative
[35]:
array([ 0.    , -0.    ,  1.8288])

但需要留意,上述的数值梯度只是电子云部分的贡献;全部偶极矩的贡献值是

[36]:
diff.derivative + np.einsum("A, At -> t", Z_A, A_t)
[36]:
array([ 0.     , -0.     , -1.00579])

参考任务解答

任务 (1)

铵离子的四个从氮原子到氢原子的偶极矢量之和为零,因此偶极矩为零。这会是通常的理解方式。具有非零偶极矩的分子应当是 \(C_n, C_{nv}, C_s\) 点群。

但需要注意到,铵离子并非是中性分子。一般讨论偶极矩时,都要求结构处于中性,因此上述的讨论原则上是不正确的。

任务 (2)

我们可以定义一个 (未必是稳定构型) 铵离子,并计算其偶极矩。这个氨分子是 \(T_d\) 对称的,并且需要将氮原子置于原点:

[37]:
mol_NH4_0 = gto.Mole()
mol_NH4_0.atom = """
 N                 -0.00000000   -0.00000000    0.00000000
 H                 -0.00000000   -0.00000000    1.01098940
 H                 -0.00000000   -0.95316995   -0.33699647
 H                  0.82546939    0.47658497   -0.33699647
 H                 -0.82546939    0.47658497   -0.33699647
"""
mol_NH4_0.nelec = (5, 5)
mol_NH4_0.basis = "6-31G"
mol_NH4_0.verbose = 0
mol_NH4_0.build()
scf.RHF(mol_NH4_0).run().dip_moment(unit="A.U.")
Dipole moment(X, Y, Z, A.U.):  0.00000, -0.00000, -0.00000
[37]:
array([ 0., -0., -0.])

我们会发现这个分子的偶极矩为零。但如果我们统一将离子沿 \(z\) 轴下移 1 Angstrom,那么偶极矩就是非零了:

[38]:
mol_NH4_1 = gto.Mole()
mol_NH4_1.atom = """
 N                 -0.00000000   -0.00000000   -1.00000000
 H                 -0.00000000   -0.00000000    0.01098940
 H                 -0.00000000   -0.95316995   -1.33699647
 H                  0.82546939    0.47658497   -1.33699647
 H                 -0.82546939    0.47658497   -1.33699647
"""
mol_NH4_1.nelec = (5, 5)
mol_NH4_1.basis = "6-31G"
mol_NH4_1.verbose = 0
mol_NH4_1.build()
scf.RHF(mol_NH4_1).run().dip_moment(unit="A.U.")
Dipole moment(X, Y, Z, A.U.):  0.00000, -0.00000, -1.88973
[38]:
array([ 0.     , -0.     , -1.88973])

有意思的是,对于该分子的 \(z\) 轴偶极矩,事实上它就是 1 Angstrom 换算成 Bohr 半径值的负值:这恰好与铵离子具有一个正电荷有关。

因此,这似乎与任务 (1) 的结论有所矛盾。一般来说,讨论离子的偶极矩可能是没有意义的。

任务 (3)

我们认为,\(\hat h\) 应当具有能量的量纲,而 \(\boldsymbol{r}\) 具有长度的量纲,因此 \(\boldsymbol{F}\) 的量纲是能量除以长度。

RHF 原子核坐标二阶梯度

前面我们提及,对 RHF 的能量作一阶梯度,可以求出一些分子的性质。譬如,原子核坐标的一阶梯度 \(E^{A_t}\),可以得到分子自身结构所产生的张力 (分子力);对参考电荷所产生的电场的导数,就能得到分子偶极矩。从这一节开始,我们简单地讨论能量量的二阶梯度计算。

我们首先先是比较易于求导数的计算原子核坐标二阶导数 \(E^{A_t B_s}\)。核坐标二阶导数的最重要的意义在于,对于稳定构象分子而言,可以求取其分子频率。

提醒

我们下面使用以后经常使用的非对称双氧水分子。但该分子并非处于稳定构象,因此我们后文计算所得的分子频率并非是有物理意义的。后文所给的计算过程仅仅是演示而已。

[1]:
from pyscf import gto, scf, lib, hessian
import numpy as np
from pyxdh.Utilities import FormchkInterface, NucCoordDerivGenerator, NumericDiff
from pyxdh.Utilities.test_molecules import Mol_H2O2
from pyxdh.DerivOnce import GradSCF
from pyxdh.DerivTwice import HessSCF
import warnings

warnings.filterwarnings("ignore")
np.set_printoptions(5, linewidth=150, suppress=True)

量化程序的频率计算

Gaussian 的频率分析

我们可以写如下的 输入卡,并得到 输出文件fch 文件

[2]:
with open("assets/H2O2-freq.gjf", "r") as f:
    print(f.read())
%chk=H2O2-freq
#p RHF/6-31G Freq NoSymm

H2O2 Frequency Analysis

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

通过输出文件,我们可以得到如下与频率分析 (或高阶导数) 有关的量:

[3]:
fchk = FormchkInterface("assets/H2O2-freq.fch")
  • Hessian 矩阵,维度为 \((3 n_\mathrm{atom}, 3 n_\mathrm{atom})\),可以用于计算分子频率,单位为 a.u.:

[4]:
fchk.hessian()
[4]:
array([[ 0.36765, -0.01096, -0.02986, -0.02036,  0.0064 ,  0.03848, -0.4029 ,  0.00214, -0.02579,  0.0556 ,  0.00242,  0.01717],
       [-0.01096,  0.02901,  0.11159,  0.0047 ,  0.08453, -0.11579,  0.00851, -0.03718,  0.0048 , -0.00226, -0.07637, -0.0006 ],
       [-0.02986,  0.11159,  0.47024, -0.00243,  0.00961, -0.33099,  0.02687,  0.0038 , -0.03383,  0.00542, -0.125  , -0.10542],
       [-0.02036,  0.0047 , -0.00243, -0.07793, -0.00283, -0.04145, -0.00102, -0.0056 ,  0.04462,  0.09931,  0.00372, -0.00074],
       [ 0.0064 ,  0.08453,  0.00961, -0.00283,  0.66306, -0.43734,  0.00034,  0.00409, -0.00816, -0.00392, -0.75168,  0.43588],
       [ 0.03848, -0.11579, -0.33099, -0.04145, -0.43734,  0.426  , -0.00318,  0.00431, -0.04919,  0.00616,  0.54882, -0.04582],
       [-0.4029 ,  0.00851,  0.02687, -0.00102,  0.00034, -0.00318,  0.41067, -0.01219, -0.02918, -0.00675,  0.00333,  0.00549],
       [ 0.00214, -0.03718,  0.0038 , -0.0056 ,  0.00409,  0.00431, -0.01219,  0.02907,  0.00724,  0.01565,  0.00402, -0.01535],
       [-0.02579,  0.0048 , -0.03383,  0.04462, -0.00816, -0.04919, -0.02918,  0.00724,  0.0954 ,  0.01035, -0.00389, -0.01238],
       [ 0.0556 , -0.00226,  0.00542,  0.09931, -0.00392,  0.00616, -0.00675,  0.01565,  0.01035, -0.14815, -0.00947, -0.02193],
       [ 0.00242, -0.07637, -0.125  ,  0.00372, -0.75168,  0.54882,  0.00333,  0.00402, -0.00389, -0.00947,  0.82402, -0.41993],
       [ 0.01717, -0.0006 , -0.10542, -0.00074,  0.43588, -0.04582,  0.00549, -0.01535, -0.01238, -0.02193, -0.41993,  0.16362]])

Hessian 矩阵 (或者张量) 其实就是分子能量对所有原子核坐标的三个分量的二次导数 \(E^{A_t B_s} = \frac{\partial^2 E}{\partial A_t \partial B_s}\)。上述矩阵的第一维度表示 \(A_t\),第二维度表示 \(B_s\)

  • 分子频率,对于非线性分子而言是 \(3 n_\mathrm{atom} - 6\) 维度;但该值只能从 out 文件得到而不能从 fch 文件给出,单位为 1/cm:

[5]:
with open("assets/H2O2-freq.out", "r") as f:
    for idx, line in enumerate(f.readlines()):
        if "Frequencies" in line:
            print("line {:4d}:".format(idx + 1), line[:-1])
line  574:  Frequencies -- -1580.6089             -1218.3809              1370.6206
line  588:  Frequencies --  1647.5426              3389.8666              5347.9015
  • 偶极矩的核坐标导数,维度为 \((3 n_\mathrm{atom}, 3)\),可以用于计算红外光谱峰强度 (单位为 a.u.):

[6]:
fchk.dipolederiv()
[6]:
array([[-0.2343 ,  0.01785,  0.16617],
       [ 0.07423, -0.4948 ,  0.00587],
       [ 0.04888, -0.08356, -0.50397],
       [-0.41785,  0.01883, -0.16946],
       [ 0.00548, -0.32846, -0.15515],
       [ 0.02287,  0.03304, -0.09475],
       [ 0.21503,  0.00182, -0.03122],
       [-0.04486,  0.44397,  0.02416],
       [-0.03371, -0.01025,  0.25124],
       [ 0.43712, -0.0385 ,  0.03451],
       [-0.03485,  0.37929,  0.12513],
       [-0.03804,  0.06077,  0.34748]])
  • 红外光谱强度,对于非线性分子而言是 \(3 n_\mathrm{atom} - 6\) 维度,与分子频率一一对应,单位为 km/mol (千米每摩尔):

[7]:
with open("assets/H2O2-freq.out", "r") as f:
    for idx, line in enumerate(f.readlines()):
        if "IR Inten" in line:
            print("line {:4d}:".format(idx + 1), line[:-1])
line  577:  IR Inten    --   195.2349               105.4141                99.7388
line  591:  IR Inten    --    17.5360                47.6775               105.1116
  • 极化率,维度为 \((3, 3)\),单位为 a.u.:

[8]:
fchk.polarizability()
[8]:
array([[ 6.58142, -0.0841 , -1.45378],
       [-0.0841 ,  4.26836,  0.39969],
       [-1.45378,  0.39969, 17.89033]])

上面这五个导出量中,

  • Hessian 矩阵、分子频率是能量的二阶核坐标导数量的导出结果;

  • 偶极矩的核坐标导数、红外光谱强度是能量的一阶核坐标与一阶电场到数量的导出结果;

  • 极化率是能量的二阶核坐标导数量的导出结果。

我们将会分为三篇文档来介绍这三种类型的导出量。这篇文档,我们会具体地给出 Hessian 矩阵的计算,并且借助外部程序计算分子频率。

PySCF 计算 RHF Hessian 矩阵

我们首先定义自洽场计算实例 scf_eng。由于非对称双氧水分子在测评和文档中都经常使用,我们可以很方便地活用 Mol_H2O2 的代码生成 RHF 类 scf.RHF 实例:

[9]:
molh = Mol_H2O2()
mol = molh.mol
scf_eng = molh.hf_eng.run()
scf_eng.e_tot
[9]:
-150.58503378083688

为了后文的便利,我们补充定义原子数 natm \(n_\mathrm{atom}\) 与 Hessian 作为矩阵时的大小 dhess \(3 n_\mathrm{atom}\)

[10]:
natm = mol.natm
dhess = natm * 3

PySCF 中,Hessian 的计算可以通过如下代码实现:

[11]:
scf_hess = hessian.RHF(scf_eng).run()

Hessian 储存在 de 变量中,其维度并非是我们常用的 \((A, t, B, s)\)\((4, 3, 4, 3)\) 的大小,而是 \((A, B, t, s)\)\((4, 4, 3, 3)\) 的大小:

[12]:
scf_hess.de.shape
[12]:
(4, 4, 3, 3)

如果我们要将 PySCF 的 Hessian 能与 Gaussian 的核对是否一致,我们需要将 Hessian 张量的中间两个维度转置:

[13]:
scf_hess.de.swapaxes(1, 2).reshape(dhess, dhess)
[13]:
array([[ 0.36765, -0.01096, -0.02986, -0.02036,  0.0064 ,  0.03848, -0.4029 ,  0.00214, -0.02579,  0.0556 ,  0.00242,  0.01717],
       [-0.01096,  0.02901,  0.11159,  0.0047 ,  0.08453, -0.11579,  0.00851, -0.03718,  0.0048 , -0.00226, -0.07637, -0.0006 ],
       [-0.02986,  0.11159,  0.47024, -0.00243,  0.00961, -0.33099,  0.02687,  0.0038 , -0.03383,  0.00542, -0.125  , -0.10542],
       [-0.02036,  0.0047 , -0.00243, -0.07793, -0.00283, -0.04145, -0.00102, -0.0056 ,  0.04462,  0.09931,  0.00372, -0.00074],
       [ 0.0064 ,  0.08453,  0.00961, -0.00283,  0.66306, -0.43734,  0.00034,  0.00409, -0.00816, -0.00392, -0.75168,  0.43588],
       [ 0.03848, -0.11579, -0.33099, -0.04145, -0.43734,  0.426  , -0.00318,  0.00431, -0.04919,  0.00616,  0.54882, -0.04582],
       [-0.4029 ,  0.00851,  0.02687, -0.00102,  0.00034, -0.00318,  0.41067, -0.01219, -0.02918, -0.00675,  0.00333,  0.00549],
       [ 0.00214, -0.03718,  0.0038 , -0.0056 ,  0.00409,  0.00431, -0.01219,  0.02907,  0.00724,  0.01565,  0.00402, -0.01535],
       [-0.02579,  0.0048 , -0.03383,  0.04462, -0.00816, -0.04919, -0.02918,  0.00724,  0.0954 ,  0.01035, -0.00389, -0.01238],
       [ 0.0556 , -0.00226,  0.00542,  0.09931, -0.00392,  0.00616, -0.00675,  0.01565,  0.01035, -0.14815, -0.00947, -0.02193],
       [ 0.00242, -0.07637, -0.125  ,  0.00372, -0.75168,  0.54882,  0.00333,  0.00402, -0.00389, -0.00947,  0.82402, -0.41993],
       [ 0.01717, -0.0006 , -0.10542, -0.00074,  0.43588, -0.04582,  0.00549, -0.01535, -0.01238, -0.02193, -0.41993,  0.16362]])

上述矩阵是对称矩阵了,我们可以看看它是否与 Gaussian 的结果吻合:

[14]:
np.allclose(scf_hess.de.swapaxes(1, 2).reshape(dhess, dhess), fchk.hessian())
[14]:
False

看似是不吻合的。但如果我们稍稍放低一些判断标准,将绝对值误差 atol 容忍到 \(10^{-6}\),或相对值误差容忍到 \(10^{-4}\),就能认为 PySCF 的计算结果与 Gaussian 接近了。

[15]:
np.allclose(scf_hess.de.swapaxes(1, 2).reshape(dhess, dhess), fchk.hessian(), atol=1e-6, rtol=1e-4)
[15]:
True

我们以后一般也沿用上述的评判标准,判断两矩阵或张量是否相等。

pyxdh 计算 RHF Hessian 矩阵

pyxdh 也提供 RHF 的 Hessian 计算。我们要首先给出其梯度辅助类 GradSCF 的实例 grdh

[16]:
config = {"scf_eng": scf_eng}
grdh = GradSCF(config)
grdh.E_1
[16]:
array([[-0.06727,  0.06951,  0.0961 ],
       [ 0.01291,  0.14195, -0.11756],
       [ 0.03423,  0.01409,  0.03949],
       [ 0.02013, -0.22555, -0.01803]])

随后通过上述的实例 grdh 构建 Hessian 辅助类 HessSCF 的实例 hessh

[17]:
config = {"deriv_A": grdh, "deriv_B": grdh}
hessh = HessSCF(config)
hessh.E_2
[17]:
array([[ 0.36765, -0.01096, -0.02986, -0.02036,  0.0064 ,  0.03848, -0.4029 ,  0.00214, -0.02579,  0.0556 ,  0.00242,  0.01717],
       [-0.01096,  0.02901,  0.11159,  0.0047 ,  0.08453, -0.11579,  0.00851, -0.03718,  0.0048 , -0.00226, -0.07637, -0.0006 ],
       [-0.02986,  0.11159,  0.47024, -0.00243,  0.00961, -0.33099,  0.02687,  0.0038 , -0.03383,  0.00542, -0.125  , -0.10542],
       [-0.02036,  0.0047 , -0.00243, -0.07793, -0.00283, -0.04145, -0.00102, -0.0056 ,  0.04462,  0.09931,  0.00372, -0.00074],
       [ 0.0064 ,  0.08453,  0.00961, -0.00283,  0.66306, -0.43734,  0.00034,  0.00409, -0.00816, -0.00392, -0.75168,  0.43588],
       [ 0.03848, -0.11579, -0.33099, -0.04145, -0.43734,  0.426  , -0.00318,  0.00431, -0.04919,  0.00616,  0.54882, -0.04582],
       [-0.4029 ,  0.00851,  0.02687, -0.00102,  0.00034, -0.00318,  0.41067, -0.01219, -0.02918, -0.00675,  0.00333,  0.00549],
       [ 0.00214, -0.03718,  0.0038 , -0.0056 ,  0.00409,  0.00431, -0.01219,  0.02907,  0.00724,  0.01565,  0.00402, -0.01535],
       [-0.02579,  0.0048 , -0.03383,  0.04462, -0.00816, -0.04919, -0.02918,  0.00724,  0.0954 ,  0.01035, -0.00389, -0.01238],
       [ 0.0556 , -0.00226,  0.00542,  0.09931, -0.00392,  0.00616, -0.00675,  0.01565,  0.01035, -0.14815, -0.00947, -0.02193],
       [ 0.00242, -0.07637, -0.125  ,  0.00372, -0.75168,  0.54882,  0.00333,  0.00402, -0.00389, -0.00947,  0.82402, -0.41993],
       [ 0.01717, -0.0006 , -0.10542, -0.00074,  0.43588, -0.04582,  0.00549, -0.01535, -0.01238, -0.02193, -0.41993,  0.16362]])

我们可以验证上述 Hessian 矩阵是否与 Gaussian 相等:

[18]:
np.allclose(hessh.E_2, fchk.hessian(), atol=1e-6, rtol=1e-4)
[18]:
True

数值导数求取 Hessian

Hessian 矩阵中单个值的计算

事实上,Hessian 就是能量值的二阶导数构成的矩阵。我们拿第 1 个氧原子的 \(z\) 轴分量、与第 1 个氢原子的 \(x\) 轴分量的 Hessian 矩阵值来举例:

[19]:
fchk.hessian()[2, 6]
[19]:
0.0268686422

之所以索引是 \((2, 6)\),是因为第一个氧原子占用索引 0, 1, 2,其 \(z\) 轴分量则是索引 2;而第 1 个氢原子占用索引 6, 7, 8,其 \(x\) 轴分量则是索引 6。

我们指出,Hessian 矩阵具有对称性,即 \(E^{A_t B_s} = E^{B_s A_t}\),或者我们也能发现下述矩阵值与上面的值是一样的:

[20]:
fchk.hessian()[6, 2]
[20]:
0.0268686422

我们之前已经会三点差分的一阶导数了,事实上求取二阶导数也是相同的。我们首先定义三点差分计算中需要使用到的 \(x - h\) 的点与 \(x + h\) 的点 (分子) mol_m1, mol_p1。这里的 \(x\) 相当于分子的原始坐标,\(h\) 相当于第 1 个氢原子 \(x\) 分量求导所用的偏移量。这里采用的偏移量 (逼近参数) 是 \(10^{-4}\),单位 Bohr。

[21]:
def gen_H2O2(coord):
    """
    Generate H2O2 molecule (with basis 6-31G)
    """
    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()
    mol.set_geom_(coord * lib.param.BOHR)
    return mol.build()
[22]:
coord_orig = mol.atom_coords()
coord_m1 = coord_orig.copy()
coord_m1[2, 0] -= 1e-4
coord_p1 = coord_orig.copy()
coord_p1[2, 0] += 1e-4
[23]:
mol_m1 = gen_H2O2(coord_m1)
mol_p1 = gen_H2O2(coord_p1)

随后,我们可以对上述用于三点差分的分子计算其分子力 \(E^{A_t}\)

[24]:
grad_m1 = scf.RHF(mol_m1).run().nuc_grad_method().run().de
grad_p1 = scf.RHF(mol_p1).run().nuc_grad_method().run().de

我们对上述分子力的第 1 个氧原子 (索引 0) \(z\) 坐标分量 (索引 2) 的值作三点差分导数计算 (相当于 \(E^{A_t B_s} = \frac{\partial E_{A_t}}{\partial B_s}\)):

[25]:
(grad_p1[0, 2] - grad_m1[0, 2]) / (2e-4)
[25]:
0.02686858719513907

我们就会发现,上述的值与 Hessian 矩阵中对应的值是相等的:

[26]:
fchk.hessian()[2, 6]
[26]:
0.0268686422

事实上,我们也可以对分子力的所有值作三点差分:

[27]:
(grad_p1 - grad_m1).flatten() / (2e-4)
[27]:
array([-0.4029 ,  0.00851,  0.02687, -0.00102,  0.00034, -0.00318,  0.41067, -0.01218, -0.02918, -0.00675,  0.00333,  0.00549])

这其实与 Hessian 关于第 1 个氢原子的 \(x\) 轴导数部分完全一致:

[28]:
fchk.hessian()[:, 6]
[28]:
array([-0.4029 ,  0.00851,  0.02687, -0.00102,  0.00034, -0.00318,  0.41067, -0.01219, -0.02918, -0.00675,  0.00333,  0.00549])

我们对其中一个坐标分量作数值导数,就可以得到 Hessian 矩阵的一行。很容易想到,如果我们对所有分子坐标分量作导数,那么完整的 Hessian 矩阵就能获得了。至此,我们就描述好了数值导数计算 Hessian 的原理。

pyxdh 数值梯度助手计算 Hessian

我们以前介绍过,使用 pyxdh 的 NucCoordDerivGenerator 类进行能量的核坐标导数 \(\frac{\partial E}{\partial A_t}\);事实上,这个类原则上可以帮助实现任意维度张量的导数,譬如我们现在需要计算 \(\frac{\partial E^{A_t}}{\partial B_s}\)

相对于之前的文档,这里在生成 NucCoordDerivGenerator 实例时,lambda 函数输入仍然是分子实例,但将 lambda 的输出更改为 pyxdh.grad.RHF 类型作为计算实例。相应的, NumericDiff 实例的 lambda 函数也要更改成输入 pyxdh.grad.RHF 类型,输出分子的梯度矢量。

[29]:
generator = NucCoordDerivGenerator(mol, lambda mol_: scf.RHF(mol_).run().nuc_grad_method().run())
diff = NumericDiff(generator, lambda mf: mf.de.flatten())

最后,我们求取梯度,就得到 Hessian 矩阵:

[30]:
diff.derivative
[30]:
array([[ 0.36765, -0.01096, -0.02986, -0.02036,  0.0064 ,  0.03848, -0.4029 ,  0.00214, -0.02579,  0.0556 ,  0.00242,  0.01717],
       [-0.01096,  0.02901,  0.11159,  0.0047 ,  0.08453, -0.11579,  0.00851, -0.03718,  0.0048 , -0.00226, -0.07637, -0.0006 ],
       [-0.02985,  0.11158,  0.47024, -0.00244,  0.00962, -0.33099,  0.02687,  0.0038 , -0.03383,  0.00542, -0.125  , -0.10542],
       [-0.02036,  0.0047 , -0.00243, -0.07793, -0.00283, -0.04145, -0.00102, -0.0056 ,  0.04462,  0.09931,  0.00372, -0.00074],
       [ 0.0064 ,  0.08453,  0.00961, -0.00282,  0.66305, -0.43734,  0.00034,  0.00409, -0.00816, -0.00391, -0.75168,  0.43588],
       [ 0.03848, -0.11578, -0.33099, -0.04145, -0.43735,  0.42601, -0.00318,  0.00431, -0.04919,  0.00616,  0.54882, -0.04582],
       [-0.4029 ,  0.00851,  0.02687, -0.00102,  0.00034, -0.00318,  0.41067, -0.01219, -0.02918, -0.00675,  0.00333,  0.00549],
       [ 0.00214, -0.03718,  0.0038 , -0.0056 ,  0.00409,  0.00431, -0.01218,  0.02907,  0.00724,  0.01565,  0.00402, -0.01535],
       [-0.02579,  0.0048 , -0.03383,  0.04462, -0.00816, -0.04919, -0.02918,  0.00724,  0.0954 ,  0.01035, -0.00389, -0.01238],
       [ 0.0556 , -0.00226,  0.00542,  0.09931, -0.00392,  0.00616, -0.00675,  0.01565,  0.01035, -0.14815, -0.00947, -0.02193],
       [ 0.00242, -0.07637, -0.125  ,  0.00372, -0.75168,  0.54882,  0.00333,  0.00402, -0.00389, -0.00948,  0.82402, -0.41993],
       [ 0.01717, -0.0006 , -0.10542, -0.00073,  0.43588, -0.04582,  0.00549, -0.01535, -0.01238, -0.02193, -0.41993,  0.16362]])

上面的数值梯度与 Gaussian 结果出入稍大 (这可能与收敛判标有关);我们再降低一些判定条件,将绝对值条件降为 \(10^{-5}\),则可以判定数值梯度与 Gaussian 梯度接近等同:

[31]:
np.allclose(diff.derivative, fchk.hessian(), atol=1e-5, rtol=1e-4)
[31]:
True

通过数值导数计算分子振动频率

我们之前提及,Hessian 的计算的一个很重要的意义是计算分子的振动频率。在这里,我们就不详细讨论如何计算频率。

我们引入一个自编的 Python 脚本 freqanal.py,该脚本能帮助我们进行频率计算:

[32]:
from freqanal import FreqAnal

其输入参量是原子质量列表 (单位 AMU)、分子坐标 (单位 Bohr)、以及 Hessian (单位 a.u.)。为了与 Gaussian 的分子频率作核对,我们需要额外定义较为精确的原子质量。

[33]:
mol_weight = np.array([15.99491, 15.99491, 1.00783, 1.00783])
freqanal = FreqAnal(mol_weight=mol_weight, mol_coord=mol.atom_coords(), hessian=diff.derivative)
freqanal.freq
[33]:
array([-1580.60283, -1218.3735 ,  1370.61697,  1647.5344 ,  3389.85998,  5347.88146])

我们可以将其与 Gaussian 输出的频率值作核对:

[34]:
with open("assets/H2O2-freq.out", "r") as f:
    for idx, line in enumerate(f.readlines()):
        if "Frequencies" in line:
            print("line {:4d}:".format(idx + 1), line[:-1])
line  574:  Frequencies -- -1580.6089             -1218.3809              1370.6206
line  588:  Frequencies --  1647.5426              3389.8666              5347.9015

RHF 极化率

上一节,我们已经讨论过了 RHF 的核坐标二阶导数 (即 Hessian) 的计算。这一节,我们简单描述电场强度的二阶导数,即极化率。我们不打算对该物理量作其物理意义的描述。

事实上,Hessian 在数学中就相当于对向量作二阶导数;但在计算化学中,它通常特指对原子核坐标的二阶导数,因此我们不称极化率矩阵为 Hessian 矩阵。

[1]:
from pyscf import gto, scf, lib, prop
import numpy as np
from pyxdh.Utilities import FormchkInterface, DipoleDerivGenerator, NumericDiff
from pyxdh.Utilities.test_molecules import Mol_H2O2
from pyxdh.DerivOnce import DipoleSCF
from pyxdh.DerivTwice import PolarSCF
import warnings

warnings.filterwarnings("ignore")
np.set_printoptions(5, linewidth=150, suppress=True)

量化软件计算 RHF 极化率

Gaussian 极化率

在上一篇文档,我们已经给出从 Gaussian 频率分析的程序输出得到极化率的过程。尽管极化率严格来说不是频率分析的结果,但一般来说,从频率分析过程中的矩阵导出极化率是不太消耗计算量的;因此,Gaussian 会在频率分析过程中给出极化率。

[2]:
fchk = FormchkInterface("assets/H2O2-freq.fch")
fchk.polarizability()
[2]:
array([[ 6.58142, -0.0841 , -1.45378],
       [-0.0841 ,  4.26836,  0.39969],
       [-1.45378,  0.39969, 17.89033]])

可以见到,极化率是与分子大小无关的 \((3, 3)\) 维度的矩阵。极化率一般写为 \(\alpha_{ts}\),其中 \(\alpha\) 是极化率量的表示符号,下标 \(t, s\) 都表示三维电子坐标的分量。

PySCF 极化率

PySCF 可以通过其 prop 库进行计算;它需要一个 scf.RHF 的计算实例 scf_eng,用于生成计算极化率的计算实例 scf_polar

[3]:
molh = Mol_H2O2()
mol = molh.mol
scf_eng = molh.hf_eng.run()
scf_polar = prop.polarizability.rhf.Polarizability(scf_eng).run()

极化率的结果可以用 polarizability 成员函数得到。

[4]:
scf_polar.polarizability()
[4]:
array([[ 6.58142, -0.0841 , -1.45378],
       [-0.0841 ,  4.26835,  0.39969],
       [-1.45378,  0.39969, 17.89033]])

可见 Gaussian 与 PySCF 的结果非常相近:

[5]:
np.allclose(scf_polar.polarizability(), fchk.polarizability())
[5]:
True

pyxdh 极化率

pyxdh 也提供了计算 RHF 的函数。其调用方式比较类似于上一篇文档提到的 HessSCF 的使用方法。首先,我们需要先定义 DipoleSCF 的实例 diph 计算偶极矩:

[6]:
diph = DipoleSCF({"scf_eng": scf_eng})
diph.E_1
[6]:
array([ 0.88992,  0.66299, -0.29469])

随后,我们将 diph 代入到 PolarSCF 的实例化过程中,得到极化率实例 polh

[7]:
polh = PolarSCF({"deriv_A": diph, "deriv_B": diph})
- polh.E_2
[7]:
array([[ 6.58142, -0.0841 , -1.45379],
       [-0.0841 ,  4.26835,  0.39969],
       [-1.45379,  0.39969, 17.89032]])

需要注意,上述的 E_2 property 调用后还需要乘以 -1,才能得到极化率结果。我们拿该结果与 Gaussian 核对:

[8]:
np.allclose(- polh.E_2, fchk.polarizability())
[8]:
True

数值导数得到极化率

三点差分得到极化率

现在我们假设需要得到极化率的第 1 行 \(\alpha_{ty}\);那么,我们对 Hamiltonian Core 作如下的变化:

\[\hat h (F) = \hat t + \hat v_\mathrm{nuc} + F y\]

其中,\(F\) 为微扰的外场强度。这个微扰外场就相当于三点差分过程中 \(x - h\)\(x + h\) 的逼近参数 \(h\)。上式的 \(y\) 表示的是我们外加电场的分量取向;由于我们是求其中一个取向为 \(y\) 方向的极化率,那么我们对解析偶极矩的 \(y\) 方向作数值求导即可。作为逼近参数的微扰外加电场大小是 \(10^{-4}\),单位为原子单位。

[9]:
def mf_func(t, f):
    mf = scf.RHF(mol)
    mf.conv_tol = 1e-10
    mf.get_hcore = lambda mol_: scf.rhf.get_hcore(mol_) - f * mol_.intor("int1e_r")[t]
    return mf.run()
[10]:
scf_eng_p1 = mf_func(1,  1e-4)
scf_eng_m1 = mf_func(1, -1e-4)
- (scf_eng_p1.dip_moment(unit="A.U.") - scf_eng_m1.dip_moment(unit="A.U.")) / 2e-4
Dipole moment(X, Y, Z, A.U.):  0.88992,  0.66256, -0.29473
Dipole moment(X, Y, Z, A.U.):  0.88991,  0.66342, -0.29465
[10]:
array([-0.0841 ,  4.26836,  0.39967])

上面的程序使用了一次负号;这个负号是因为两次负电荷的的负号累加导致,因此需要消去一个负号。

我们已经通过三点差分得到了极化率中关于 \(y\) 取向的一行了;那么剩下两个取向也会是非常容易获得的了。

pyxdh 数值求导

pyxdh 的数值求导机制就是上述过程,即需要一个生成更变了 Hamiltonian Core 的计算实例 mf_func 以实例化 DipoleDerivGenerator,随后在 NumDiff 中对偶极矩作三点差分:

[11]:
generator = DipoleDerivGenerator(mf_func, interval=1e-6)
diff = NumericDiff(generator, lambda mf: mf.dip_moment(unit="A.U.", verbose=0))

那么,极化率的值就可以导出如下:

[12]:
- diff.derivative
[12]:
array([[ 6.58142, -0.0841 , -1.45377],
       [-0.0841 ,  4.26836,  0.39967],
       [-1.45379,  0.39968, 17.89025]])

我们可以验证上述极化率是否与 Gaussian 相等:

[13]:
np.allclose(- diff.derivative, fchk.polarizability(), atol=1e-6, rtol=1e-4)
[13]:
True

RHF 偶极矩的核坐标导数

在这一章的最后,我们讨论一个相对复杂一些的导数,即偶极矩的核坐标导数 \(d_s^{A_t} = \frac{\partial d_s}{\partial A_t}\)。这个量结合频率分析,可以得到红外光谱;我们在这里会使用一个小程序绘制光谱图而不详细展开。

[1]:
%matplotlib notebook

from pyscf import gto, scf, lib, prop
import numpy as np
from matplotlib import pyplot as plt
from pyxdh.Utilities import FormchkInterface, NucCoordDerivGenerator, NumericDiff
from pyxdh.Utilities.test_molecules import Mol_H2O2
from pyxdh.DerivOnce import DipoleSCF, GradSCF
from pyxdh.DerivTwice import DipDerivSCF
import warnings

warnings.filterwarnings("ignore")
np.set_printoptions(5, linewidth=150, suppress=True)

量化软件计算 RHF 偶极矩的核坐标导数

Gaussian 偶极矩的核坐标导数

我们在 Hessian 的文档中已经展示 Gaussian 的偶极矩核坐标导数的求取了。这里展示一下结果:

[2]:
fchk = FormchkInterface("assets/H2O2-freq.fch")
fchk.dipolederiv()
[2]:
array([[-0.2343 ,  0.01785,  0.16617],
       [ 0.07423, -0.4948 ,  0.00587],
       [ 0.04888, -0.08356, -0.50397],
       [-0.41785,  0.01883, -0.16946],
       [ 0.00548, -0.32846, -0.15515],
       [ 0.02287,  0.03304, -0.09475],
       [ 0.21503,  0.00182, -0.03122],
       [-0.04486,  0.44397,  0.02416],
       [-0.03371, -0.01025,  0.25124],
       [ 0.43712, -0.0385 ,  0.03451],
       [-0.03485,  0.37929,  0.12513],
       [-0.03804,  0.06077,  0.34748]])

同时,我们也展示一下 Gaussian 计算得到的红外光谱强度 (单位为 km/mol):

[3]:
with open("assets/H2O2-freq.out", "r") as f:
    for idx, line in enumerate(f.readlines()):
        if "IR Inten" in line:
            print("line {:4d}:".format(idx + 1), line[:-1])
line  577:  IR Inten    --   195.2349               105.4141                99.7388
line  591:  IR Inten    --    17.5360                47.6775               105.1116

pyxdh 偶极矩的核坐标导数

[4]:
molh = Mol_H2O2()
mol = molh.mol
scf_eng = molh.hf_eng
[5]:
diph = DipoleSCF({"scf_eng": scf_eng, "cphf_tol": 1e-10})
gradh = GradSCF({"scf_eng": scf_eng, "cphf_tol": 1e-10})
dipdh = DipDerivSCF({"deriv_A": diph, "deriv_B": gradh})
dipdh.E_2
[5]:
array([[-0.2343 ,  0.07423,  0.04889, -0.41784,  0.00548,  0.02287,  0.21502, -0.04486, -0.03371,  0.43712, -0.03485, -0.03804],
       [ 0.01785, -0.4948 , -0.08356,  0.01883, -0.32846,  0.03304,  0.00182,  0.44397, -0.01025, -0.0385 ,  0.37929,  0.06077],
       [ 0.16618,  0.00587, -0.50398, -0.16946, -0.15515, -0.09475, -0.03122,  0.02416,  0.25124,  0.03451,  0.12513,  0.34748]])

上述的矩阵维度与 Gaussian 输出恰好是转置;在稍低的判定条件下,可以基本判断两矩阵近乎相等:

[6]:
np.allclose(fchk.dipolederiv(), dipdh.E_2.T, atol=1e-5, rtol=1e-4)
[6]:
True

偶极矩的核坐标导数的数值导数

三点差分数值导数

我们在之前的文档中,已经对原子和坐标与电场的三点差分进行过计算。这里的计算也大同小异,我们就不作更详细的说明了。但我们需要指出,由于带电场的分子梯度并不是非常容易求取的,因此我们使用数值梯度求导的策略是求出偶极矩后,对原子核坐标作数值导数。

譬如说,我们对第一个氧原子的 \(x\) 坐标作导数求取。我们移动该坐标以产生两个分子 mol_m1, mol_p1 作核坐标导数的三点差分。

[7]:
def gen_H2O2(coord):
    """
    Generate H2O2 molecule (with basis 6-31G)
    """
    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()
    mol.set_geom_(coord * lib.param.BOHR)
    return mol.build()
[8]:
coord_orig = mol.atom_coords()
coord_m1 = coord_orig.copy()
coord_m1[0, 0] -= 1e-4
coord_p1 = coord_orig.copy()
coord_p1[0, 0] += 1e-4
mol_m1 = gen_H2O2(coord_m1)
mol_p1 = gen_H2O2(coord_p1)

随后我们求取这两个分子的偶极矩,作三点差分:

[9]:
dip_m1 = scf.RHF(mol_m1).run().dip_moment(unit="A.U.", verbose=0)
dip_p1 = scf.RHF(mol_p1).run().dip_moment(unit="A.U.", verbose=0)
(dip_p1 - dip_m1) / 2e-4
[9]:
array([-0.2343 ,  0.01785,  0.16617])

我们会发现,上述导数的值与 Gaussian 输出的值相等:

[10]:
fchk.dipolederiv()[0]
[10]:
array([-0.2343 ,  0.01785,  0.16617])

pyxdh 数值导数

我们的被求导量既然是原子核坐标,那么我们的实例化的类是 NucCoordDerivGenerator,而非 DipoleDerivGenerator。我们使用与求取 Hessian 时相似的生成方式;但计算实例可以是 scf.RHF 类而非 grad.RHF 类,因为 scf.RHF 类可以直接导出作为被求导量的偶极矩:

[11]:
generator = NucCoordDerivGenerator(mol, lambda mol_: scf.RHF(mol_).run())
diff = NumericDiff(generator, lambda mf: mf.dip_moment(unit="A.U.", verbose=0))

使用 derivative property,就可以求出该分子的数值偶极矩的核坐标导数:

[12]:
diff.derivative
[12]:
array([[-0.2343 ,  0.01785,  0.16617],
       [ 0.07423, -0.4948 ,  0.00587],
       [ 0.04889, -0.08356, -0.50398],
       [-0.41785,  0.01883, -0.16946],
       [ 0.00548, -0.32846, -0.15516],
       [ 0.02286,  0.03304, -0.09474],
       [ 0.21502,  0.00182, -0.03122],
       [-0.04486,  0.44397,  0.02416],
       [-0.03371, -0.01025,  0.25124],
       [ 0.43712, -0.0385 ,  0.03451],
       [-0.03485,  0.37929,  0.12514],
       [-0.03804,  0.06077,  0.34748]])

我们可以将其与 Gaussian 的输出结果作对照:

[13]:
np.allclose(diff.derivative, fchk.dipolederiv(), atol=1e-5, rtol=1e-4)
[13]:
True

红外光谱绘制

提醒

我们讨论的双氧水分子并非处于稳定构象,因此我们所得的分子频率和红外光谱并非是有物理意义的。后文所给的计算过程仅仅是演示而已。通过频率分析得到的红外光谱一般被认为处在稳定结构时才有讨论的意义。

我们最后陈述一下偶极矩的原子核坐标导数可以用于模拟红外光谱。下面的程序仍然借用了外部的 freqanal.py

[14]:
from freqanal import FreqAnal, FactIR

我们首先需要通过 Hessian 矩阵得到频率信息 (单位 1/cm):

[15]:
mol_weight = np.array([15.99491, 15.99491, 1.00783, 1.00783])
frq = FreqAnal(mol_weight=mol_weight, mol_coord=mol.atom_coords(), hessian=fchk.hessian())
frq.freq
[15]:
array([-1580.60525, -1218.37861,  1370.61982,  1647.53904,  3389.8587 ,  5347.88911])

随后,通过分子振动的简正坐标与偶极的核坐标导数作内积 (即求出分子在简正运动下,偶极矩的瞬时变化大小),可以给出每个分子振动下的红外积分强度 (单位 km/mol):

[16]:
dipderiv_mode = np.einsum("Ar, Aq -> qr", diff.derivative, frq.q) * FactIR
ir_intensities = (dipderiv_mode ** 2).sum(axis=1)
ir_intensities
[16]:
array([195.23468, 105.41248,  99.73978,  17.53655,  47.67743, 105.11221])

上面的向量值与 Gaussian 输出的红外光谱大小近乎相等:

[17]:
with open("assets/H2O2-freq.out", "r") as f:
    for idx, line in enumerate(f.readlines()):
        if "IR Inten" in line:
            print("line {:4d}:".format(idx + 1), line[:-1])
line  577:  IR Inten    --   195.2349               105.4141                99.7388
line  591:  IR Inten    --    17.5360                47.6775               105.1116

上面是积分的红外光谱强度;模拟红外光谱时,还需要指定半峰宽峰的展宽程度。在这里,我们假定半峰宽都是 \(50 \, \mathsf{cm}^{-1}\);并且展宽模式是 Lorentzian 型。那么,红外光谱图就绘制如下:

[18]:
def lorentzian_freq(omega, omega_n, gamma):
    return 100 * 0.5 / np.pi * gamma / ((omega - omega_n)**2 + 0.25 * gamma**2)

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
[19]:
fig, ax = plt.subplots(figsize=(6, 4))
ax.grid()

x = np.arange(0, 4000, 1)
ax.plot(x, ir_plot(x, 50, frq.freq, ir_intensities), label="Molar Absorption Coefficient")
ax.set_ylabel("Molar Absorption Coefficient (L mol$^{-1}$ cm$^{-1}$)")
ax.set_xlabel("Vibration Wavenumber (cm$^{-1}$)")
ax.set_title("H$_2$O$_2$ (not optimized) Infrared Spectrum (RHF/6-31G)")
ax.set_xlim(0, 4000)
ax.legend(loc="upper left")

ax2 = ax.twinx()
for i in range(ir_intensities.size):
    if i == 0:
        ax2.plot([frq.freq[i], frq.freq[i]], [0, ir_intensities[i]], c="C2", linewidth=1, label="IR Intensity")
    else:
        ax2.plot([frq.freq[i], frq.freq[i]], [0, ir_intensities[i]], c="C2", linewidth=1)
ax2.set_ylabel("IR Intensity (km mol$^{-1}$)")
ax2.legend(loc="upper right")
fig.tight_layout()

一阶梯度与性质:序

这一部分中,我们会讨论从自洽场到 XYG3 型泛函一阶梯度的性质的实现,包括核坐标梯度与偶极矩计算。在这一章的最后,我们会以 XYG3 型原子的电子云密度计算作为练习。

背景资料

参考文献

从这一节开始,我们就开始讨论如何求取分子能量的解析梯度,也是真正理解 pyxdh 项目的核心内容了。在此之前,我们先需要了解下述的几篇文献。在使用这些文献时,会以首作者名来代表这篇文章或书籍。

  • Yamaguchi

    这篇书籍会是一篇计算梯度量的导引书籍。所有 RHF 方法的梯度计算都可以从这篇书籍找到。我们的大部分程序都基于这本书在 RHF 梯度的讨论上。

    Yamaguchi, Y.; Goddard, J. D.; Osamura, Y. & Schaefer, H.

    A New Dimension to Quantum Chemistry: Analytic Derivative Methods in Ab Initio Molecular Electronic Structure Theory (International Series of Monographs on Chemistry)

    Oxford University Press, 1994

  • Szabo

    这篇书籍是基础的量化程序书籍。这本书的内容通常不在讨论范围之内,因为它几乎不讨论能量梯度性质;但作为基础书,我们以后有可能会引用其公式。

    Szabo, A. & Ostlund, N. S.

    Modern Quantum Chemistry: Introduction to Advanced Electronic Structure Theory (Dover Books on Chemistry)

    Dover Publications, 1996

  • Aikens

    这篇文档讨论 MP2 的一阶梯度实现。

    Aikens, C. M.; Webb, S. P.; Bell, R. L.; Fletcher, G. D.; Schmidt, M. W. & Gordon, M. S.

    A derivation of the frozen-orbital unrestricted open-shell and restricted closed-shell second-order perturbation theory analytic gradient expressions

    Theor. Chem. Acc. 2003, 110, 233-253

    doi: 10.1007/s00214-003-0453-3

  • Su

    这篇文档讨论 XYG3 型泛函的一阶梯度实现。

    Su, N. Q.; Zhang, I. Y. & Xu, X.

    Analytic derivatives for the XYG3 type of doubly hybrid density functionals: Theory, implementation, and assessment

    J. Comput. Chem. 2013, 34, 1759-1774

    doi: 10.1002/jcc.23312

  • Cammi

    这篇文档讨论 MP2 的二阶梯度实现。

    Cammi, R.; Mennucci, B.; Pomelli, C.; Cappelli, C.; Corni, S.; Frediani, L.; Trucks, G. W. & Frisch, M. J.

    Second-order Møller–Plesset second derivatives for the polarizable continuum model: theoretical bases and application to solvent effects in electrophilic bromination of ethylene

    Theor. Chem. Acc. 2004, 111, 66-77

    doi: 10.1007/s00214-003-0521-8

  • Handy

    这篇文档讨论 MP2 二阶梯度中涉及到的 U 矩阵奇点项的处理。关于奇点项,我们以后会花精力讨论这个问题。

    Handy, N.; Amos, R.; Gaw, J.; Rice, J. & Simandiras, E.

    The elimination of singularities in derivative calculations

    Chem. Phys. Lett. 1985, 120, 151-158

    doi: 10.1016/0009-2614(85)87031-7

矩阵乘法的全导数:链式法则

在讨论量化问题之前,我们先了解矩阵乘法的全导数是如何给出的。这一段的符号是纯数学的,即不包含任何物理意义。

[1]:
import numpy as np

譬如,我们现在有矩阵 \(A_{ij}\)\(B_{jk}\) 相乘:

\[C_{ik} = A_{ij} B_{jk}\]

或者用粗体表示矩阵:

\[\mathbf{C} = \mathbf{A} \mathbf{B}\]

同时,矩阵 \(A_{ij}\)\(B_{jk}\) 被参数 \(t\) 决定;譬如说,我们定义

\[\begin{split}\begin{align} A_{ij} &= (2i + j) \exp(2 t) \\ B_{jk} &= \sin(j t - 2 k) \end{align}\end{split}\]
[2]:
A = lambda t: (np.arange(2)[:, None] + np.arange(3)) * np.exp(2 * t)
A(1)
[2]:
array([[ 0.       ,  7.3890561, 14.7781122],
       [ 7.3890561, 14.7781122, 22.1671683]])
[3]:
B = lambda t: np.sin(np.arange(3)[:, None] * t - 2 * np.arange(4))
B(1)
[3]:
array([[ 0.        , -0.90929743,  0.7568025 ,  0.2794155 ],
       [ 0.84147098, -0.84147098, -0.14112001,  0.95892427],
       [ 0.90929743,  0.        , -0.90929743,  0.7568025 ]])

那么矩阵对参数 \(t\) 的导数就可以表示为

\[\begin{split}\begin{align} A_{ij}^t = \frac{\partial A_{ij}}{\partial t} = 2 (2i + j) \exp(t) \\ B_{jk}^t = \frac{\partial B_{jk}}{\partial t} = j \cos (jt - 2k) \end{align}\end{split}\]
[4]:
A_p = lambda t: 2 * (np.arange(2)[:, None] + np.arange(3)) * np.exp(2 * t)
A_p(1)
[4]:
array([[ 0.        , 14.7781122 , 29.5562244 ],
       [14.7781122 , 29.5562244 , 44.33433659]])
[5]:
B_p = lambda t: np.arange(3)[:, None] * np.cos(np.arange(3)[:, None] * t - 2 * np.arange(4))
B_p(1)
[5]:
array([[ 0.        , -0.        , -0.        ,  0.        ],
       [ 0.54030231,  0.54030231, -0.9899925 ,  0.28366219],
       [-0.83229367,  2.        , -0.83229367, -1.30728724]])

我们可以用三点差分法来判断,上述矩阵的导数是否计算正确:

[6]:
(A(1 + 1e-4) - A(1 - 1e-4)) / 2e-4
[6]:
array([[ 0.        , 14.7781123 , 29.55622459],
       [14.7781123 , 29.55622459, 44.33433689]])
[7]:
(B(1 + 1e-4) - B(1 - 1e-4)) / 2e-4
[7]:
array([[ 0.        ,  0.        ,  0.        ,  0.        ],
       [ 0.5403023 ,  0.5403023 , -0.98999249,  0.28366218],
       [-0.83229367,  1.99999999, -0.83229367, -1.30728723]])

矩阵 \(C_{ik} = A_{ij} B_{jk}\) 可以表示为

[8]:
C = lambda t: A(t) @ B(t)
C(1)
[8]:
array([[ 19.65537571,  -6.21767631, -14.48044305,  18.26965745],
       [ 32.59190172, -19.15420232, -16.64998031,  33.01187559]])

现在我们用数值导数的方法全导数 \(C_{ik}^t = \frac{\partial C_{ik}}{\partial t}\)

[9]:
(C(1 + 1e-4) - C(1 - 1e-4)) / 2e-4
[9]:
array([[ 31.00334575,  21.11319627, -48.57572542,  19.31607267],
       [ 54.71885701,  14.01058065, -66.37977464,  41.23688581]])

该全导数的求取方式是链式法则:

\[C_{ik}^t = \frac{\partial C_{ik}}{\partial t} = \frac{\partial A_{ij}}{\partial t} B_{jk} + A_{ij} \frac{\partial B_{jk}}{\partial t} = A_{ij}^t B_{jk} + A_{ij} B_{jk}^t\]
[10]:
A_p(1) @ B(1) + A(1) @ B_p(1)
[10]:
array([[ 31.00334618,  21.11319582, -48.57572548,  19.31607316],
       [ 54.71885761,  14.01058005, -66.37977474,  41.23688649]])

我们后期很少接触非常复杂的矩阵导数,譬如形如 \(\exp(\mathrm{A})\) 的导数。我们以后的工作会非常繁杂冗长;链式法则近乎于就是全部的数学基础了。尽管它很简单,但非常关键,并且真正活用链式法则其实还是有一定困难的。

Skeleton 导数与 U 导数概述

我们曾经介绍过,对分子梯度 (分子力,向量或矩阵) 的导数是 Hessian。这就是矩阵导数的一个例子。它就是这一节的讨论的矩阵全导数的一个例子。但我们曾经计算的是数值梯度。

从今之后,我们的目标是求取解析梯度。数值梯度也是重要的,但它的意义在于验证解析梯度的正确性。

显然,我们如果要求取分子梯度性质,就需要对能量或一些矩阵作全导数计算。所谓全导数,就是一般意义下的导数。但为了化简计算或对计算分项作分类,我们会引入新的用语,称为 Skeleton 导数 (原子轨道矩阵导数)。

记号说明

我们重新强调一些记号:

  • 上下标 \(A, B, M\):原子;对于双氧水,可以是两个氢、两个氧原子中的任意一个;

  • 三维向量 \(\boldsymbol{A}, \boldsymbol{B}, \boldsymbol{M}\):原子三维笛卡尔坐标;

  • 三维向量 \(\boldsymbol{r}\):电子坐标;

  • 下标 \(t, s, r, w\):三维笛卡尔坐标分量,取值范围 \(\{ x, y, z \}\)

  • 上标或标量 \(A_t, B_s\):原子坐标的坐标分量;

  • 标量 \(r\):线段长度,譬如 \(r_{AB}\) 表示原子 \(A\)\(B\) 的距离;

  • 电荷标量 \(Z_A\)\(A\) 原子的核电荷数;

  • 函数或格点 \(\phi\):作为函数的原子轨道。

一些常用下标如下:

  • 下标 \(\mu, \nu, \kappa, \lambda\) 表示原子轨道角标,在程序中用 u, v, k, l 表示;

  • 下标 \(i, j, k, l\) 表示占据分子轨道角标;

  • 下标 \(a, b, c\) 表示非占分子轨道角标;

  • 下标 \(p, q, r, s, m\) 表示任意轨道角标。

记号更变说明

记号 \(\partial_\mathbb{A}\) 代表对变量 \(\mathbb{A}\) 求偏导数,等价于 \(\frac{\partial}{\partial \mathbb{A}}\)。该符号用于行内或简化表达式。

在 pyxdh 的早期版本中将导数分为 Skeleton 与 U 导数,并且使用 \(\partial_\mathbb{A}\) 代表 Skeleton 导数,\(\partial_\mathbb{A}^\mathrm{U}\) 代表 U 导数;而 \(\frac{\partial}{\partial \mathbb{A}}\) 才是一般意义的偏导数。现在的 pyxdh 文档决定废弃这种符号,因为这种符号可能导致很多歧义。

这是对可能的以前看过该文档的读着说明的;如果读着没有看过早期的 pyxdh 文档并且不能理解上一段的意义,请无视之。

补充记号说明

  • 上角标或普通数值 \(\mathbb{A}, \mathbb{B}\):任意被求导量,可以是原子核坐标分量或电荷坐标分量。

我们拿 Hamiltonian Core 举例。我们称 Hamiltonian Core 矩阵在被求导量 \(\mathbb{A}\) 下的导数为 Skeleton 导数:

\[h_{\mu \nu}^\mathbb{A} = \frac{\partial h_{\mu \nu}}{\partial \mathbb{A}}\]

但我们也很经常处理分子轨道下的 Hamiltonian Core 矩阵。对于这类矩阵,我们定义 Skeleton 导数为

\[h_{pq}^\mathbb{A} = C_{\mu p} h_{\mu \nu}^\mathbb{A} C_{\nu q}\]

事实上,分子轨道下的 Hamiltonian Core 矩阵的全导数包含对分子轨道的导数项:

\[\frac{\partial h_{pq}}{\partial \mathbb{A}} = \frac{\partial C_{\mu p}}{\partial \mathbb{A}} h_{\mu \nu} C_{\nu q} + C_{\mu p} h_{\mu \nu}^\mathbb{A} C_{\nu q} + C_{\mu p} h_{\mu \nu} \frac{\partial C_{\nu q}}{\partial \mathbb{A}}\]

因此,Skeleton 的意义是,在一个矩阵的全导数中,去除其与分子轨道导数有关的量。我们依据下式定义 U 矩阵 \(U_{mp}^\mathbb{A}\) (Yamaguchi, p398, G.1)

\[\frac{\partial C_{\mu p}}{\partial \mathbb{A}} = C_{\mu m} U_{mp}^\mathbb{A}\]

那么,上面的分子轨道下 Hamiltonian Core 矩阵全导数可以写为

\[\frac{\partial h_{pq}}{\partial \mathbb{A}} = h_{pq}^\mathbb{A} + h_{pm} U_{mq}^\mathbb{A} + h_{mq} U_{mp}^\mathbb{A}\]

上式只有第一项是 Skeleton 导数。我们以后经常称后两项为 U 导数。

任务 (1)

证明上一个等式。

因此,Skeleton 导数也可以视作不产生 U 矩阵的导数。以后我们经常会遇到 Skeleton 导数,其符号也类似于 \(h_{pq}^\mathbb{A}\),但存在例外。因此,作者决定每次出现新的符号时都额外作一次定义。

关于 U 矩阵,其中一个相当重要的性质是:

\[S_{pq}^\mathbb{A} + U_{pq}^\mathbb{A} + U_{qp}^\mathbb{A} = 0\]

一个重要的特性是,U 矩阵一般是普通矩阵,而非通常量化中所看到的对称矩阵。因为这个特性,我们在处理 U 矩阵的对称性时需要小心。

任务 (2)

在任务 (1) 所被证明的对于 Hamiltonian Core 成立的等式,套用到重叠矩阵也同样成立。请说明上式成立。

任务参考答案

任务 (1)

我们将 \(\partial_\mathbb{A} C_{\mu p}\) 的定义式代入 \(\partial_\mathbb{A} h_{pq}\) 的导出式中,得到

\[\begin{split}\begin{align} \frac{\partial h_{pq}}{\partial \mathbb{A}} &= C_{\mu p} h_{\mu \nu}^\mathbb{A} C_{\nu q} + C_{\mu m} U_{mp}^\mathbb{A} h_{\mu \nu} C_{\nu q} + C_{\mu p} h_{\mu \nu} C_{\mu m} U_{mq}^\mathbb{A} \\ &= h_{pq}^\mathbb{A} + C_{\mu m} h_{\mu \nu} C_{\nu q} U_{mp}^\mathbb{A} + C_{\mu p} h_{\mu \nu} C_{\mu m} U_{mq}^\mathbb{A} \\ &= h_{pq}^\mathbb{A} + h_{mq} U_{mp}^\mathbb{A} + h_{pm} U_{mq}^\mathbb{A} \end{align}\end{split}\]

任务 (2)

我们知道

\[\frac{\partial S_{pq}}{\partial \mathbb{A}} = S_{pq}^\mathbb{A} + S_{pm} U_{mq}^\mathbb{A} + S_{mq} U_{mp}^\mathbb{A}\]

但同时,根据定义,

\[S_{pq} = \delta_{pq}\]

因此,分子轨道下的重叠矩阵是严格的单位矩阵,对任何物理量的导数为零,即

\[\frac{\partial S_{pq}}{\partial \mathbb{A}} = 0\]

因此,上式化为

\[0 = S_{pq}^\mathbb{A} + \delta_{pm} U_{mq}^\mathbb{A} + \delta_{mq} U_{mp}^\mathbb{A} = S_{pq}^\mathbb{A} + U_{pq}^\mathbb{A} + U_{qp}^\mathbb{A}\]

RHF 偶极矩计算

我们在曾经的文档中,已经讨论了解析 RHF 的公式表达式了;但我们尚未系统地推导偶极矩的解析梯度。这一节我们系统地推导 RHF 偶极矩,并且确立以后文档的风格。

我们之前说到,任何导数通常都可以分为 Skeleton 导数与 U 导数;但对于 RHF 的一阶梯度响应性质,U 导数可以通过巧妙的数学方式避免。因此,我们只需要学会 Skeleton 导数的求取就可以完成 RHF 的偶极矩计算。

准备工作与符号定义

我们假定以后会始终引入如下的库函数和设定:

[1]:
%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
from pyxdh.Utilities import NucCoordDerivGenerator, DipoleDerivGenerator, NumericDiff

np.einsum = partial(np.einsum, optimize=["greedy", 1024 ** 3 * 2 / 8])
np.allclose = partial(np.allclose, atol=1e-6, rtol=1e-4)
np.set_printoptions(5, linewidth=150, suppress=True)
warnings.filterwarnings("ignore")

以后的文档可能会在正文前,花大量代码进行准备工作。这些代码可能会非常相似,但有细微的变化。譬如

  • 量化方法上,有 RHF 自洽场、GGA 自洽场、GGA 非自洽、MP2 后自洽、bDH 后自洽、xDH 后自洽型;

  • 一阶梯度上,有电场导数、核坐标导数;

  • 二阶梯度上,有核坐标二阶导数、电场二阶导数、核坐标与电场混合导数。

读者可能需要注意到这些差距。

其中一部分变量是我们以后也会经常用到的、物理意义始终不变的变量:

  • mol 分子实例,类型为 gto.Mole

[2]:
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()
[2]:
<pyscf.gto.mole.Mole at 0x7f89c5c28220>
  • nao \(n_\mathrm{AO}\) 原子轨道数量 (基组数量);

  • nmo \(n_\mathrm{MO}\) 分子轨道数量,被定义为与原子轨道数量相同;

  • nocc \(n_\mathrm{occ}\) 占据轨道数量,在闭壳层中被定义为电子数的一半;

  • nvir \(n_\mathrm{vir}\) 非占轨道数量,\(n_\mathrm{MO} - n_\mathrm{occ}\)

  • so, sv, sa 分别为占据、非占、全部轨道的分割。

[3]:
nmo, nao, natm = mol.nao, mol.nao, mol.natm
nocc = mol.nelec[0]
nvir = nmo - nocc
so, sv, sa = slice(0, nocc), slice(nocc, nmo), slice(0, nmo)
  • H_0_ao \(h_{\mu \nu}\) 原子轨道 Hamiltonian Core 矩阵;

  • S_0_ao \(S_{\mu \nu}\) 原子轨道重叠矩阵;

  • eri0_ao \((\mu \nu | \kappa \lambda)\) 原子轨道 ERI 积分。

[4]:
H_0_ao = mol.intor("int1e_kin") + mol.intor("int1e_nuc")
S_0_ao = mol.intor("int1e_ovlp")
eri0_ao = mol.intor("int2e")

我们暂且先定义下述非普遍的自洽场过程 diph 是通过 DipoleSCF 类实现的:

[5]:
from pyxdh.DerivOnce import DipoleSCF
diph = DipoleSCF({"scf_eng": scf.RHF(mol)})

经过自洽场计算后的量有 (不论是 GGA 或是 RHF):

  • C \(C_{\mu p}\) 轨道系数;

  • Co \(C_{\mu i}\), Cv \(C_{\mu a}\) 分别为占据轨道系数、非占轨道系数

  • e \(\varepsilon_p\) 轨道能;

  • eo \(\varepsilon_i\), ev \(\varepsilon_a\) 分别为占据轨道能、非占轨道能;

  • D \(D_{\mu \nu}\) 原子轨道密度矩阵。

[6]:
C = diph.C
Co, Cv = C[:, so], C[:, sv]
e = diph.e
eo, ev = e[so], e[sv]
D = diph.D
  • F_0_ao \(F_{\mu \nu}\) 原子轨道 Fock 矩阵 (需要留意 Fock 矩阵与上述原子轨道矩阵不同,是与分子轨道系数相关的量);

[7]:
F_0_ao = diph.F_0_ao

下面是依靠分子轨道方能定义的量:

  • H_0_mo \(h_{pq}\) 分子轨道 Hamiltonian Core 矩阵;

  • S_0_mo \(S_{pq}\) 分子轨道重叠矩阵;

  • F_0_mo \(F_{pq}\) 分子轨道 Fock 矩阵;

  • eri0_mo \((pq | rs)\) 分子轨道 ERI 积分。

[8]:
H_0_mo = diph.H_0_mo
S_0_mo = diph.S_0_mo
F_0_mo = diph.F_0_mo
eri0_mo = diph.eri0_mo

其余的变量通常是与梯度有关的量,这些变量名可能会在以后介绍。

在继续进行文档之前,我们先定义偶极矩的数值梯度辅助类 DipoleDerivGenerator 实例 dipn

[9]:
def dipn_generator(component, interval):
    scf_eng = scf.RHF(mol)
    def get_hcore(mol=mol):
        return scf.rhf.get_hcore(mol) - interval * mol.intor("int1e_r")[component]
    scf_eng.get_hcore = get_hcore
    config = {
        "scf_eng": scf_eng,
    }
    return DipoleSCF(config)
[10]:
dipn = DipoleDerivGenerator(dipn_generator)

Hamiltonian Core Skeleton 导数

解析梯度

我们在普通的 RHF 计算中,Hamiltonian Core 是动能与原子核静电势能导出量之和:

\[\begin{split}\begin{align} \hat h = \hat t + \hat v_\mathrm{nuc} &= - \frac{1}{2} \frac{\partial^2}{\partial t^2} - \frac{Z_M}{| \boldsymbol{r} - \boldsymbol{M} |} \quad (\textsf{Einsum form}) \\ &= - \frac{1}{2} \sum_r \frac{\partial^2}{\partial t^2} - \sum_M \frac{Z_M}{| \boldsymbol{r} - \boldsymbol{M} |} \quad (\textsf{Ordinary form}) \end{align}\end{split}\]

若不作更多说明,我们以后通常用上面的作者所定义的 Einsum Form 进行公式描述 (这与正统的 Einstein Summation 近乎完全不同)。但可能会非常不直观,因此读者可能需要习惯这些记号,或者自己创立自己的公式符号体系。

我们对上面的表达式作补充说明。第一项的 \(t\) 是坐标分量,而非 \(\hat t\) 的动能算符一意。第二项的 \(M\) 表示原子;我们知道对于单个电子而言,静电势能是各个原子对其势能的和;因此对 \(M\) 求和。而 \(\hat h\) 事实上是与电子坐标 \(\boldsymbol{r}\) 有关的算符,因此不需要对 \(\boldsymbol{r}\) 进行求和。

但上述量与电场完全无关。我们提到过,偶极矩是分子在外加电场下的能量微扰表征。这个外加电场可以附加在 Hamiltonian Core 上,因此我们这篇文档讨论的 Hamiltonian Core 实际上是

\[\begin{split}\begin{align} \hat h (F_t) &= - \frac{1}{2} \frac{\partial^2}{\partial t^2} - \frac{Z_M}{| \boldsymbol{r} - \boldsymbol{M} |} - F_t \quad (\textsf{Einsum form}) \\ &= - \frac{1}{2} \sum_r \frac{\partial^2}{\partial t^2} - \sum_M \frac{Z_M}{| \boldsymbol{r} - \boldsymbol{M} |} - \sum_t F_t t \quad (\textsf{Ordinary form}) \end{align}\end{split}\]

那么,其对应的 Hamiltonian Core 矩阵为

\[h_{\mu \nu} (F_t) = \langle \mu | - \frac{1}{2} \frac{\partial^2}{\partial t^2} - \frac{Z_M}{| \boldsymbol{r} - \boldsymbol{M} |} | \nu \rangle - F_t \langle \mu | t | \nu \rangle\]

那么,

\[\frac{\partial h_{\mu \nu} (F_t)}{\partial F_t} = - \langle \mu | t | \nu \rangle = - t_{\mu \nu}\]

对于上述表达式,我们也会简写为

\[h_{\mu \nu}^t = \frac{\partial h_{\mu \nu}}{\partial F_t} = - t_{\mu \nu}\]

上述的矩阵将会是维度 \((t, \mu, \nu)\) 的张量 H_1_ao,其在 PySCF 中可以通过如下方式生成:

[11]:
H_1_ao = - mol.intor("int1e_r")
H_1_ao.shape
[11]:
(3, 22, 22)

符号 H_1_ao 相对于 H_0_ao 而言,改变的 1 代表一阶 Skeleton 导数 \(h_{\mu \nu}^\mathbb{A}\);而对于电场导数,它就特例化为 \(h_{\mu \nu}^t\)

这个符号也会用在所有以 DerivOnce 为基类的类的实例中,譬如偶极计算实例 diphH_1_ao property 就可以导出 \(h_{\mu \nu}^t\)。我们可以验证上面计算得到的 H_1_aoDipoleSCF 给出的 H_1_ao 是相等的:

[12]:
np.allclose(H_1_ao, diph.H_1_ao)
[12]:
True

数值梯度

我们可以通过 dipn 来辅助作三点差分所给出的 nd_H_0_ao \(\partial_{F_t} h_{\mu \nu}\)

[13]:
nd_H_0_ao = NumericDiff(dipn, lambda diph: diph.H_0_ao).derivative
nd_H_0_ao.shape
[13]:
(3, 22, 22)

上式中的 nd_H_0_ao 可以用来验证我们所计算的 \(h_{\mu \nu}^t\) H_1_ao;之所以起名 nd_H_0_ao,是因为它是 H_0_ao \(h_{\mu \nu}\) 的数值导数 (Numerical Derivative)。

我们以后通常下述的图像来表明方才所计算的密度矩阵梯度是正确的。

下图表示的是误差的直方图。其中,

  • 蓝色条表示数值矩阵值与解析矩阵值之间的误差,橙色条表示数值导数矩阵本身的值。

  • 横坐标表示矩阵数值大小,纵坐标表示处在某一区间的矩阵数值数量。

因此,若蓝色条越靠左,表明数值矩阵与解析矩阵在绝对误差上越小;蓝色与橙色条差距越大,表明数值矩阵与解析矩阵在相对误差上越小。

[14]:
fig, ax = plt.subplots(figsize=(4, 3))
ax.hist(abs(nd_H_0_ao - H_1_ao).ravel(), bins=np.logspace(np.log10(1e-12),np.log10(1e-2), 50), alpha=0.5)
ax.hist(abs(nd_H_0_ao).ravel(), bins=np.logspace(np.log10(1e-12),np.log10(1e-2), 50), alpha=0.5)
ax.set_xscale("log")
fig.tight_layout()

这表明,我们计算所得到的解析梯度 H_1_ao \(h_{\mu \nu}^t\) 确实地与数值梯度非常接近 nd_H_0_ao

我们以后对于每一个可以求数值梯度验证的量,都会进行上述图像的绘制。尽管在当前的例子中,解析矩阵与数值矩阵的差距非常小,以至于在 np.allclose 函数下这两个矩阵也会被认为是近似相等的:

[15]:
np.allclose(H_1_ao, nd_H_0_ao)
[15]:
True

但在以后更为广泛的情况中,解析与数值矩阵未必能通过 np.allclose 的判断;因此需要绘制上述图像来确认。一般来说,只要蓝色条 (解析与数值矩阵之差) 绝大部分元素小于 \(10^{-6}\)、且与橙色条 (数值矩阵) 能明显区分,一般就可以了;因为矩阵最后都需要经过运算才能得到最终的偶极矩或分子力,在计算的过程中这些误差多少会被消除。

分子轨道的 Skeleton 导数

我们已经介绍了原子轨道下的 Skeleton 导数;分子轨道的 Skeleton 导数可以用下式简单地给出:

\[h_{pq}^t = C_{\mu p} h_{\mu \nu}^t C_{\nu q}\]
[16]:
H_1_mo = np.einsum("up, tuv, vq -> tpq", C, H_1_ao, C)
H_1_mo.shape
[16]:
(3, 22, 22)

可以验证在 DipoleSCF 中实现的 H_1_mo property 与上述输出是相同的:

[17]:
np.allclose(H_1_mo, diph.H_1_mo)
[17]:
True

但是,就如上一篇文档所言,

\[\frac{\partial h_{pq}}{\partial F_t} \neq h_{pq}^t\]

我们不妨先生成数值导数 deriv_H_0_mo \(\partial_{F_t} h_{pq}\)

[18]:
nd_H_0_mo = NumericDiff(dipn, lambda diph: diph.H_0_mo).derivative
nd_H_0_mo.shape
[18]:
(3, 22, 22)

我们尝试绘制 deriv_H_0_mo \(\partial_{F_t} h_{pq}\)H_1_mo 的差距 \(h_{pq}^t\)

[19]:
fig, ax = plt.subplots(figsize=(4, 3))
ax.hist(abs(nd_H_0_mo - H_1_mo).ravel(), bins=np.logspace(np.log10(1e-7),np.log10(1e2), 50), alpha=0.5)
ax.hist(abs(nd_H_0_mo).ravel(), bins=np.logspace(np.log10(1e-7),np.log10(1e2), 50), alpha=0.5)
ax.set_xscale("log")
fig.tight_layout()

可见,\(\partial_{F_t} h_{pq}\)\(h_{pq}^t\) 之间相差巨大。关于这一点,我们会在以后进一步陈述。

重叠矩阵 Skeleton 导数

我们回顾重叠矩阵的定义:

\[S_{\mu \nu} = \langle \mu | \nu \rangle = \int \phi_\mu (\boldsymbol{r}) \phi_\nu (\boldsymbol{r}) \, \mathrm{d} \boldsymbol{r}\]

该矩阵仅仅与电荷坐标与原子核坐标有关,与电场量无关。因此,

\[\frac{\partial S_{\mu \nu}}{\partial F_t} = S_{\mu \nu}^t = 0\]

事实上,DipoleSCF 类给出的 S_1_ao \(S_{\mu \nu}^t\) 就被定义为了标量 0。

[20]:
diph.S_1_ao
[20]:
0

但需要注意,\(S_{\mu \nu}^t\) 从定义上并非是标量,而是维度为 \((t, \mu, \nu)\) 的张量,其中每个元素的值为零。数值导数 nd_S_0_ao \(\partial_{F_t} S_{\mu \nu}\) 的结果如下:

[21]:
nd_S_0_ao = NumericDiff(dipn, lambda diph: diph.S_0_ao).derivative
nd_S_0_ao.shape
[21]:
(3, 22, 22)

其绝对值最大的元素就是零:

[22]:
np.abs(nd_S_0_ao).max()
[22]:
0.0

对应地,分子轨道下的重叠矩阵导数 \(S_{pq}^t\) 也为零:

[23]:
diph.S_1_mo
[23]:
0

任务 (1)

我们方才提及,

\[\frac{\partial h_{pq}}{\partial F_t} \neq h_{pq}^t\]

这类等式普遍来说是不成立的,但

\[\frac{\partial S_{pq}}{\partial F_t} = S_{pq}^t = 0\]

请说明上述等式成立的原因。

ERI 积分 Skeleton 导数

我们回顾 ERI 积分的定义:

\[(\mu \nu | \kappa \lambda) = \int \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\]

我们会发现上述量也仅仅是关于电荷坐标与原子核坐标的量,因此,其 Skeleton 导数

\[\frac{\partial (\mu \nu | \kappa \lambda)}{\partial F_t} = (\mu \nu | \kappa \lambda)^t = 0\]

上述量储存在 dipheri1_ao property 中:

[24]:
diph.eri1_ao
[24]:
0

那么,其对应的分子轨道 Skeleton 导数也为零:

[25]:
diph.eri1_mo
[25]:
0

任务 (2)

尽管 \(S_{pq}^t = 0\)\((\mu \nu | \kappa \lambda)^t = 0\),但 \(\partial_{F_t} S_{pq} = S_{pq}^t = 0\) 这样的结论不能直接套用到 \((pq|rs)\) 上。请用程序验证

\[\frac{\partial (pq|rs)}{\partial F_t} \neq (pq|rs)^t = 0\]

电子态能量导数

解析导数

我们知道,总能量可以分为电子能量 \(E_\mathrm{elec}\) 与原子核能量 \(E_\mathrm{nuc}\)。我们先回顾电子能量的计算过程:

\[E_\mathrm{elec} = h_{\mu \nu} D_{\mu \nu} + \frac{1}{2} D_{\mu \nu} (\mu \nu | \kappa \lambda) D_{\kappa \lambda} - \frac{1}{4} D_{\mu \nu} (\mu \kappa | \nu \lambda) D_{\kappa \lambda}\]

我们通过链式法则,应当能知道:

\[\begin{split}\begin{align} \frac{\partial E_\mathrm{elec}}{\partial F_t} &= \frac{\partial h_{\mu \nu}}{\partial F_t} D_{\mu \nu} + \frac{1}{2} D_{\mu \nu} \frac{\partial (\mu \nu | \kappa \lambda)}{\partial F_t} D_{\kappa \lambda} - \frac{1}{4} D_{\mu \nu} \frac{\partial (\mu \kappa | \nu \lambda)}{\partial F_t} D_{\kappa \lambda} \\ &\quad + F_{\mu \nu} \frac{\partial D_{\mu \nu}}{\partial F_t} \end{align}\end{split}\]

其中,上式的第一行是 Skeleton 导数的集合,而第二行是与分子轨道有关的导数,即 U 导数的集合。

任务 (3)

上式的第一行是非常显然能导出的;请说明第二行的导出过程。

但是,上式的四项中,除了第一项之外,其余三项都为零。

任务 (4)

上式第二、第三项中 Skeleton 导数 \((\mu \nu | \kappa \lambda)^t\) 为零,因此值为零;这是很容易理解的。

请证明第四项也为零。

因此,

\[\frac{\partial E_\mathrm{elec}}{\partial F_t} = h_{\mu \nu}^t D_{\mu \nu}\]

我们将变量 dip_elec 定义为 \(\partial_{F_t} E_\mathrm{elec}\)

[26]:
dip_elec = np.einsum("tuv, uv -> t", H_1_ao, D)
dip_elec
[26]:
array([ -0.99981,  -0.65982, -24.86113])

数值导数

我们可以用 PySCF 的 energy_elec method 求出电子态能量并作数值导数:

[27]:
nd_E_elec = NumericDiff(dipn, lambda diph: diph.scf_eng.energy_elec()[0]).derivative
nd_E_elec
[27]:
array([ -0.99981,  -0.65982, -24.86113])

我们会发现,数值导数与解析导数的值近乎一致:

[28]:
np.allclose(dip_elec, nd_E_elec)
[28]:
True

程序用法

尽管我们曾经提到过要用 pyplot 绘图查看数值导数与解析导数的差异,但这是针对中间计算矩阵而用的,因为中间计算矩阵即使误差较大也未必会对结果带来影响。

但对作为最终结果的贡献项,我们仍然要用 np.allclose 核验。np.allclose 是更为严苛的核验方式。

我们 曾经 用较为物理的方式,提到过计算电子云对偶极矩的贡献大小。这里我们用了解析导数的方式对其作呈现;两者的推导结论是相同的。

原子核互斥能导数

原子核互斥能对偶极矩的贡献也非常重要;但我们不作更多说明。其推导参考 之前的文档;我们定义该贡献为 dip_nuc

\[\frac{\partial E_\mathrm{nuc}}{\partial F_t} = Z_A A_t\]
[29]:
dip_nuc = np.einsum("A, At -> t", mol.atom_charges(), mol.atom_coords())
dip_nuc
[29]:
array([ 1.88973,  1.32281, 24.56644])

那么,最后我们将两个偶极矩贡献量相加,得到 dip_total

\[\frac{\partial E}{\partial F_t} = \frac{\partial E_\mathrm{elec}}{\partial F_t} + \frac{\partial E_\mathrm{nuc}}{\partial F_t}\]
[30]:
dip_total = dip_elec + dip_nuc
dip_total
[30]:
array([ 0.88992,  0.66299, -0.29469])

需要留意,该总和量暂时不能通过求数值导数获得;这是因为在 PySCF 中或 pyxdh 中,核坐标导数没有写为 \(F_t\) 的因变量。因此,我们看到的对总能量的导数会是电子态能量导数;这是错误的。

[31]:
nd_eng = NumericDiff(dipn, lambda diph: diph.eng).derivative
nd_eng  ## Wrong total energy derivative to get dipole!
[31]:
array([ -0.99981,  -0.65982, -24.86113])

最后,我们声明我们的计算结果与 PySCF 所给出的结果完全相同:

[32]:
dip_total_pyscf = diph.scf_eng.dip_moment(unit="A.U.", verbose=0)
dip_total_pyscf
[32]:
array([ 0.88992,  0.66299, -0.29469])
[33]:
np.allclose(dip_total, dip_total_pyscf)
[33]:
True

参考任务解答

任务 (1)

正如上一篇文档 任务 (2) 所言,由于 \(S_{pq} = \delta_{pq}\),因此 \(S_{pq}\) 与外加电场 \(F_t\) 完全无关;故 \(\partial_{F_t} S_{pq}\) 为零。

任务 (2)

计算 \((pq|rs)\) 的方法,在 diph 中是 eri0_mo property。我们将 \(\partial_{F_t} (pq|rs)\) 记为 nd_eri0_mo

[34]:
nd_eri0_mo = NumericDiff(dipn, lambda diph: diph.eri0_mo).derivative
nd_eri0_mo.shape
[34]:
(3, 22, 22, 22, 22)

该张量显然不是零张量;我们能发现,绝大多数值处于 \(10^{-2}\) 数量级。

[35]:
fig, ax = plt.subplots(figsize=(4, 3))
ax.hist(abs(nd_eri0_mo).ravel(), bins=np.logspace(np.log10(1e-7),np.log10(1e2), 50), alpha=0.5)
ax.set_xscale("log")
fig.tight_layout()

任务 (3)

\[E_\mathrm{elec} = h_{\mu \nu} D_{\mu \nu} + \frac{1}{2} D_{\mu \nu} (\mu \nu | \kappa \lambda) D_{\kappa \lambda} - \frac{1}{4} D_{\mu \nu} (\mu \kappa | \nu \lambda) D_{\kappa \lambda}\]

上式除开 Skeleton 导数之外,就是所有对密度矩阵 \(D_{\mu \nu}\)\(D_{\kappa \lambda}\) 的导数贡献了:

\[\begin{align} \frac{\partial E_\mathrm{elec}}{\partial F_t} \leftarrow h_{\mu \nu} \frac{\partial D_{\mu \nu}}{\partial F_t} + \frac{1}{2} \frac{\partial D_{\mu \nu}}{\partial F_t} (\mu \nu | \kappa \lambda) D_{\kappa \lambda} + \frac{1}{2} D_{\mu \nu} (\mu \nu | \kappa \lambda) \frac{\partial D_{\kappa \lambda}}{\partial F_t} - \frac{1}{4} \frac{\partial D_{\mu \nu}}{\partial F_t} (\mu \kappa | \nu \lambda) D_{\kappa \lambda} - \frac{1}{4} D_{\mu \nu} (\mu \kappa | \nu \lambda) \frac{\partial D_{\kappa \lambda}}{\partial F_t} \end{align}\]

上式的向左箭头意指贡献而非等价。我们指出,上式的第 2、3 项是相等的。我们先用普通的求和形式写出上面的第 2、3 项:

\[\begin{split}\begin{align} \textsf{Term 2}&: \, \frac{1}{2} \sum_{\mu \nu \kappa \lambda} \frac{\partial D_{\mu \nu}}{\partial F_t} (\mu \nu | \kappa \lambda) D_{\kappa \lambda} \\ \textsf{Term 3}&: \, \frac{1}{2} \sum_{\mu \nu \kappa \lambda} D_{\mu \nu} (\mu \nu | \kappa \lambda) \frac{\partial D_{\kappa \lambda}}{\partial F_t} \end{align}\end{split}\]

既然是求和,那么角标符号就不再重要。我们将第 3 项的角标 \((\mu, \nu, \kappa, \lambda)\) 替换为 \((\kappa, \lambda, \mu, \nu)\),得到

\[\textsf{Term 3}: \, \frac{1}{2} \sum_{\kappa \lambda \mu \nu} D_{\kappa \lambda} (\kappa \lambda | \mu \nu) \frac{\partial D_{\mu \nu}}{\partial F_t}\]

这里需要利用到 ERI 积分的对称性

\[(\kappa \lambda | \mu \nu) = (\mu \nu | \kappa \lambda)\]

就得到了与第 2 项完全一模一样的结果。

对于第 4、5 项亦作同样处理,得到

\[\begin{split}\begin{align} \frac{\partial E_\mathrm{elec}}{\partial F_t} &\leftarrow h_{\mu \nu} \frac{\partial D_{\mu \nu}}{\partial F_t} + \frac{\partial D_{\mu \nu}}{\partial F_t} (\mu \nu | \kappa \lambda) D_{\kappa \lambda} - \frac{1}{2} \frac{\partial D_{\mu \nu}}{\partial F_t} (\mu \kappa | \nu \lambda) D_{\kappa \lambda} \\ &= \big( h_{\mu \nu} + (\mu \nu | \kappa \lambda) D_{\kappa \lambda} - \frac{1}{2} (\mu \kappa | \nu \lambda) D_{\kappa \lambda} \big) \frac{\partial D_{\mu \nu}}{\partial F_t} \\ &= F_{\mu \nu} \frac{\partial D_{\mu \nu}}{\partial F_t} \end{align}\end{split}\]

关于 RHF 下 Fock 矩阵的形式,读者可能需要自行回忆一下。我们在此用程序表明 RHF 下,

\[F_{\mu \nu} = h_{\mu \nu} + (\mu \nu | \kappa \lambda) D_{\kappa \lambda} - \frac{1}{2} (\mu \kappa | \nu \lambda) D_{\kappa \lambda}\]
[36]:
np.allclose(
    + H_0_ao
    + np.einsum("uvkl, kl -> uv", eri0_ao, D)
    - 0.5 * np.einsum("ukvl, kl -> uv", eri0_ao, D),
    F_0_ao
)
[36]:
True

任务 (4)

我们继续对上一个任务中没有化简完的 \(\partial_{F_t} D_{\mu \nu}\) 作展开。我们知道,密度矩阵从定义上是占据轨道矩阵的缩并:

\[D_{\mu \nu} = 2 C_{\mu i} C_{\nu i}\]

那么,根据链式法则,有

\[\frac{\partial D_{\mu \nu}}{\partial F_t} = \frac{\partial C_{\mu i}}{\partial F_t} C_{\nu i} + C_{\mu i} \frac{\partial C_{\nu i}}{\partial F_t}\]

根据 U 矩阵定义

\[\frac{\partial C_{\mu p}}{\partial \mathbb{A}} = C_{\mu m} U_{mp}^\mathbb{A}\]

代入链式法则导出式,得到

\[\frac{\partial D_{\mu \nu}}{\partial F_t} = 2 \big( C_{\mu m} U_{mi}^t C_{\nu i} + C_{\mu i} C_{\nu m} U_{mi}^t \big)\]

上式中出现了看上去非常相似的两项;但这两项并非是相等,而是互为转置。我们以后会用 U_1 表示 U 矩阵 (但 U 矩阵分为安全与非安全的,但现在还不需要了解这些):

[37]:
U_1 = diph.U_1

用 np.allclose 可以验证其互为转置关系:

[38]:
np.allclose(
    np.einsum("um, tmi, vi -> tuv", C, U_1[:, sa, so], Co),
    np.einsum("ui, vm, tmi -> tuv", Co, C, U_1[:, sa, so]).swapaxes(-1, -2)
)
[38]:
True

\(\partial_{F_t} D_{\mu \nu}\) 结果代入,得到

\[\frac{\partial E_\mathrm{elec}}{\partial F_t} \leftarrow F_{\mu \nu} 2 \frac{\partial D_{\mu \nu}}{\partial F_t} = 4 (F_{mi} + F_{im}) U_{mi}^t = 2 F_{mi} U_{mi}^t\]

我们将上述矩阵分为两部分考虑。若 \(m\) 处在非占轨道,则

\[\frac{\partial E_\mathrm{elec}}{\partial F_t} \leftarrow 4 F_{ai} U_{ai}^t = 0\]

这是因为从 RHF 定义上,\(F_{ai} = 0\)

剩下的部分是 \(m\) 处于占据轨道,则

\[\frac{\partial E_\mathrm{elec}}{\partial F_t} \leftarrow 4 F_{ji} U_{ji}^t = 2 \big( F_{ij} U_{ij}^t + F_{ji} U_{ji}^t \big) = 2 F_{ij} (U_{ij}^t + U_{ji}^t) = 0\]

上式的

  • 第一个等号利用到的是对 \((i, j)\) 求和等价于对 \((j, i)\) 求和,因此可以互换 \((i, j)\) 角标而不影响结果;

  • 第二个等号利用到的是 \(F_{ij} = F_{ji}\) 即 Fock 矩阵是对称矩阵;

  • 第三个等号利用到的是 \(U_{ij}^t + U_{ji}^t = - S_{ij}^t = 0\),其更为一般的结论 \(S_{pq}^\mathbb{A} + U_{pq}^\mathbb{A} + U_{qp}^\mathbb{A} = 0\) 我们已经在上一节的任务中证明了。

RHF 核坐标梯度计算

上一节,我们对 RHF 下的偶极矩作了计算。我们求取了 Hamiltonian Core 矩阵、重叠矩阵、ERI 积分的 Skeleton 导数。我们利用过 U 矩阵的特性,避免计算了较为复杂和耗时的 U 矩阵,从而只利用概念相对简单的 Skeleton 导数就解决了偶极矩计算。

偶极矩也确实计算简单,并且可以从物理图景和一阶导数两种方式求得结果,因此可以作为一阶梯度计算不错的切入点。但也由于很多矩阵在电场下的导数为零,许多电场梯度所能给出的公式不能很好地推广到其它梯度响应性质,因此不是很好的切入点。在以后,我们会更多地使用核坐标梯度来引导学习各种响应性质。

由于分子轨道的 Skeleton 导数从定义上是原子轨道 Skeleton 导数与轨道系数缩并而来,因此我们只要了解原子轨道的 Skeleton 导数的具体求取过程即可。后文不会对分子轨道的 Skeleton 作很翔实的说明。

准备工作

与上一节的情况不同的是,我们这里是对核坐标梯度求导。因此,上一节的偶极矩辅助类实例 diph 在这里替换为了梯度辅助类实例 gradh

[1]:
%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
from pyxdh.Utilities import NucCoordDerivGenerator, DipoleDerivGenerator, NumericDiff
from pyxdh.DerivOnce import GradSCF

np.einsum = partial(np.einsum, optimize=["greedy", 1024 ** 3 * 2 / 8])
np.allclose = partial(np.allclose, atol=1e-6, rtol=1e-4)
np.set_printoptions(5, linewidth=150, suppress=True)
warnings.filterwarnings("ignore")
[2]:
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()
[2]:
<pyscf.gto.mole.Mole at 0x7f64c4f7c940>
[3]:
gradh = GradSCF({"scf_eng": scf.RHF(mol)})
[4]:
nmo, nao, natm, nocc, nvir = gradh.nao, gradh.nao, gradh.natm, gradh.nocc, gradh.nvir
so, sv, sa = gradh.so, gradh.sv, gradh.sa
C, Co, Cv, e, eo, ev, D = gradh.C, gradh.Co, gradh.Cv, gradh.e, gradh.eo, gradh.ev, gradh.D
H_0_ao, S_0_ao, eri0_ao, F_0_ao = gradh.H_0_ao, gradh.S_0_ao, gradh.eri0_ao, gradh.F_0_ao
H_0_mo, S_0_mo, eri0_mo, F_0_mo = gradh.H_0_mo, gradh.S_0_mo, gradh.eri0_mo, gradh.F_0_mo
[5]:
def grad_generator(mol):
    scf_eng = scf.RHF(mol)
    config = {
        "scf_eng": scf_eng,
    }
    return GradSCF(config)
[6]:
gradn = NucCoordDerivGenerator(mol, grad_generator)

重叠矩阵 Skeleton 导数

上一节我们先讨论了 Hamiltonian Core 导数;但在核坐标梯度中,Hamiltonian Core 导数并不是非常容易求取的。我们先讨论更为简单的重叠矩阵 Skeleton 导数。但即使重叠矩阵较为简单,其过程可能也会令新读者感到相当痛苦。

解析导数 (1) 额外符号的引入

重叠积分的定义是

\[S_{\mu \nu} = \langle \mu | \nu \rangle = \int \phi_{\mu} (\boldsymbol{r}) \phi_{\nu} (\boldsymbol{r}) \, \mathrm{d} \boldsymbol{r}\]

但是从上面的定义中,我们似乎看不到任何与核坐标有关的变量。

事实上,原子轨道作为 Gaussian 函数基组,它的定义还与其对应的原子核坐标有关。因此,我们可以对原子轨道角标 \(\mu, \nu\) 下方明确加上原子角标,并将函数的自变量写为电子坐标与基组中心的原子核坐标之差的形式:

\[S_{\mu \nu} = \int \phi_{\mu_{M_1}} (\boldsymbol{r} - \boldsymbol{M}_1) \phi_{\nu_{M_2}} (\boldsymbol{r} - \boldsymbol{M}_2) \, \mathrm{d} \boldsymbol{r}\]

需要说明,下标 \(M_1, M_2\) 只是表明原子轨道 \(\mu\) 的中心在原子 \(M_1\) 上,原子轨道 \(\nu\) 的中心在原子 \(M_2\) 上。

记号说明

  • 原子轨道下标原子角标 \(\mu_M\)

    • 若原子 \(M\) 是原子轨道 \(\mu\) 作为 Gaussian 函数的中心,那么该角标的矩阵或张量值可能非零;

    • 但若 \(M\) 并非 \(\mu\) 的中心,那么矩阵或张量值一定为零。

这个符号有一点点类似于 Kronecker \(\delta\) 函数。如果我们不用 Einstein Summation 而只使用普通求和,那么用刚才的符号,下述表达式成立:

\[S_{\mu \nu} = \int \phi_\mu \phi_\nu \, \mathrm{d} \boldsymbol{r} = \sum_{M_1, M_2} \int \phi_{\mu_{M_1}} \phi_{\nu_{M_2}} \, \mathrm{d} \boldsymbol{r}\]

现在,我们对上式作关于原子核 \(A\)\(t\) 坐标分量的导数:

\[S_{\mu \nu}^{A_t} = \frac{\partial S_{\mu \nu}}{\partial A_t} = \int \frac{\partial \phi_{\mu_{M_1}} (\boldsymbol{r} - \boldsymbol{M}_1)}{\partial A_t} \phi_{\nu_{M_2}} (\boldsymbol{r} - \boldsymbol{M}_2) \, \mathrm{d} \boldsymbol{r} + \int \phi_{\mu_{M_1}} (\boldsymbol{r} - \boldsymbol{M}_1) \frac{\partial \phi_{\nu_{M_2}} (\boldsymbol{r} - \boldsymbol{M}_2)}{\partial A_t} \, \mathrm{d} \boldsymbol{r}\]

上式多少有些冗长。事实上,上式的两项非常相似,差别仅仅是交换了 \(\mu, \nu\) 而已。我们以后经常会使用如下记号简写:

\[S_{\mu \nu}^{A_t} = \int \frac{\partial \phi_{\mu_{M_1}} (\boldsymbol{r} - \boldsymbol{M}_1)}{\partial A_t} \phi_{\nu_{M_2}} (\boldsymbol{r} - \boldsymbol{M}_2) \, \mathrm{d} \boldsymbol{r} + \mathrm{swap} (\mu, \nu)\]

\(S_{\mu \nu}^{A_t}\) 稀疏性 (1) 程序结果演示

在继续讨论之前,我们不妨先看一看重叠矩阵 Skeleton 导数的一些特性。gradh.S_1_ao 是 pyxdh 所生成的解析的 \(S_{\mu \nu}^\mathbb{A}\),其维度 \((\mathbb{A}, \mu, \nu)\) 如下:

[7]:
gradh.S_1_ao.shape
[7]:
(12, 22, 22)

其第一个维度 \(\mathbb{A}\) 相当于原子核的坐标分量 \(A_t\),但被压成了一个维度。因此,若要取索引为 1 的原子 (第二个氧原子) 的 \(z\) 分量,则应当取 gradh.S_1_ao[5]

但这个矩阵仍然很庞大,我们不妨将这个矩阵拆开来看。

我们首先取出 gradh.S_1_ao[5] 上述矩阵的索引 \([9, 18)\) 行、索引 \([0, 9) \cup [18, 22)\) 列;这是上述矩阵 (除开其转置之外) 唯一有非零值的地方:

[8]:
slice_mu = range(9, 18)
slice_nu = (*range(0, 9), *range(18, 22))
gradh.S_1_ao[5][slice_mu, :][:, slice_nu]
[8]:
array([[-0.     , -0.00166, -0.03032,  0.     ,  0.     , -0.00498,  0.     ,  0.     , -0.06593, -0.00084, -0.01656, -0.05936, -0.0228 ],
       [-0.00166, -0.0604 , -0.16854,  0.     ,  0.     , -0.10281,  0.     ,  0.     , -0.27194, -0.02041, -0.09431, -0.25276, -0.10814],
       [-0.03032, -0.16854, -0.25868,  0.     ,  0.     , -0.11283,  0.     ,  0.     , -0.20539, -0.09265, -0.16881, -0.17951, -0.1391 ],
       [ 0.     ,  0.     ,  0.     , -0.02832,  0.     ,  0.     , -0.1074 ,  0.     ,  0.     , -0.02096, -0.03809,  0.     ,  0.     ],
       [ 0.     ,  0.     ,  0.     ,  0.     , -0.02832,  0.     ,  0.     , -0.1074 ,  0.     ,  0.     ,  0.     , -0.25164, -0.03143],
       [ 0.00498,  0.10281,  0.11283,  0.     ,  0.     ,  0.15701,  0.     ,  0.     ,  0.05342,  0.02655,  0.03186, -0.03344, -0.06682],
       [ 0.     ,  0.     ,  0.     , -0.1074 ,  0.     ,  0.     , -0.25868,  0.     ,  0.     , -0.1314 , -0.12397,  0.     ,  0.     ],
       [ 0.     ,  0.     ,  0.     ,  0.     , -0.1074 ,  0.     ,  0.     , -0.25868,  0.     ,  0.     ,  0.     , -0.18089, -0.07151],
       [ 0.06593,  0.27194,  0.20539,  0.     ,  0.     ,  0.05342,  0.     ,  0.     , -0.21484,  0.13419,  0.07135, -0.23641, -0.23223]])

其它的矩阵元的值近乎于为零。我们可以将矩阵元的绝对值和减去两倍上面子矩阵的矩阵元绝对值和,得到接近于 0 的结果:

[9]:
np.abs(gradh.S_1_ao[5]).sum() - 2 * np.abs(gradh.S_1_ao[5][slice_mu, :][:, slice_nu]).sum()
[9]:
2.3092638912203256e-14

因此,其实相当多的矩阵元是零值。

这对于其它的原子核也是类似的。

这种现象并非是偶然的。我们先回忆一下很早之前提及的 原子轨道分割

[10]:
mol.aoslice_by_atom()
[10]:
array([[ 0,  5,  0,  9],
       [ 5, 10,  9, 18],
       [10, 12, 18, 20],
       [12, 14, 20, 22]])

因此,上述的非零的矩阵元所代表的的是,\(\mu\) 所代表的原子是第 2 个氧原子,\(\nu\) 所代表的原子是第 1 个氧原子和其它氢原子时,矩阵元是非零的。

我们以后会为了便利,使用 mol_slice 函数给出原子轨道的分割:

[11]:
def mol_slice(atm_id, mol=mol):
    _, _, p0, p1 = mol.aoslice_by_atom()[atm_id]
    return slice(p0, p1)

譬如,我们要取出第二个氧原子 (索引为 1 的原子) 的所有原子轨道的话,下述代码就可以取出其分割:

[12]:
mol_slice(1)
[12]:
slice(9, 18, None)

解析导数 (2) 最终表达式

接下来,我们看看为什么这么多矩阵元的值为零。我们先对下式作讨论。

\[\frac{\partial \phi_{\mu_{M_1}} (\boldsymbol{r} - \boldsymbol{M}_1)}{\partial A_t} \phi_{\nu_{M_2}} (\boldsymbol{r} - \boldsymbol{M}_2)\]

我们定义 \(\boldsymbol{u} = \boldsymbol{r} - \boldsymbol{M}_1\),那么根据链式法则,

\[\frac{\partial \phi_{\mu_{M_1}} (\boldsymbol{r} - \boldsymbol{M}_1)}{\partial A_t} = \frac{\partial \phi_{\mu_{M_1}} (\boldsymbol{u})}{\partial A_t} = \frac{\partial \phi_{\mu_{M_1}} (\boldsymbol{u})}{\partial \boldsymbol{u}} \cdot \frac{\partial \boldsymbol{u}}{\partial A_t}\]

先考察 \(\partial_{A_t} \boldsymbol{u}\)。由于 \(\boldsymbol{r}\) 是电子坐标,与原子核坐标分量 \(A_t\) 无关,因此

\[\frac{\partial \boldsymbol{r}}{\partial A_t} = \boldsymbol{0}\]

而若 \(M_1\) 作为原子与原子 \(A\) 不同,那么 \(\boldsymbol{M}_1\) 也与 \(A_t\) 毫无关系:

\[\frac{\partial \boldsymbol{M}_1}{\partial A_t} = \boldsymbol{0} \quad (M_1 \neq A)\]

但若 \(M_1\) 就是原子 \(A\),或者说原子轨道 \(\mu\) 的中心恰好是 \(A\) 原子,那么

\[\frac{\partial \boldsymbol{M}_1}{\partial A_t} = (\delta_{tx}, \delta_{ty}, \delta_{tz}) \quad (M_1 = A)\]

之所以写成上式的形式,是因为首先我们注意到 \(\boldsymbol{M}_1 = \boldsymbol{A}\) 本身是原子坐标,具有三个分量;如果我们将 \(A_t\) 当作标量考虑,那么上述偏导结果还是三元素向量。

那么,我们可以将 \(\boldsymbol{A}\) 写成向量 \((A_x, A_y, A_z)\) 的形式。\(A_t\) 就是其中的一个分量;因此,求导的结果就是 \(A_t\) 所在分量导数为 1,其余两个分量导数为零。这可以写成 Kronecker \(\delta\) 的形式。

那么综合起来,我们可以写

\[\frac{\partial \boldsymbol{u}}{\partial A_t} = - \frac{\partial \boldsymbol{M}_1}{\partial A_t} = - \delta_{M_1 A} (\delta_{tx}, \delta_{ty}, \delta_{tz})\]

这也就意味着只有三维坐标 \(\boldsymbol{u}\) 中只有 \(u_t\) 会对偏导产生贡献。因此,

\[\frac{\partial \phi_{\mu_{M_1}} (\boldsymbol{r} - \boldsymbol{M}_1)}{\partial A_t} = \frac{\partial \phi_{\mu_{M_1}} (\boldsymbol{u})}{\partial \boldsymbol{u}} \cdot \frac{\partial \boldsymbol{u}}{\partial A_t} = - \frac{\partial \phi_{\mu_{M_1}} (\boldsymbol{u})}{\partial u_t} \delta_{M_1 A} = - \frac{\partial \phi_{\mu_A} (\boldsymbol{u})}{\partial u_t}\]

化简至此,我们认为基本就够了。我们通常会将上式再缩写为

\[\frac{\partial \phi_{\mu_{M_1}} (\boldsymbol{r} - \boldsymbol{M}_1)}{\partial A_t} = - \phi_{t \mu_A}\]

其中,简写记号 \(\phi_{t \mu_A}\) 意味着原子轨道 \(\mu\) 只在原子 \(A\) 上才显示有值,且该原子轨道被求过 \(t\) 方向的偏导数。

我们回顾这一段最初的问题,即重叠矩阵的 Skeleton 导数:

\[\begin{split}\begin{align} S_{\mu \nu}^{A_t} &= \int \frac{\partial \phi_{\mu_{M_1}} (\boldsymbol{r} - \boldsymbol{M}_1)}{\partial A_t} \phi_{\nu_{M_2}} (\boldsymbol{r} - \boldsymbol{M}_2) \, \mathrm{d} \boldsymbol{r} + \mathrm{swap} (\mu, \nu) \\ &= - \int \phi_{t \mu_A} \phi_\nu \, \mathrm{d} \boldsymbol{r} + \mathrm{swap} (\mu, \nu) \\ &= - \langle \partial_t \mu_A | \nu \rangle + \mathrm{swap} (\mu, \nu) \end{align}\end{split}\]

上面的表达式回答了为何 \(S_{\mu \nu}^{A_t}\) 有这么多零值。这是因为,若 \(\mu\) 作为 Gaussian 函数的中心不是原子核 \(A\),那么 \(\langle \partial_t \mu_A | \nu \rangle\) 为零值。即使说还有 \(\mathrm{swap} (\mu, \nu)\),但这个补上去的转置也仍然不会改变 \(S_{\mu \nu}^{A_t}\) 是一个零值非常多的矩阵的状况。

不仅仅是 \(S_{\mu \nu}^{A_t}\),其它的不少 Skeleton 导数都具有这种稀疏性。尽管储存这种稀疏的矩阵一般来说总是低效的,但为了程序方便,我们通常都明确地在程序中使用稠密矩阵来储存这类稀疏矩阵。

在这一小段的最后,我们回顾一下上述推导过程中,一个很常用的推论:

常用推论

\[\partial_{A_t} \mu = - \partial_t \mu_A\]

解析导数 (3) 程序

所幸的是,PySCF 中,支持对 \(\langle \partial_t \mu | \nu \rangle\) 积分的计算。该积分可以暂存为 int1e_ipovlp

[13]:
int1e_ipovlp = mol.intor("int1e_ipovlp")
int1e_ipovlp.shape
[13]:
(3, 22, 22)

但这甚至与我们所期望的 \(S_{\mu \nu}^{A_t}\) 的维度也完全不同;int1e_ovlp \(\langle \partial_t \mu | \nu \rangle\) 的维度是 \((t, \mu, \nu)\),而 \(S_{\mu \nu}^{A_t}\) 的维度应当是 \((A, t, \mu, \nu)\)\((\mathbb{A} = A_t, \mu, \nu)\)

这是因为 \(\langle \partial_t \mu | \nu \rangle\) 并不等同于 \(\langle \partial_t \mu_A | \nu \rangle\);后者引入了原子轨道和原子之间的关系。譬如,对于 \(A\) 为第 2 个氧原子 (索引 1 的原子) 而言,\(\langle \partial_t \mu_A | \nu \rangle\) 积分中,当 \(\mu\) 处在其它原子上时,矩阵元应当都为零。我们可以用下述代码生成 \(A\) 为第 2 个氧原子的 S_1_ao_atom1 \(S_{\mu \nu}^{A_t}\) (维度为 \((t, \mu, \nu)\)):

[14]:
S_1_ao_atom1 = np.zeros((3, nao, nao))
sA = mol_slice(1)
S_1_ao_atom1[:, sA, :] = - int1e_ipovlp[:, sA, :]
S_1_ao_atom1 += S_1_ao_atom1.swapaxes(-1, -2)
np.allclose(S_1_ao_atom1, gradh.S_1_ao[3:6])
[14]:
True

上述代码的

  • Line 1:生成维度为 \((t, \mu, \nu)\) 的零矩阵;

  • Line 2:给出第二个氧原子为中心的所有原子轨道 \(\mu_A\) 的索引;

  • Line 3:对所有处在原子 \(A\) 上的原子轨道 \(\mu\),赋值 \(-\langle \partial_t \mu | \nu \rangle\)

  • Line 4:处理 \(\mathrm{swap}(\mu, \nu)\)

  • Line 5:与真实结果进行比对。

那么,对上面的程序作一点点延伸,就可以给出完整的、所有原子的 S_1_ao \(S_{\mu \nu}^{A_t}\) 了 (维度 \((A, t, \mu, \nu)\)):

[15]:
S_1_ao = np.zeros((natm, 3, nao, nao))
for A in range(natm):
    sA = mol_slice(A)
    S_1_ao[A, :, sA, :] = - int1e_ipovlp[:, sA, :]
S_1_ao += S_1_ao.swapaxes(-1, -2)

我们可以将其与 pyxdh 所给出的 gradh.S_1_ao 作比较;但在此之前,我们注意到两者维度不同:

[16]:
print("Our's S_1_ao:", S_1_ao.shape)
print("pyxdh S_1_ao:", gradh.S_1_ao.shape)
Our's S_1_ao: (4, 3, 22, 22)
pyxdh S_1_ao: (12, 22, 22)

这是因为 pyxdh 使用 \(\mathbb{A} = A_t\) 的方式存储各种解析导数,因此会将 \((A, t)\) 组合为一个维度。这么做在一阶响应性质中会是累赘,但在二阶响应性质的推导和编写上会轻松不少。我们在以后的一阶响应性质的文档中,统一还是拆分 \((A, t)\) 作为两个维度考虑。

那么,为比较上述两个张量是否相等,可以完全压平再进行比较:

[17]:
np.allclose(S_1_ao.flatten(), gradh.S_1_ao.flatten())
[17]:
True

\(S_{\mu \nu}^{A_t}\) 稀疏性 (2) 详细解释

我们刚才已经推导过,

\[\begin{split}\begin{align} S_{\mu \nu}^{A_t} &= - \langle \partial_t \mu_A | \nu \rangle + \mathrm{swap} (\mu, \nu) \\ &= - \langle \partial_t \mu_A | \nu \rangle - \langle \mu | \partial_t \nu_A \rangle \end{align}\end{split}\]

因此,容易知道对于原子 \(A\) 来讲,\(S^{A_t}_{\mu \nu}\) 中,若 \(\mu \neq A\)\(\nu \neq A\),则 \(S_{\mu \nu}^{A_t} = 0\)。下面展示的是,当 \(A\) 是第 2 个氧原子 (索引 1) 且 \(t = z\) 分量时,\(S_{\mu \nu}^{A_t}\) 中所有 \(\mu \neq A\)\(\nu \neq A\) 的元素值:

[18]:
slice_mu = list(range(nao))[mol_slice(0)] + list(range(nao))[mol_slice(2)] + list(range(nao))[mol_slice(3)]
slice_nu = list(range(nao))[mol_slice(0)] + list(range(nao))[mol_slice(2)] + list(range(nao))[mol_slice(3)]
S_1_ao[1, 2][slice_mu, :][:, slice_nu]
[18]:
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., 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., 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.]])

剩下的元素全部都可能非零吗?并非如此。\(S^{A_t}_{\mu \nu}\) 中,若 \(\mu = A\)\(\nu = A\),仍然有 \(S_{\mu \nu}^{A_t} = 0\)

[19]:
slice_mu = mol_slice(1)
slice_nu = mol_slice(1)
S_1_ao[1, 2][slice_mu, :][:, slice_nu]
[19]:
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., -0., -0., -0., -0., -0., -0.],
       [-0., -0., -0., -0., -0., -0., -0., -0., -0.],
       [ 0.,  0.,  0., -0., -0., -0., -0., -0., -0.]])

任务 (1)

证明

\[\langle \partial_t \mu_A | \nu_A \rangle + \mathrm{swap} (\mu, \nu) = 0\]

其它的情况 (\(\mu = A\)\(\nu \neq A\),或 \(\mu \neq A\)\(\nu = A\)) 则未必为零值;但完全可能因为分子或原子轨道对称性,导致零值的产生。这种零值我们就不作讨论了。

数值导数

最后,我们用数值导数与解析导数相互验证来收尾这一段。定义数值导数 nd_S_0_ao \(\partial_{A_t} S_{\mu \nu}\)

[20]:
nd_S_0_ao = NumericDiff(gradn, lambda gradh: gradh.S_0_ao).derivative
nd_S_0_ao.shape
[20]:
(12, 22, 22)

像上一节一样,我们绘制其与方才计算得到的 S_1_ao \(S_{\mu \nu}^t\) 的误差图:

[21]:
fig, ax = plt.subplots(figsize=(4, 3))
ax.hist(abs(nd_S_0_ao.ravel() - S_1_ao.ravel()), bins=np.logspace(np.log10(1e-12), np.log10(1e-2), 50), alpha=0.5)
ax.hist(abs(nd_S_0_ao.ravel()), bins=np.logspace(np.log10(1e-12), np.log10(1e-2), 50), alpha=0.5)
ax.set_xscale("log")
fig.tight_layout()

Hamiltonian Core 导数

解析梯度 (1) 原子轨道导数

Hamiltonian Core 定义为

\[\hat h = \hat t + \hat v_\mathrm{nuc}\]

我们先考虑动能积分:

\[\begin{split}\begin{align} h_{\mu \nu}^{A_t} &\leftarrow \frac{\partial}{\partial A_t} \langle \mu | \hat t | \nu \rangle \\ &= \langle \partial_{A_t} \mu | \hat t | \nu \rangle + \mathrm{swap} (\mu, \nu) \\ &= - \langle \partial_t \mu_A | \hat t | \nu \rangle + \mathrm{swap} (\mu, \nu) \end{align}\end{split}\]

上面的第 3 行导出过程中,我们利用到了 \(\partial_{A_t} \mu = - \partial_t \mu_A\) 的结论。

任务 (2)

请用简单的语言,简述为何下式为零:

\[\langle \mu_A | \partial_{A_t} \hat t | \nu_A \rangle = 0\]

其中,\(\langle \partial_t \mu | \hat t | \nu \rangle\) 积分在 PySCF 中可以通过 int1e_ipkin 获得。因此,动能部分的贡献大小是 H_1_ao_kin

[22]:
int1e_ipkin = mol.intor("int1e_ipkin")
H_1_ao_kin = np.zeros((natm, 3, nao, nao))
for A in range(natm):
    sA = mol_slice(A)
    H_1_ao_kin[A, :, sA, :] = - int1e_ipkin[:, sA, :]
H_1_ao_kin += H_1_ao_kin.swapaxes(-1, -2)

随后我们考虑势能积分:

\[\begin{split}\begin{align} h_{\mu \nu}^{A_t} &\leftarrow \frac{\partial}{\partial A_t} \langle \mu | \hat v_\mathrm{nuc} | \nu \rangle \\ &= \langle \partial_{A_t} \mu | \hat v_\mathrm{nuc} | \nu \rangle + \langle \mu | \hat v_\mathrm{nuc} | \partial_{A_t} \nu \rangle + \langle \mu | \partial_{A_t} \hat v_\mathrm{nuc} | \nu \rangle \end{align}\end{split}\]

我们指出,在势能积分中,\(\langle \mu | \partial_{A_t} \hat v_\mathrm{nuc} | \nu \rangle\) 一项并非是零;而这一项考虑起来也稍复杂一些,我们放在下一小段中叙述。这里我们先生成前两项的贡献大小 H_1_ao_nuc1

\[h_{\mu \nu}^{A_t} \leftarrow - \langle \partial_t \mu_A | \hat v_\mathrm{nuc} | \nu \rangle + \mathrm{swap} (\mu, \nu)\]

其中,\(\langle \partial_t \mu | \hat v_\mathrm{nuc} | \nu \rangle\) 积分在 PySCF 中可以通过 int1e_ipnuc 获得:

[23]:
int1e_ipnuc = mol.intor("int1e_ipnuc")
H_1_ao_nuc1 = np.zeros((natm, 3, nao, nao))
for A in range(natm):
    sA = mol_slice(A)
    H_1_ao_nuc1[A, :, sA, :] = - int1e_ipnuc[:, sA, :]
H_1_ao_nuc1 += H_1_ao_nuc1.swapaxes(-1, -2)

解析导数 (2) 核静电势算符导数

这里我们处理 \(\langle \mu | \partial_{A_t} \hat v_\mathrm{nuc} | \nu \rangle\) 的计算。这里我们暂时不用 Einstein Summation。

我们回顾到

\[\hat v_\mathrm{nuc} = - \sum_M \frac{Z_M}{| \boldsymbol{r} - \boldsymbol{M} |}\]

上式是对原子核索引 \(M\) 求和。由于算符 \(v_\mathrm{nuc}\) 不像偏导算符,\(v_\mathrm{nuc}\) 可以当作数来对待,因此单独分析 \(\partial_{A_t} \hat v_\mathrm{nuc}\) 是有意义的。那么,

\[\partial_{A_t} \hat v_\mathrm{nuc} = - \sum_M \frac{\partial}{\partial A_t} \frac{Z_M}{| \boldsymbol{r} - \boldsymbol{M} |}\]

上式中,有可能与 \(A_t\) 产生联系的只可能是 \(M\) 原子核坐标 \(\boldsymbol{M}\)。参考 \(S_{\mu \nu}^{A_t}\) 导出过程分析,我们知道,若 \(M \neq A\),那么两者就无关了。因此,只有当 \(M = A\) 时才能产生导数:

\[\partial_{A_t} \hat v_\mathrm{nuc} = - \frac{\partial}{\partial A_t} \frac{Z_A}{| \boldsymbol{r} - \boldsymbol{A} |}\]

注意到上式并没有对任何角标作求和。下面我们利用一个小技巧,将关于 \(A_t\) (原子核坐标分量) 的导数转为关于 \(t\) (电子坐标分量) 的导数:

\[\frac{\partial}{\partial t} \frac{Z_A}{| \boldsymbol{r} - \boldsymbol{A} |} = - \frac{\partial}{\partial A_t} \frac{Z_A}{| \boldsymbol{r} - \boldsymbol{A} |}\]

任务 (3)

请尝试证明上述等式。

因此,

\[\langle \mu | \partial_{A_t} \hat v_\mathrm{nuc} | \nu \rangle = \langle \mu | \frac{\partial}{\partial t} \frac{Z_A}{| \boldsymbol{r} - \boldsymbol{A} |} | \nu \rangle\]

我们固然可以利用任务 (3) 的结论求取上述积分结果;但这不仅困难,而且 PySCF 中事实上没有提供类似于 \(\langle \mu | r^3 | \nu \rangle\) 的积分程序。

我们再使用一个小技巧:

\[\begin{split}\begin{align} \langle \mu | \partial_{A_t} \hat v_\mathrm{nuc} | \nu \rangle &= \int \phi_\mu \phi_\nu \frac{\partial}{\partial t} \frac{Z_A}{| \boldsymbol{r} - \boldsymbol{A} |} \, \mathrm{d} \boldsymbol{r} \\ &= - \int \frac{\partial}{\partial t} (\phi_\mu \phi_\nu) \cdot \frac{Z_A}{| \boldsymbol{r} - \boldsymbol{A} |} \, \mathrm{d} \boldsymbol{r} \\ &= - \langle \partial_t \mu | \frac{Z_A}{| \boldsymbol{r} - \boldsymbol{A} |} | \nu \rangle + \mathrm{swap} (\mu, \nu) \end{align}\end{split}\]

在 PySCF 中, \(\langle \partial_t \mu | r^{-1} | \nu \rangle\) 的积分可以用 int1e_iprinv 调出。我们知道,对于上面 \(|\boldsymbol{r} - \boldsymbol{A}|^{-1}\) 这种情况,积分的求取需要先将原点移动至 \(A\) 原子坐标即 \(\boldsymbol{A}\)。这个程序的技巧在 核排斥势积分 有所提及。我们将上述积分的结果储存在 H_1_ao_nuc2 中:

[24]:
H_1_ao_nuc2 = np.zeros((natm, 3, nao, nao))
Z_A = mol.atom_charges()
for A in range(natm):
    with mol.with_rinv_as_nucleus(A):
        H_1_ao_nuc2[A] -= Z_A[A] * mol.intor("int1e_iprinv")
H_1_ao_nuc2 += H_1_ao_nuc2.swapaxes(-1, -2)

任务 (4)

请尝试证明上述推导过程的第二个等号:

\[\int \phi_\mu \phi_\nu \frac{\partial}{\partial t} \frac{Z_A}{| \boldsymbol{r} - \boldsymbol{A} |} \, \mathrm{d} \boldsymbol{r} = - \int \frac{\partial}{\partial t} (\phi_\mu \phi_\nu) \cdot \frac{Z_A}{| \boldsymbol{r} - \boldsymbol{A} |} \, \mathrm{d} \boldsymbol{r}\]

任务 (5)

读者可能在这里意识到了,之所以我们利用下述小技巧 (将偏导从对原子分量偏导 \(\partial_{A_t}\) 转为对电子坐标分量偏导)

\[\frac{\partial}{\partial t} \frac{Z_A}{| \boldsymbol{r} - \boldsymbol{A} |} = - \frac{\partial}{\partial A_t} \frac{Z_A}{| \boldsymbol{r} - \boldsymbol{A} |}\]

是为了进一步证明任务 (4) 中所指出的公式。

不利用上面的小技巧事实上很难推演出能程序化的表达式。退一步说,下述等式为何一般地不成立?

\[\int \phi_\mu \phi_\nu \frac{\partial}{\partial A_t} \frac{Z_A}{| \boldsymbol{r} - \boldsymbol{A} |} \, \mathrm{d} \boldsymbol{r} \not \equiv - \int \frac{\partial}{\partial A_t} (\phi_\mu \phi_\nu) \cdot \frac{Z_A}{| \boldsymbol{r} - \boldsymbol{A} |} \, \mathrm{d} \boldsymbol{r}\]

解析梯度总结与数值梯度

至此,我们已经将所有的 \(h_{\mu \nu}^{A_t}\) 的贡献项求取完毕了:

\[h_{\mu \nu}^{A_t} = - \langle \partial_t \mu_A | \hat t | \nu \rangle - \langle \partial_t \mu_A | \hat v_\mathrm{nuc} | \nu \rangle - \langle \partial_t \mu | \frac{Z_A}{| \boldsymbol{r} - \boldsymbol{A} |} | \nu \rangle + \mathrm{swap} (\mu, \nu)\]

综合上面几段代码,我们可以通过下述方式实现 H_1_ao \(h_{\mu \nu}^{A_t}\)

[25]:
int1e_ipkin = mol.intor("int1e_ipkin")
int1e_ipnuc = mol.intor("int1e_ipnuc")
Z_A = mol.atom_charges()
[26]:
H_1_ao = np.zeros((natm, 3, nao, nao))
for A in range(natm):
    sA = mol_slice(A)
    H_1_ao[A, :, sA, :] -= int1e_ipkin[:, sA, :]
    H_1_ao[A, :, sA, :] -= int1e_ipnuc[:, sA, :]
    with mol.with_rinv_as_nucleus(A):
        H_1_ao[A] -= Z_A[A] * mol.intor("int1e_iprinv")
H_1_ao += H_1_ao.swapaxes(-1, -2)

这与 pyxdh 所实现的 \(h_{\mu \nu}^{A_t}\) 完全一致:

[27]:
np.allclose(H_1_ao.ravel(), gradh.H_1_ao.ravel())
[27]:
True

我们也可以通过数值导数的方法生成 nd_H_0_ao \(\partial_{A_t} h_{\mu \nu}\) 并用图像验证其与 H_1_ao \(h_{\mu \nu}^{A_t}\) 非常接近:

[28]:
nd_H_0_ao = NumericDiff(gradn, lambda gradh: gradh.H_0_ao).derivative
nd_H_0_ao.shape
[28]:
(12, 22, 22)
[29]:
fig, ax = plt.subplots(figsize=(4, 3))
ax.hist(abs(nd_H_0_ao.ravel() - H_1_ao.ravel()), bins=np.logspace(np.log10(1e-12), np.log10(1e-2), 50), alpha=0.5)
ax.hist(abs(nd_H_0_ao.ravel()), bins=np.logspace(np.log10(1e-12), np.log10(1e-2), 50), alpha=0.5)
ax.set_xscale("log")
fig.tight_layout()

ERI 积分导数

解析导数

我们借助 \(\partial_{A_t} \mu = \partial_t \mu_A\) 的结论,可以得到

\[\begin{split}\begin{align} (\mu \nu | \kappa \lambda)^{A_t} = \frac{\partial}{\partial A_t} (\mu \nu | \kappa \lambda) &= - \big[ (\partial_t \mu_A \nu | \kappa \lambda) + (\mu \partial_t \nu_A | \kappa \lambda) + (\mu \nu | \partial_t \kappa_A \lambda) + (\mu \nu | \kappa \partial_t \lambda_A) \big] \\ &= - (\partial_t \mu_A \nu | \kappa \lambda) + \mathrm{swap} (\mu, \nu) + \mathrm{swap} (\mu \nu, \kappa \lambda) \end{align}\end{split}\]

上面展示了两种 ERI 积分的导数表达式,分别对应了两种程序实现 eri1_ao \((\mu \nu | \kappa \lambda)^{A_t}\) 的方式。第一种采用第一行的思路;其中,我们利用到 PySCF 中 \((\partial_t \mu \nu | \kappa \lambda)\) 可以用 int2e_ip1 调出:

[30]:
int2e_ip1 = mol.intor("int2e_ip1")
[31]:
eri1_ao = np.zeros((natm, 3, nao, nao, nao, nao))
for A in range(natm):
    sA = mol_slice(A)
    eri1_ao[A, :, sA, :, :, :] -= int2e_ip1[:, sA]
    eri1_ao[A, :, :, sA, :, :] -= int2e_ip1[:, sA].transpose(0, 2, 1, 3, 4)
    eri1_ao[A, :, :, :, sA, :] -= int2e_ip1[:, sA].transpose(0, 3, 4, 1, 2)
    eri1_ao[A, :, :, :, :, sA] -= int2e_ip1[:, sA].transpose(0, 3, 4, 2, 1)
eri1_ao.shape
[31]:
(4, 3, 22, 22, 22, 22)

它与 pyxdh 中给出的结果相同:

[32]:
np.allclose(eri1_ao.ravel(), gradh.eri1_ao.ravel())
[32]:
True

第二行的思路则可以用下述代码实现:

[33]:
eri1_ao = np.zeros((natm, 3, nao, nao, nao, nao))
for A in range(natm):
    sA = mol_slice(A)
    eri1_ao[A, :, sA, :, :, :] -= int2e_ip1[:, sA]
eri1_ao += eri1_ao.swapaxes(-3, -4)
eri1_ao += eri1_ao.swapaxes(-1, -3).swapaxes(-2, -4)
eri1_ao.shape
[33]:
(4, 3, 22, 22, 22, 22)
[34]:
np.allclose(eri1_ao.ravel(), gradh.eri1_ao.ravel())
[34]:
True

这两种方法在后文中都有可能使用。

数值梯度

nd_eri0_ao 表示 \(\partial_{A_t} (\mu \nu | \kappa \lambda)\)

[35]:
nd_eri0_ao = NumericDiff(gradn, lambda gradh: gradh.eri0_ao).derivative
nd_eri0_ao.shape
[35]:
(12, 22, 22, 22, 22)

其与解析的 eri1_ao \((\mu \nu | \kappa \lambda)^{A_t}\) 的对比为:

[36]:
fig, ax = plt.subplots(figsize=(4, 3))
ax.hist(abs(nd_eri0_ao.ravel() - eri1_ao.ravel()), bins=np.logspace(np.log10(1e-12), np.log10(1e-2), 50), alpha=0.5)
ax.hist(abs(nd_eri0_ao.ravel()), bins=np.logspace(np.log10(1e-12), np.log10(1e-2), 50), alpha=0.5)
ax.set_xscale("log")
fig.tight_layout()

电子态能量导数

有了上面的准备工作之后,我们就可以求取电子态能量在原子核坐标分量下的导数 E_1 \(\partial_{A_t} E_\mathrm{elec}\) 了。在此之前,我们对任意变量的能量导数作叙述。

根据

\[E_\mathrm{elec} = h_{\mu \nu} D_{\mu \nu} + \frac{1}{2} D_{\mu \nu} (\mu \nu | \kappa \lambda) D_{\kappa \lambda} - \frac{1}{4} D_{\mu \nu} (\mu \kappa | \nu \lambda) D_{\kappa \lambda}\]

我们有

\[\begin{split}\begin{align} \frac{\partial E_\mathrm{elec}}{\partial \mathbb{A}} &= \frac{\partial h_{\mu \nu}}{\partial \mathbb{A}} D_{\mu \nu} + \frac{1}{2} D_{\mu \nu} \frac{\partial (\mu \nu | \kappa \lambda)}{\partial \mathbb{A}} D_{\kappa \lambda} - \frac{1}{4} D_{\mu \nu} \frac{\partial (\mu \kappa | \nu \lambda)}{\partial \mathbb{A}} D_{\kappa \lambda} + F_{\mu \nu} \frac{\partial D_{\mu \nu}}{\partial \mathbb{A}} \\ &= \left( h_{\mu \nu}^\mathbb{A} + \frac{1}{2} (\mu \nu | \kappa \lambda)^\mathbb{A} D_{\kappa \lambda} - \frac{1}{4} (\mu \kappa | \nu \lambda)^\mathbb{A} D_{\kappa \lambda} \right) D_{\mu \nu} - 2 F_{ij} S_{ij}^\mathbb{A} \end{align}\end{split}\]

这与在上一篇文档求取 \(S_{\mu \nu}^t\) 的推演非常类似,使用到的也几乎仅仅是链式法则。

关于 \(F_{\mu \nu} \frac{\partial D_{\mu \nu}}{\partial \mathbb{A}}\) 一项是如何出现的,请参考上一份文档的 任务 (3)

任务 (6)

请证明

\[\frac{\partial E_\mathrm{elec}}{\partial \mathbb{A}} \leftarrow F_{\mu \nu} \frac{\partial D_{\mu \nu}}{\partial \mathbb{A}} = - 2 F_{ij} S_{ij}^\mathbb{A}\]

上式等式左对 \(\mu, \nu\) 求和,等式右对 \(i, j\) 求和。

上面的表达式对于 RHF 方法,是具有普适性的;因此,普适的 \(\mathbb{A}\) 替换为特化的 \(A_t\) 后,有电子态能量在原子核坐标分量下的导数

\[\frac{\partial E_\mathrm{elec}}{\partial A_t} = \left( h_{\mu \nu}^{A_t} + \frac{1}{2} (\mu \nu | \kappa \lambda)^{A_t} D_{\kappa \lambda} - \frac{1}{4} (\mu \kappa | \nu \lambda)^{A_t} D_{\kappa \lambda} \right) D_{\mu \nu} - 2 F_{ij} S_{ij}^{A_t}\]

为程序编写的方便,我们也可以拆开写为

\[\frac{\partial E_\mathrm{elec}}{\partial A_t} = h_{\mu \nu}^{A_t} D_{\mu \nu} + \frac{1}{2} (\mu \nu | \kappa \lambda)^{A_t} D_{\mu \nu} D_{\kappa \lambda} - \frac{1}{4} (\mu \kappa | \nu \lambda)^{A_t} D_{\mu \nu} D_{\kappa \lambda} - 2 F_{ij} S_{ij}^{A_t}\]

该导数可以在程序中写作 E_1_elec \(\partial_{A_t} E_\mathrm{elec}\);在此之前我们先生成 S_1_mo \(S_{ij}^{A_t}\)

\[S_{pq}^{A_t} = C_{\mu p} S_{\mu \nu}^{A_t} C_{\nu q}\]
[37]:
S_1_mo = np.einsum("up, Atuv, vq -> Atpq", C, S_1_ao, C)
[38]:
E_1_elec = (
    +        np.einsum("Atuv, uv -> At", H_1_ao, D)
    + 0.5  * np.einsum("Atuvkl, uv, kl -> At", eri1_ao, D, D)
    - 0.25 * np.einsum("Atukvl, uv, kl -> At", eri1_ao, D, D)
    - 2    * np.einsum("ij, Atij -> At", F_0_mo[so, so], S_1_mo[:, :, so, so])
)
E_1_elec
[38]:
array([[-2.3075 , -0.7927 , -9.10088],
       [-0.36945, -2.32149, 10.18083],
       [ 2.72808, -0.0358 , -0.60531],
       [-0.05114,  3.14999, -0.47464]])

通过数值导数,我们也能得到 nd_E_0_elec \(\partial_{A_t} E_\mathrm{elec}\)

[39]:
nd_E_0_elec = NumericDiff(gradn, lambda gradh: gradh.scf_eng.energy_elec()[0]).derivative.reshape(natm, 3)
nd_E_0_elec
[39]:
array([[-2.3075 , -0.7927 , -9.10088],
       [-0.36945, -2.32149, 10.18083],
       [ 2.72808, -0.0358 , -0.60531],
       [-0.05114,  3.14999, -0.47464]])

下面我们对比数值导数与解析导数之间的差别。由于能量梯度是最终的结果,我们用更严苛的 np.allclose 来验证结果:

[40]:
np.allclose(E_1_elec, nd_E_0_elec)
[40]:
True

原子核互斥能导数

这一段我们暂时不用 Einstein Summation。

我们回顾原子核互斥能的计算:

\[E_\mathrm{nuc} = \frac{1}{2} \sum_{M N} \frac{Z_M Z_N}{| \boldsymbol{M} - \boldsymbol{N} |} = \frac{1}{2} \sum_{M N} Z_{MN} r_{MN}^{-1}\]

其中,\(M, N\) 为原子角标,

\[\begin{split}\begin{align} Z_{MN} &= Z_M Z_N \\ r_{MN} &= | \boldsymbol{M} - \boldsymbol{N} | \end{align}\end{split}\]

现在,我们对上式作关于 \(A_t\) 的导数:

\[\frac{\partial E_\mathrm{nuc}}{\partial A_t} = \frac{1}{2} \sum_{M N} Z_{MN} \frac{\partial}{\partial A_t} r_{MN}^{-1}\]

到这里我们先回顾 \(r_{MN}\) 的定义:

\[r_{MN} = \sqrt{\sum_{t} (M_t - N_t)^2}\]

因此,

\[\begin{split}\begin{align} \frac{\partial}{\partial A_t} r_{MN}^{-1} &= \frac{\partial}{\partial A_t} \left( \sum_t (M_t - N_t)^2 \right)^{-1/2} \\ &= - \frac{1}{2} \left( \sum_t (M_t - N_t)^2 \right)^{-3/2} \frac{\partial}{\partial A_t} \sum_t (M_t - N_t)^2 \\ &= - r_{MN}^{-3} (M_t - N_t) (\delta_{MA} - \delta_{NA}) \end{align}\end{split}\]

为了程序实现的便利,我们会定义

\[V_{MNt} = M_t - N_t\]

该张量会具有 \(V_{MNt} = - V_{NMt}\) 的反对称性质。因此,

\[\begin{split}\begin{align} \frac{\partial E_\mathrm{nuc}}{\partial A_t} &= - \frac{1}{2} \sum_{M N} Z_{MN} r_{MN}^{-3} V_{MNt} (\delta_{MA} - \delta_{NA}) \\ &= - \frac{1}{2} \left( \sum_{N} Z_{AN} r_{AN}^{-3} V_{ANt} - \sum_{M} Z_{MA} r_{MA}^{-3/2} V_{MAt} \right) \\ &= - \sum_M Z_{AM} r_{AM}^{-3} V_{AMt} \end{align}\end{split}\]

在程序中,我们定义

  • nuc_Z \(Z_{MN}\)

  • nuc_rinv \(r_{MN}^{-1}\)

  • nuc_V \(V_{MNt}\)

[41]:
nuc_Z = np.einsum("M, N -> MN", mol.atom_charges(), mol.atom_charges())
nuc_V = lib.direct_sum("Mt - Nt -> MNt", mol.atom_coords(), mol.atom_coords())
nuc_rinv = 1 / (np.linalg.norm(nuc_V, axis=2) + np.diag([np.inf] * natm))

那么,程序实现 E_1_nuc \(\partial_{A_t} E_\mathrm{nuc}\) 可以表示如下:

[42]:
E_1_nuc = - np.einsum("AM, AM, AMt -> At", nuc_Z, nuc_rinv**3, nuc_V)
E_1_nuc
[42]:
array([[  2.24023,   0.86221,   9.19698],
       [  0.38236,   2.46344, -10.29839],
       [ -2.69385,   0.04989,   0.6448 ],
       [  0.07127,  -3.37554,   0.45661]])

我们也可以用过数值导数方式给出 nd_E_0_nuc \(\partial_{A_t} E_\mathrm{nuc}\)

[43]:
nd_E_0_nuc = NumericDiff(gradn, lambda gradh: gradh.scf_eng.energy_nuc()).derivative.reshape(natm, 3)
nd_E_0_nuc
[43]:
array([[  2.24023,   0.86221,   9.19698],
       [  0.38236,   2.46344, -10.29839],
       [ -2.69385,   0.04989,   0.6448 ],
       [  0.07127,  -3.37554,   0.45661]])

可以验证,数值导数与解析导数之间近乎相等:

[44]:
np.allclose(E_1_nuc, nd_E_0_nuc)
[44]:
True

分子总能量导数

综上,整个分子最终的能量导数为 E_1

\[\frac{\partial E}{\partial A_t} = \frac{\partial E_\mathrm{elec}}{\partial A_t} + \frac{\partial E_\mathrm{nuc}}{\partial A_t}\]
[45]:
E_1 = E_1_elec + E_1_nuc
E_1
[45]:
array([[-0.06727,  0.06951,  0.0961 ],
       [ 0.01291,  0.14195, -0.11756],
       [ 0.03423,  0.01409,  0.03949],
       [ 0.02013, -0.22555, -0.01803]])

可见,分子总能量的导数总体来说并不大;这是因为电子态能量导数与核排斥能导数两个相当大的矩阵正负抵消导致的。

上述导数在 pyxdh 中,以 gradh.E_1 property 呈现:

[46]:
np.allclose(E_1.ravel(), gradh.E_1.ravel())
[46]:
True

最后,我们通过验证数值导数 nd_E_0 \(\partial_{A_t} E\) 的方式,来确定我们方才推演的解析导数确实是正确的:

[47]:
nd_E_0 = NumericDiff(gradn, lambda gradh: gradh.eng).derivative.reshape(natm, 3)
nd_E_0
[47]:
array([[-0.06727,  0.06951,  0.0961 ],
       [ 0.01291,  0.14195, -0.11756],
       [ 0.03423,  0.01409,  0.03949],
       [ 0.02013, -0.22555, -0.01803]])
[48]:
np.allclose(E_1, nd_E_0)
[48]:
True

参考任务解答

任务 (1)

首先,我们指出,\(\langle \partial_t \mu_A | \nu_A \rangle\) 并不为零;譬如对于原子 \(A\) 代表第 1 个氢原子 (索引 2) 的情形,我们给出 \(\langle \partial_z \mu_A | \nu_A \rangle\)

[49]:
sA = mol_slice(1)
int1e_ipovlp[2, sA, sA]
[49]:
array([[-0.     ,  0.     , -0.     ,  0.     ,  0.     , -1.51402,  0.     ,  0.     , -0.16843],
       [ 0.     ,  0.     ,  0.     ,  0.     ,  0.     , -0.68687,  0.     ,  0.     , -0.6159 ],
       [-0.     ,  0.     ,  0.     ,  0.     ,  0.     , -0.2606 ,  0.     ,  0.     , -0.51962],
       [ 0.     ,  0.     ,  0.     ,  0.     ,  0.     ,  0.     ,  0.     ,  0.     ,  0.     ],
       [ 0.     ,  0.     ,  0.     ,  0.     ,  0.     ,  0.     ,  0.     ,  0.     ,  0.     ],
       [ 1.51402,  0.68687,  0.2606 ,  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.16843,  0.6159 ,  0.51962,  0.     ,  0.     ,  0.     ,  0.     ,  0.     ,  0.     ]])

但我们注意到这是一个反对称矩阵,因此求 \(\langle \partial_z \mu_A | \nu_A \rangle\) 加上其转置即 \(\langle \mu_A | \partial_z \nu_A \rangle\) 后,该矩阵就变成了零值了。

事实上,\(\langle \partial_z \mu | \nu \rangle\) 作为张量本身就是反对称的,或者说

\[\langle \partial_z \mu | \nu \rangle = - \langle \mu | \partial_z \nu \rangle\]

也可以说,\(\partial_z\) 算符是反厄米的。

简单地说,使用分部积分法,

\[\begin{split}\begin{align} \langle \partial_z \mu | \nu \rangle &= \int_{-\infty}^{+\infty} \int_{-\infty}^{+\infty} \int_{-\infty}^{+\infty} \frac{\partial \phi_\mu}{\partial_z} \phi_\nu \, \mathrm{d} z \, \mathrm{d} y \, \mathrm{d} x \\ &= \int_{-\infty}^{+\infty} \int_{-\infty}^{+\infty} \left( \phi_\mu \phi_\nu \big|_{z \rightarrow -\infty}^{+\infty} - \int_{-\infty}^{+\infty} \phi_\mu \frac{\partial \phi_\nu}{\partial_z} \, \mathrm{d} z \right) \, \mathrm{d} y \, \mathrm{d} x \end{align}\end{split}\]

而我们知道,作为原子轨道基组的 Gaussian 函数具有当坐标距离中心无穷远时,函数取值为零的性质,即

\[\lim_{z \rightarrow \pm \infty} \phi (x, y, z) = 0, \quad \forall x, y \in \mathbb{R}\]

这条公式算是断言,我们就不作证明了。与其有关的问题是波函数 (或基组) 的平方可积性质。

那么,上式就化为了

\[\langle \partial_z \mu | \nu \rangle = - \int_{-\infty}^{+\infty} \int_{-\infty}^{+\infty} \int_{-\infty}^{+\infty} \phi_\mu \frac{\partial \phi_\nu}{\partial_z} \, \mathrm{d} z \, \mathrm{d} y \, \mathrm{d} x = \langle \mu | \partial_z \nu \rangle\]

显然这对其它两种分量的导数也适用;因此,

\[\langle \partial_t \mu | \nu \rangle + \langle \mu | \partial_t \nu \rangle = \langle \partial_t \mu | \nu \rangle + \mathrm{swap} (\mu, \nu) = 0\]

\(\mu, \nu\) 被限定都在原子 \(A\) 上,上式仍然成立,因此命题得证。

但上述的证明过程可能会让我们误认为,既然 \(S_{\mu \nu}^t\) 就是通过 \(\langle \partial_t \mu | \nu \rangle\) 生成的,那么看起来所有值都会是零。事实上,

\[\langle \partial_t \mu_A | \nu_B \rangle + \langle \mu_B | \partial_t \nu_A \rangle \not\equiv 0\]

这是因为,如果 \(\mu\)\(A\) 原子上且 \(\nu\)\(B\) 原子上,那么 \(\langle \partial_t \mu_A | \nu_B \rangle \not\equiv 0\);但假使现在 \(A \neq B\),那么一定有 \(\langle \mu_B | \partial_t \nu_A \rangle = 0\);因此,上式的左边未必为零。这里 \(\not\equiv\) 符号的意义是未必为零。

任务 (2)

这是因为 \(\hat t\) 算符是对电子坐标的导数;它与原子核完全无关,因此在原子核坐标分量 \(A_t\) 下不可能有任何导数出现。

任务 (3)

待证等式为

\[\frac{\partial}{\partial t} \frac{Z_A}{| \boldsymbol{r} - \boldsymbol{A} |} = - \frac{\partial}{\partial A_t} \frac{Z_A}{| \boldsymbol{r} - \boldsymbol{A} |}\]

注意到我们可以写 (距离的定义是通过 \(L_2\) Norm 给出的)

\[| \boldsymbol{r} - \boldsymbol{A} | = \sqrt{\sum_{s} (s - A_s)^2}\]

其中,\(s\)\(t\) 一样代表坐标分量,\(s \in \{ x, y, z \}\)

因此,等式左边有:

\[\begin{split}\begin{align} \frac{\partial}{\partial t} \frac{Z_A}{| \boldsymbol{r} - \boldsymbol{A} |} &= Z_A \frac{\partial}{\partial t} \left( \sum_{s} (s - A_s)^2 \right)^{- 1/2} \\ &= - \frac{1}{2} Z_A \left( \sum_{s} (s - A_s)^2 \right)^{- 3/2} \frac{\partial}{\partial t} \sum_{s} (s - A_s)^2 \\ &= - \frac{1}{2} \frac{Z_A}{| \boldsymbol{r} - \boldsymbol{A} |^3} \frac{\partial}{\partial t} (t - A_t)^2 \\ &= - \frac{Z_A (t - A_t)}{| \boldsymbol{r} - \boldsymbol{A} |^3} \end{align}\end{split}\]

其中,第三个等号利用到了当 \(s \in \{ y, z \}\) 时,\(s\) 与被求导变量 \(t\) 无关,因此导数必然为零的性质。

仿照上面的推导,应当能发现

\[\frac{\partial}{\partial A_t} \frac{Z_A}{| \boldsymbol{r} - \boldsymbol{A} |} = \frac{Z_A (t - A_t)}{| \boldsymbol{r} - \boldsymbol{A} |^3}\]

因此,命题得证。

任务 (4)

思路同任务 (1)。这里就不详细展开了。

任务 (5)

因为分部积分只适用于被偏导变量与被积分的微元变量相同的情形。在

\[\int \phi_\mu \phi_\nu \frac{\partial}{\partial A_t} \frac{Z_A}{| \boldsymbol{r} - \boldsymbol{A} |} \, \mathrm{d} \boldsymbol{r} \not \equiv - \int \frac{\partial}{\partial A_t} (\phi_\mu \phi_\nu) \cdot \frac{Z_A}{| \boldsymbol{r} - \boldsymbol{A} |} \, \mathrm{d} \boldsymbol{r}\]

中,被偏导变量是 \(A_t\),被积分微元是三维向量 \(\boldsymbol{r}\),或者是三重积分下的三个变量 \((x, y, z)\)\(A_t\)\((x, y, z)\) 之间毫无关系,因此不能套用分部积分的公式推演思路。

进一步说,\(\partial_t\) 一般来说是反厄米算符,而 \(\partial_{A_t}\) 一般不具有厄米或反厄米性质。

任务 (6)

下述的推演过程与上一篇文档的 任务 (4) 非常相似。

待证明等式为

\[\frac{\partial E_\mathrm{elec}}{\partial \mathbb{A}} \leftarrow F_{\mu \nu} \frac{\partial D_{\mu \nu}}{\partial \mathbb{A}} = - 2 F_{ij} S_{ij}^\mathbb{A}\]

首先,通过链式法则,有

\[\frac{\partial D_{\mu \nu}}{\partial \mathbb{A}} = \frac{\partial (2 C_{\mu i} C_{\nu i})}{\partial \mathbb{A}} = 2 \left( \frac{\partial (C_{\mu i})}{\partial \mathbb{A}} C_{\nu i} + C_{\mu i} \frac{\partial (C_{\nu i})}{\partial \mathbb{A}} \right)\]

根据 U 矩阵定义,有

\[\frac{\partial D_{\mu \nu}}{\partial \mathbb{A}} = 2 \big( C_{\mu m} U_{mi}^\mathbb{A} C_{\nu i} + C_{\nu m} U_{mi}^\mathbb{A} C_{\mu i} \big)\]

上式与 \(F_{\mu \nu}\) 缩并后,有

\[\begin{split}\begin{align} \frac{\partial E_\mathrm{elec}}{\partial \mathbb{A}} \leftarrow F_{\mu \nu} \frac{\partial D_{\mu \nu}}{\partial \mathbb{A}} &= 2 \big( C_{\mu m} F_{\mu \nu} C_{\nu i} U_{mi}^\mathbb{A} + C_{\mu i} F_{\mu \nu} C_{\nu m} U_{mi}^\mathbb{A} \big) \\ &= 2 (F_{mi} + F_{im}) U_{mi}^\mathbb{A} \\ &= 4 F_{mi} U_{mi}^\mathbb{A} \end{align}\end{split}\]

其中,最后一个等号利用了 Fock 矩阵的对称性。

对于 RHF 而言,Fock 矩阵的非占-占据部分为零,即 \(F_{ai} = 0\),因此上式中全轨道角标 \(m\) 只在处于占据轨道时才有值;因此

\[\begin{split}\begin{align} \frac{\partial E_\mathrm{elec}}{\partial \mathbb{A}} \leftarrow F_{\mu \nu} \frac{\partial D_{\mu \nu}}{\partial \mathbb{A}} &= 4 F_{mi} U_{mi}^\mathbb{A} = 2 F_{ji} U_{ji}^\mathbb{A} \\ &= 2 \big( F_{ij} U_{ij}^\mathbb{A} + F_{ji} U_{ji}^\mathbb{A} \big) \\ &= 2 F_{ij} (U_{ij}^\mathbb{A} + U_{ji}^\mathbb{A}) \\ &= -2 F_{ij} S_{ij}^\mathbb{A} \end{align}\end{split}\]

上式的第 2 行利用了求和角标可交换的特性,第 3 行利用了 Fock 矩阵的对称性,第 4 行利用了

\[U_{ij}^\mathbb{A} + U_{ji}^\mathbb{A} + S_{ij}^\mathbb{A} = 0\]

RHF 核坐标梯度的 U 矩阵计算

之前我们已经讨论过 RHF 下的偶极矩与原子核坐标分量的能量导数计算了。这些导数的计算过程中,只需要使用矩阵的 Skeleton 导数即可。

但对于 MP2 方法而言,其原子核坐标分量的导数就不只需要 Skeleton 导数,还需要 U 矩阵的非占-占据部分了。这一节我们就来考察核坐标梯度下的 U 矩阵的求取,与 U 矩阵的一些性质。

准备工作

[1]:
%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
from pyxdh.Utilities import NucCoordDerivGenerator, DipoleDerivGenerator, NumericDiff
from pyxdh.DerivOnce import GradSCF

np.einsum = partial(np.einsum, optimize=["greedy", 1024 ** 3 * 2 / 8])
np.allclose = partial(np.allclose, atol=1e-6, rtol=1e-4)
np.set_printoptions(5, linewidth=150, suppress=True)
warnings.filterwarnings("ignore")
[2]:
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()
[2]:
<pyscf.gto.mole.Mole at 0x7f62b0731730>
[3]:
gradh = GradSCF({"scf_eng": scf.RHF(mol)})
[4]:
nmo, nao, natm, nocc, nvir = gradh.nao, gradh.nao, gradh.natm, gradh.nocc, gradh.nvir
so, sv, sa = gradh.so, gradh.sv, gradh.sa
C, Co, Cv, e, eo, ev, D = gradh.C, gradh.Co, gradh.Cv, gradh.e, gradh.eo, gradh.ev, gradh.D
H_0_ao, S_0_ao, eri0_ao, F_0_ao = gradh.H_0_ao, gradh.S_0_ao, gradh.eri0_ao, gradh.F_0_ao
H_0_mo, S_0_mo, eri0_mo, F_0_mo = gradh.H_0_mo, gradh.S_0_mo, gradh.eri0_mo, gradh.F_0_mo
[5]:
def grad_generator(mol):
    scf_eng = scf.RHF(mol)
    config = {
        "scf_eng": scf_eng,
    }
    return GradSCF(config)
[6]:
gradn = NucCoordDerivGenerator(mol, grad_generator)

但与之前文档不同的是,我们需要额外地给一个 GradSCF 实例 gradh_nr,它引入一个额外的选项 rotation (轨道旋转),其值为 False

[7]:
gradh_nr = GradSCF({"scf_eng": scf.RHF(mol), "rotation": False})

其中 nr 意指 no rotation (无轨道旋转)。gradh_nrgradh 只在处理 U 矩阵时会有不同,但作为最终结果的导数一般来说是相同的;在实例化 GradSCF 时,默认使用轨道旋转。

我们以后也会定义一些常用的导数量:

  • H_1_ao \(h_{\mu \nu}^\mathbb{A}\), H_1_mo \(h_{pq}^\mathbb{A}\)

  • S_1_ao \(S_{\mu \nu}^\mathbb{A}\), S_1_mo \(S_{pq}^\mathbb{A}\)

  • eri1_ao \((\mu \nu | \kappa \lambda)^\mathbb{A}\), eri1_mo \((pq|rs)^\mathbb{A}\)

其中的函数 to_natm_3 是将 gradhgradh_nr 的导数矩阵的首个维度 (12) 变为原子数量乘上 3 (4, 3)。

[8]:
def to_natm_3(mat: np.ndarray):
    shape = list(mat.shape)
    shape = [int(shape[0] / 3), 3] + shape[1:]
    return mat.reshape(shape)
[9]:
H_1_ao, S_1_ao, eri1_ao = to_natm_3(gradh.H_1_ao), to_natm_3(gradh.S_1_ao), to_natm_3(gradh.eri1_ao)
H_1_mo, S_1_mo, eri1_mo = to_natm_3(gradh.H_1_mo), to_natm_3(gradh.S_1_mo), to_natm_3(gradh.eri1_mo)

数值导数求取 U 矩阵

没有经过“轨道旋转”的 U 矩阵

我们曾经提及过,U 矩阵定义如下:

\[\frac{\partial C_{\mu p}}{\partial \mathbb{A}} = C_{\mu m} U_{m p}^\mathbb{A}\]

这其实相当于矩阵乘法;如果我们想要反推出 \(U_{mp}^{A_t}\) 的值,那么对 \(C_{\mu m}\) 取逆即可:

\[U_{mp}^\mathbb{A} = (\mathbf{C}^{-1})_{m \mu} \frac{\partial C_{\mu p}}{\partial \mathbb{A}}\]

其中,没有经过“旋转” 的轨道系数 \(C_{\mu m}\) 要求是 Canonical RHF 给出的系数矩阵,它至少同时满足 \(F_{pq} = e_p \delta_{pq}\)\(C_{\mu p} S_{\mu \nu} C_{\nu q} = \delta_{pq}\) 两个对角矩阵的条件。

具体到核坐标梯度上来。我们首先定义系数矩阵导数 nd_C \(\partial_{A_t}\)

[10]:
nd_C = NumericDiff(gradn, lambda gradh: gradh.C).derivative.reshape((natm, 3, nao, nao))
nd_C.shape
[10]:
(4, 3, 22, 22)

随后,我们就可以给出通过数值导数计算而来的 U 矩阵 nd_U_1_nr \(U_{mp}^{A_t}\)

[11]:
nd_U_1_nr = np.einsum("mu, Atup -> Atmp", np.linalg.inv(C), nd_C)
nd_U_1_nr.shape
[11]:
(4, 3, 22, 22)

在 pyxdh 中,gradh_nr.U_1 代表的是没有经过旋转的解析 U 矩阵 \(U_{pq}^{A_t}\)。上述数值 U 矩阵 nd_U_1_nr 与该解析 U 矩阵 gradh_nr.U_1 的误差图是:

[12]:
fig, ax = plt.subplots(figsize=(4, 3))
ax.hist(abs(nd_U_1_nr.ravel() - gradh_nr.U_1.ravel()), bins=np.logspace(np.log10(1e-9), np.log10(1e-1), 50), alpha=0.5)
ax.hist(abs(gradh_nr.U_1.ravel()), bins=np.logspace(np.log10(1e-9), np.log10(1e-1), 50), alpha=0.5)
ax.set_xscale("log")
fig.tight_layout()

这样一张误差图与以前求 Skeleton 导数的误差图比起来,我们能明显感到,蓝色的条很明显地往较大的数值偏移,并且与橙色条有部分重叠;但我们仍然判断解析导数与数值导数比较接近。我们以后会经常见到这种情形,特别是矩阵或张量中含有关于 U 矩阵计算的情况时。

经过“轨道旋转”的 U 矩阵 (1) \(\mathscr{U}_{pq}^{A_t}\) 的定义

上面提到,这是没有经过“轨道旋转”的 U 矩阵 \(U_{pq}^{A_t}\),它对应的计算实例是 gradh_nr。而经过“轨道旋转”的 U 矩阵计算实例是 gradh,我们写为 \(\mathscr{U}_{pq}^{A_t}\)。一般地,\(U_{pq}^{A_t} \neq \mathscr{U}_{pq}^{A_t}\)

[13]:
fig, ax = plt.subplots(figsize=(4, 3))
ax.hist(abs(nd_U_1_nr.ravel() - gradh.U_1.ravel()), bins=np.logspace(np.log10(1e-9), np.log10(1e-1), 50), alpha=0.5)
ax.hist(abs(gradh.U_1.ravel()), bins=np.logspace(np.log10(1e-9), np.log10(1e-1), 50), alpha=0.5)
ax.set_xscale("log")
fig.tight_layout()

我们看到,我们通过数值导数所求出的 U 矩阵 \(U_{pq}^{A_t}\) 和解析的、经过“轨道旋转”的 \(\mathscr{U}_{pq}^{A_t}\) 的差值中,有许多超过了 \(10^{-4}\) 量级;这表明两个矩阵差距太大,不能认为是近乎相同的。

事实上,经过某种“轨道旋转”(也可以理解为 non-Canonical RHF) 的 \(\mathscr{U}_{pq}^{A_t}\) 并非按照一般 U 矩阵的定义;其定义如下:

\[\begin{split}\begin{equation} \mathscr{U}_{pq}^{A_t} = \left\{ \begin{matrix} -\frac{1}{2} S_{ij}^{A_t} &\quad \textsf{occ-occ block} \\ -\frac{1}{2} S_{ab}^{A_t} &\quad \textsf{vir-vir block} \\ U_{ai}^{A_t} &\quad \textsf{vir-occ block} \\ U_{ia}^{A_t} &\quad \textsf{occ-vir block} \\ \end{matrix} \right. \end{equation}\end{split}\]

有意思的是,它在绝大多数情况下确实可以替代普通的 \(U_{pq}^{A_t}\) 进行计算。关于这一点,我们会在以后遇到时逐个分析。我们现在需要知道的是,\(U_{pq}^{A_t}\)\(\mathscr{U}_{pq}^{A_t}\) 同时满足的性质是:

\[\begin{split}\begin{align} U_{pq}^{A_t} + U_{qp}^{A_t} + S_{pq}^{A_t} &= 0 \\ \mathscr{U}_{pq}^{A_t} + \mathscr{U}_{qp}^{A_t} + S_{pq}^{A_t} &= 0 \end{align}\end{split}\]

定义 \(\mathscr{U}_{pq}^{A_t}\) 并不是为了简化公式推导 (它的引入事实上还添了不少麻烦),而是为了数值计算的稳定性和程序的便利。我们这一节会讨论到,若存在能量简并轨道,则没有进行“轨道旋转”的 \(U_{pq}^{A_t}\) 矩阵将会产生奇点 (无穷大值);因此需要使用“轨道旋转”了的矩阵 \(\mathscr{U}_{pq}^{A_t}\) 来避免数值的不稳定性。因此,我们有必要事先介绍与引入 \(\mathscr{U}_{pq}^{A_t}\)

经过“轨道旋转”的 U 矩阵 (2) \(\mathscr{U}_{pq}^\mathbb{A}\)\(U_{pq}^\mathbb{A}\) 的关系

上面的讨论不仅对核坐标导数 \(A_t\) 有意义,也可以推广到任意导数量 \(\mathbb{A}\)。这一小段我们就讨论 \(\mathscr{U}_{pq}^\mathbb{A}\)\(U_{pq}^\mathbb{A}\) 的关系。

推导正确性存疑

这里的推导并没有程序可以验证,也未必是正确的。

以后的文段中,不一定会应用这里的结论。一般来说,跳过这一小段的阅读不会对后续文段造成影响。

所谓“轨道旋转”,是指对分子轨道作旋转操作:

\[\mathscr{C}_{\mu p'} = C_{\mu p} X_{pp'}\]

或者,我们将上式写为矩阵乘法的形式:

\[\boldsymbol{\mathscr{C}} = \mathbf{C} \mathbf{X}\]

其中,我们称 \(\mathbf{X}\) 为旋转矩阵,\(p'\) 记号表示旋转过后的分子轨道角标。旋转矩阵具有以下特性:

  • 为保证旋转后的系数矩阵仍然具有 \(D_{\mu \nu} = \mathscr{C}_{\mu i'} \mathscr{C}_{\nu i'} = C_{\mu i} C_{\nu i}\) 的密度矩阵特性,\(\mathbf{X}\) 必须是对角块矩阵 (block-diagonal);或者说,\(X_{ai'} = 0\)\(X_{ia'} = 0\)

  • \(\mathbf{X}\) 需要是一个正交矩阵,即 \(\mathbf{X}^\dagger \mathbf{X} = \mathbf{1}\),即 \(\mathbf{X}^\dagger = \mathbf{X}^{-1}\)

上述性质的一个推论是 (下面的 \(\mathbf{S}\) 表示的是原子轨道下的重叠矩阵 \(S_{\mu \nu}\))

\[\boldsymbol{\mathscr{C}}^\dagger \mathbf{S} \boldsymbol{\mathscr{C}} = \mathbf{X}^\dagger \mathbf{C}^\dagger \mathbf{S} \mathbf{C} \mathbf{X} = \mathbf{X}^\dagger \mathbf{1} \mathbf{X} = \mathbf{1}\]

上面的第三个等号成立依据是 \(C_{\mu p} S_{\mu \nu} C_{\nu q} = \delta_{pq}\)

那么,我们就对 \(\boldsymbol{\mathscr{C}}\) 作关于微扰量 \(\mathbb{A}\) 的导数。我们首先定义旋转过后、再经过正交变换的 U 矩阵为 \(\tilde{\boldsymbol{\mathscr{U}}}^\mathbb{A}\)

\[\frac{\partial \boldsymbol{\mathscr{C}}}{\partial \mathbb{A}} = \boldsymbol{\mathscr{C}} \tilde{\boldsymbol{\mathscr{U}}}^\mathbb{A}\]

那么,对 \(\boldsymbol{\mathscr{C}} = \mathbf{C} \mathbf{X}\) 等式两边作关于 \(\mathbb{A}\) 的偏导数,得到

\[\boldsymbol{\mathscr{C}} \tilde{\boldsymbol{\mathscr{U}}}^\mathbb{A} = \mathbf{C} \mathbf{U}^\mathbb{A} \mathbf{X} + \mathbf{C} \partial_\mathbb{A} \mathbf{X}\]

对上式左乘 \(\mathbf{C}^{-1}\),右乘 \(\mathbf{X}^{-1} = \mathbf{X}^\dagger\),得到

\[\mathbf{X} \tilde{\boldsymbol{\mathscr{U}}}^\mathbb{A} \mathbf{X}^\dagger = \mathbf{U}^\mathbb{A} + (\partial_\mathbb{A} \mathbf{X}) \mathbf{X}^\dagger\]

我们定义上式为经过轨道旋转后的 \(\boldsymbol{\mathscr{U}}^\mathbb{A}\),即

\[\boldsymbol{\mathscr{U}}^\mathbb{A} = \mathbf{X} \tilde{\boldsymbol{\mathscr{U}}}^\mathbb{A} \mathbf{X}^\dagger = \mathbf{U}^\mathbb{A} + (\partial_\mathbb{A} \mathbf{X}) \mathbf{X}^\dagger\]

之所以采用上式作为轨道旋转后的 U 矩阵的定义,而非 \(\tilde{\boldsymbol{\mathscr{U}}}^\mathbb{A}\),是因为我们想要保证下式的成立:

\[\mathscr{U}_{pq}^\mathbb{A} + \mathscr{U}_{qp}^\mathbb{A} + S_{pq}^\mathbb{A} = 0\]

也请读者注意,我们在 \(\mathscr{C}_{\mu p'}\),进而在 \(\tilde{\mathscr{U}}_{m' p'}^\mathbb{A}\) 使用的下标是旋转后的分子轨道 (即加一撇);但在 \(\mathscr{U}_{mp}^\mathbb{A}\) 中却使用的是普通的、为旋转的分子轨道角标 (即没有撇)。

下面我们从 \(\boldsymbol{\mathscr{C}}^\dagger \mathbf{S} \boldsymbol{\mathscr{C}} = \mathbf{1}\) 出发,验证该等式。对该式两边求关于 \(\mathbb{A}\) 的偏导,得到

\[\begin{split}\begin{align} \mathbf{0} &= \frac{\partial \boldsymbol{\mathscr{C}}^\dagger}{\partial \mathbb{A}} \mathbf{S} \boldsymbol{\mathscr{C}} + \boldsymbol{\mathscr{C}}^\dagger \frac{\partial \mathbf{S}}{\partial \mathbb{A}} \boldsymbol{\mathscr{C}} + \boldsymbol{\mathscr{C}}^\dagger \mathbf{S} \frac{\partial \boldsymbol{\mathscr{C}}}{\partial \mathbb{A}} \\ &= \tilde{\boldsymbol{\mathscr{U}}}^{\mathbb{A} \dagger} \boldsymbol{\mathscr{C}}^\dagger \mathbf{S} \boldsymbol{\mathscr{C}} + \boldsymbol{\mathscr{C}}^\dagger \mathbf{S} \boldsymbol{\mathscr{C}} \tilde{\boldsymbol{\mathscr{U}}}^{\mathbb{A}} + \boldsymbol{\mathscr{C}}^\dagger \mathbf{S}^\mathbb{A} \boldsymbol{\mathscr{C}} \\ &= \tilde{\boldsymbol{\mathscr{U}}}^{\mathbb{A} \dagger} + \tilde{\boldsymbol{\mathscr{U}}}^{\mathbb{A}} + \boldsymbol{\mathscr{C}}^\dagger \mathbf{S}^\mathbb{A} \boldsymbol{\mathscr{C}} \end{align}\end{split}\]

利用 \(\mathbf{X}\) 为正交矩阵的特性,可以导出下述关系

\[\tilde{\boldsymbol{\mathscr{U}}}^\mathbb{A} = \mathbf{X}^\dagger \boldsymbol{\mathscr{U}}^\mathbb{A} \mathbf{X}\]

同时注意到,\(\boldsymbol{\mathscr{C}} = \mathbf{C} \mathbf{X}\),那么

\[\mathbf{0} = \mathbf{X}^\dagger \boldsymbol{\mathscr{U}}^{\mathbb{A} \dagger} \mathbf{X} + \mathbf{X}^\dagger \boldsymbol{\mathscr{U}}^{\mathbb{A}} \mathbf{X} + \mathbf{X}^\dagger \mathbf{C}^\dagger \mathbf{S}^\mathbb{A} \mathbf{C} \mathbf{X}\]

对上式左乘 \(\mathbf{X}\),右乘 \(\mathbf{X}^\dagger\),得到

\[\mathbf{0} = \boldsymbol{\mathscr{U}}^{\mathbb{A} \dagger} + \boldsymbol{\mathscr{U}}^{\mathbb{A}} + \mathbf{C}^\dagger \mathbf{S}^\mathbb{A} \mathbf{C}\]

留意到上面的 \(\boldsymbol{S}^\mathbb{A}\) 是原子轨道张量 \(S_{\mu \nu}^\mathbb{A}\),因此与 \(\mathbf{C}\) 作张量缩并后,得到的才是分子轨道张量 \(S_{pq}^\mathbb{A}\)。上式等价于 \(\mathscr{U}_{pq}^\mathbb{A} + \mathscr{U}_{qp}^\mathbb{A} + S_{pq}^\mathbb{A} = 0\)

由于满足了上面的条件,因此我们称定义 \(\boldsymbol{\mathscr{U}}^\mathbb{A} = \mathbf{U}^\mathbb{A} + (\partial_\mathbb{A} \mathbf{X}) \mathbf{X}^\dagger\) 是合理的。

原则上,任何对角正交矩阵 \(\mathbf{X}\) 都是允许的,并且 \(\partial_\mathbb{A} \mathbf{X}\) 的性质在作者看来是无法控制 (具有相当大可变性) 的。因此,作者认为,在给定 \(S_{pq}^\mathbb{A}\) 的情况下,对于任何满足 \(\mathscr{U}_{pq}^\mathbb{A} + \mathscr{U}_{qp}^\mathbb{A} + S_{pq}^\mathbb{A} = 0\)\(\mathscr{U}_{pq}^\mathbb{A}\) 都是允许的。因此,为了程序计算的方便,我们可以作定义

\[\begin{split}\begin{equation} \mathscr{U}_{pq}^{A_t} = \left\{ \begin{matrix} -\frac{1}{2} S_{ij}^{A_t} &\quad \textsf{occ-occ block} \\ -\frac{1}{2} S_{ab}^{A_t} &\quad \textsf{vir-vir block} \\ U_{ai}^{A_t} &\quad \textsf{vir-occ block} \\ U_{ia}^{A_t} &\quad \textsf{occ-vir block} \\ \end{matrix} \right. \end{equation}\end{split}\]

这看起来多此一举,但事实上,\(\mathscr{U}_{pq}^\mathbb{A}\) 避免了 \(U_{pq}^\mathbb{A}\) 所产生的很强的数值不稳定性。下图中,蓝色条展示的是 \(U_{pq}^{A_t}\) 矩阵元值大小分布,橙色条展示的是 \(\mathscr{U}_{pq}^{A_t}\) 矩阵元值大小分布。很明显能发现,橙色条的值普遍地比蓝色小一些。

[14]:
fig, ax = plt.subplots(figsize=(4, 3))
ax.hist(abs(gradh_nr.U_1.ravel()), bins=np.logspace(np.log10(1e-4), np.log10(1e4), 50), alpha=0.5)
ax.hist(abs(gradh.U_1.ravel()), bins=np.logspace(np.log10(1e-4), np.log10(1e4), 50), alpha=0.5)
ax.set_xscale("log")
fig.tight_layout()

任务 (1)

我们之前用的都是非对称的双氧水分子;若现在考虑 \(T_d\) 对称性的甲烷分子,请对未经“轨道旋转”的 \(U_{\mu \nu}^{A_t}\) 和经过“轨道旋转”的 \(\mathscr{U}_{\mu \nu}^{A_t}\) 绘制上述图像。

应当能预期看到蓝色的条延续到至少 \(10^{10}\) 量级,意味着 \(U_{\mu \nu}^{A_t}\)\(T_d\) 对称性的甲烷分子中,数值奇点问题要明显很多。原因会在后文提及。

最后,我们补充关于 \(\boldsymbol{\mathscr{U}}^\mathbb{A} = \mathbf{U}^\mathbb{A} + (\partial_\mathbb{A} \mathbf{X}) \mathbf{X}^\dagger\) 中,第二项 \((\partial_\mathbb{A} \mathbf{X}) \mathbf{X}^\dagger\) 的讨论。另外一种导出该项的方法是

\[\mathbf{0} = \frac{\partial \mathbf{1}}{\partial \mathbb{A}} = \frac{\partial (\mathbf{X} \mathbf{X}^\dagger)}{\partial \mathbb{A}} = (\partial_\mathbb{A} \mathbf{X}) \mathbf{X}^\dagger + \mathbf{X} (\partial_\mathbb{A} \mathbf{X}^\dagger)\]

因此,我们还可以写 \(\boldsymbol{\mathscr{U}}^\mathbb{A} = \mathbf{U}^\mathbb{A} - \mathbf{X} (\partial_\mathbb{A} \mathbf{X}^\dagger)\)

我们说,\(U_{ij}^\mathbb{A}\)\(U_{ab}^\mathbb{A}\) 一般来说不是对称矩阵;但我们定义的 \(\mathscr{U}_{ij}^\mathbb{A} = - \frac{1}{2} S_{ij}^\mathbb{A}\)\(\mathscr{U}_{ab}^\mathbb{A} = - \frac{1}{2} S_{ab}^\mathbb{A}\) 则是对称矩阵。这种从非对称到对称的差距,应当就是通过 \((\partial_\mathbb{A} \mathbf{X}) \mathbf{X}^\dagger\) 这样一项来弥补的。

最后,我们指出 \(\mathbb{X}\) 的具体性质。我们以后即使使用了“轨道旋转”的分子轨道系数矩阵 \(\boldsymbol{\mathscr{C}}\),我们会定义它在 未被微扰 的情况下,

\[\boldsymbol{\mathscr{C}} = \mathbf{C}\]

这也同时,意味着

\[\mathbf{X} = \mathbf{1}\]

但微扰下的情形就不同了。我们会定义

\[\partial_\mathbb{A} \mathbf{X} = \boldsymbol{\mathscr{U}}^\mathbb{A} - \mathbf{U}^\mathbb{A}\]

因此,所谓“轨道旋转”并没有在未微扰的参考态上真的旋转了轨道系数矩阵,因此所有与求导无关的矩阵、轨道能,都没有发生变化。这种“旋转”的效应只出现在微扰的过程中。从程序的角度而言,这种微扰仅仅是为了解决 \(\mathbf{U}^\mathbb{A}\) 的奇点问题;但从性质上讲,这种旋转不应导致最终的物理响应,譬如核坐标导数、电场导数产生变化。

Fock 矩阵导数与 A 张量

在继续讨论 U 矩阵导数的解析求法之前,我们先对 Fock 矩阵导数相关的问题作叙述。

Fock 矩阵导数 (1) Skeleton 导数

我们之前讨论过 Hamiltonian Core、重叠积分、ERI 积分的 Skeleton 导数;但我们尚没有讨论过 Fock 矩阵的 Skeleton 导数。

我们现在考虑 Fock 矩阵的导数。我们先回顾 Fock 矩阵的定义:

\[F_{\mu \nu} = h_{\mu \nu} + (\mu \nu | \kappa \lambda) D_{\kappa \lambda} - \frac{1}{2} (\mu \kappa | \nu \lambda) D_{\kappa \lambda}\]

所有与分子轨道无关的导数量的求和就是 Fock 矩阵的 Skeleton 导数 F_1_ao

\[\frac{\partial F_{\mu \nu}}{\partial A_t} \leftarrow F_{\mu \nu}^{A_t} = h_{\mu \nu}^{A_t} + (\mu \nu | \kappa \lambda)^{A_t} D_{\kappa \lambda} - \frac{1}{2} (\mu \kappa | \nu \lambda)^{A_t} D_{\kappa \lambda}\]
[15]:
F_1_ao = (
    + H_1_ao
    + np.einsum("Atuvkl, kl -> Atuv", eri1_ao, D)
    - 0.5 * np.einsum("Atukvl, kl -> Atuv", eri1_ao, D)
)
F_1_ao.shape
[15]:
(4, 3, 22, 22)

这也与 pyxdh 中对 \(F_{\mu \nu}^{A_t}\) 的实现结果一致:

[16]:
np.allclose(F_1_ao.ravel(), gradh.F_1_ao.ravel())
[16]:
True

分子轨道下的 Fock Skeleton 导数 F_1_mo 也很容易地导出如下:

\[F_{pq}^{A_t} = C_{\mu p} F_{\mu \nu}^{A_t} C_{\nu q}\]
[17]:
F_1_mo = np.einsum("up, Atuv, vq -> Atpq", C, F_1_ao, C)
F_1_mo.shape
[17]:
(4, 3, 22, 22)

但是,需要注意到,不同于我们之前对 Hamiltonian Core、Overlap、ERI 积分的认识,Fock 矩阵的 Skeleton 导数 \(F_{\mu \nu}^{A_t}\) 并不等价于 \(\partial_{A_t} F_{\mu \nu}\);这也能从图像上看出端倪 (蓝色与橙色条纹相当接近):

[18]:
nd_F_0_ao = NumericDiff(gradn, lambda gradh: gradh.F_0_ao).derivative
nd_F_0_ao.shape
[18]:
(12, 22, 22)
[19]:
fig, ax = plt.subplots(figsize=(4, 3))
ax.hist(abs(nd_F_0_ao.ravel() - F_1_ao.ravel()), bins=np.logspace(np.log10(1e-9), np.log10(1e1), 50), alpha=0.5)
ax.hist(abs(nd_F_0_ao.ravel()), bins=np.logspace(np.log10(1e-9), np.log10(1e1), 50), alpha=0.5)
ax.set_xscale("log")
fig.tight_layout()

Fock 矩阵导数 (2) U 导数与原子轨道下的 A 张量

正是因为 \(F_{\mu \nu}\) 不像其他原子轨道积分,它是与密度有关的量;因此,我们将其导数与密度有关的部分抽提出来:

\[\begin{split}\begin{align} \frac{\partial F_{\mu \nu}}{\partial A_t} &\leftarrow (\mu \nu | \kappa \lambda) \frac{\partial D_{\kappa \lambda}}{\partial A_t} - \frac{1}{2} (\mu \kappa | \nu \lambda) \frac{\partial D_{\kappa \lambda}}{\partial A_t} \\ &= 2 \left( (\mu \nu | \kappa \lambda) - \frac{1}{2} (\mu \kappa | \nu \lambda) \right) \big( C_{\kappa m} C_{\lambda i} + C_{\kappa i} C_{\lambda m} \big) U_{mi}^{A_t} \\ &= \big( 4 (\mu \nu | \kappa \lambda) - (\mu \kappa | \nu \lambda) - (\mu \lambda | \kappa \nu) \big) C_{\kappa m} C_{\lambda i} U_{mi}^{A_t} \end{align}\end{split}\]

任务 (2)

说明上述证明过程中的第 3 个等号为何成立。

为了符号上的便利,我们会定义原子轨道下的 A 张量

\[A_{\mu \nu, \kappa \lambda} = 4 (\mu \nu | \kappa \lambda) - (\mu \kappa | \nu \lambda) - (\mu \lambda | \kappa \nu)\]

注意到尽管我们是通过求导得到的 A 张量;但 A 张量却没有包含任何导数的信息。因此,A 张量不论是原子核坐标梯度或是电场梯度,都完全相同。

那么,\(\partial_{A_t} F_{\mu \nu}\) 中的 U 导数部分贡献可以记为

\[\frac{\partial F_{\mu \nu}}{\partial A_t} \leftarrow A_{\mu \nu, \kappa \lambda} C_{\kappa m} C_{\lambda i} U_{mi}^{A_t}\]

因此,总的原子轨道 Fock 矩阵导数可以写为

\[\frac{\partial F_{\mu \nu}}{\partial A_t} = F_{\mu \nu}^{A_t} + A_{\mu \nu, \kappa \lambda} C_{\kappa m} C_{\lambda i} U_{mi}^{A_t}\]

我们定义变量 A_0_ao \(A_{\mu \nu, \kappa \lambda}\)

[20]:
A_0_ao = 4 * eri0_ao - eri0_ao.swapaxes(-2, -3) - eri0_ao.swapaxes(-1, -3)
A_0_ao.shape
[20]:
(22, 22, 22, 22)

我们假设已经知道了 \(U_{pq}^{A_t}\) 可以通过 gradh_nr.U_1 调出,那么原子轨道下 Fock 矩阵 U 导数的贡献可以储存在变量 F_U_ao 中:

[21]:
F_U_ao = np.einsum("uvkl, km, li, Atmi -> Atuv", A_0_ao, C, Co, to_natm_3(gradh_nr.U_1)[:, :, :, so])

我们不妨用图像验证一下,将 U 导数与 Skeleton 导数 (F_1_ao \(F_{\mu \nu}^{A_t}\)) 相加,是否与 nd_F_0_ao \(\partial_{A_t} F_{\mu \nu}\) 近似相等:

[22]:
fig, ax = plt.subplots(figsize=(4, 3))
ax.hist(abs(nd_F_0_ao.ravel() - (F_1_ao + F_U_ao).ravel()), bins=np.logspace(np.log10(1e-11), np.log10(1e-1), 50), alpha=0.5)
ax.hist(abs(nd_F_0_ao.ravel()), bins=np.logspace(np.log10(1e-11), np.log10(1e-1), 50), alpha=0.5)
ax.set_xscale("log")
fig.tight_layout()

Fock 矩阵导数 (3) 分子轨道下的 A 张量

但一般的量化程序、以及 pyxdh 程序中,通常不使用上述的 A_0_ao \(A_{\mu \nu, \kappa \lambda}\) 张量进行实际的量化计算;取而代之的是对任意矩阵 \(X_{rs}\),计算 \(A_{pq, rs} X_{rs}\)。在此之前,我们先需要介绍一下分子轨道下的 A 张量 \(A_{pq, rs}\)

张量 A_0_mo \(A_{pq, rs}\) 定义如下:

\[A_{pq, rs} = C_{\mu p} C_{\nu q} A_{\mu \nu, \kappa \lambda} C_{\kappa r} C_{\lambda s}\]

这个定义与 ERI 积分有些相似。

[23]:
A_0_mo = np.einsum("up, vq, uvkl, kr, ls -> pqrs", C, C, A_0_ao, C, C)
A_0_mo.shape
[23]:
(22, 22, 22, 22)

引入该张量的缘由是我们会经常处理对分子轨道下 \(F_{pq}\) 的导数 (Yamaguchi, p409, N.1):

\[\frac{\partial F_{pq}}{\partial {A_t}} = F_{pq}^{A_t} + A_{pq, mi} U_{mi}^{A_t} + F_{pm} U_{mq}^{A_t} + F_{mq} U_{mp}^{A_t}\]

任务 (3)

证明上述等式。

任务 (4)

用程序求取 \(\partial_{A_t} F_{pq}\) 的解析导数;并通过求出数值导数,来验证你的解析导数是否正确。你应当需要 grad_nr.U_1 来表示 U 矩阵 \(U_{pq}^{A_t}\)

A 张量 (1) pyxdh 程序说明

但对于 RHF (特别是 GGA) 方法而言,在内存中储存四维度的张量一般要尽力避免,因此通常会设计一个函数用于计算 \(A_{pq, mi} U_{mi}^{A_t}\)。在 pyxdh 中,这样一个函数是 Ax0_Core

举一个例子。现在对于末尾两个维度是 \((n_\mathrm{MO}, n_\mathrm{MO})\) 的任意张量 (数值上或维度大小上,我们不妨定义为与 \(U_{pq}^{A_t}\) 具有相同维度) X \(X_{rs}^{A_t}\),我们可以用下述代码求出 AX \(A_{pq, rs} X_{rs}^{A_t}\)

[24]:
X = np.random.randn(natm, 3, nao, nao)
[25]:
Ax0_Core = gradh.Ax0_Core
AX = Ax0_Core(sa, sa, sa, sa)(X)
AX.shape
[25]:
(4, 3, 22, 22)

我们能发现,该结果可以用 A_0_mo 一样能导出:

[26]:
np.allclose(
    np.einsum("pqrs, Atrs -> Atpq", A_0_mo, X),
    AX
)
[26]:
True

现在我们详细讲讲 Ax0_Core 的使用。首先,Ax0_Core 本身是一个嵌套函数,它的输入是四个分割 (slice),输出是一个函数,姑且称它为 fx

[27]:
Ax0_Core(sa, sa, sa, sa)
[27]:
<function pyxdh.DerivOnce.deriv_once_scf.DerivOnceSCF.Ax0_Core.<locals>.fx(X_)>

函数 fx 的输入是除最后两维度外任意维度的张量,输出是 A 张量缩并后的结果。

随后我们说明这四个分割的意义。对于 Ax0_Core(sa, sa, sa, sa),四个分割都是全轨道分割,意味着我们使用的张量是 \(A_{pq, rs}\)。但若针对 \(A_{pq, mi} U_{mi}^{A_t}\) 而言,最后一个角标所代表的分割应当是占据轨道,因此 \(A_{pq, mi} U_{mi}^{A_t}\) 在程序中应当表示为 AU_nr

[28]:
AU_nr = Ax0_Core(sa, sa, sa, so)(to_natm_3(gradh_nr.U_1)[:, :, :, so])
AU_nr.shape
[28]:
(4, 3, 22, 22)

我们以后还会经常遇到类似于 \(A_{ai, bj} U_{bj}^{A_t}\) 的情况;对于这类张量缩并,我们需要用相应的轨道分割:

[29]:
AU_nr = Ax0_Core(sv, so, sv, so)(to_natm_3(gradh_nr.U_1)[:, :, sv, so])
AU_nr.shape
[29]:
(4, 3, 13, 9)

A 张量 (2) PySCF 程序说明

在 pyxdh 的后期版本中,\(A_{pq, rs} X_{rs}^\mathbb{A}\) 的实现是直接依靠 PySCF 的程序 _gen_rhf_response 实现的。该函数也是一个嵌套函数,其输出定义为 resp

[30]:
from pyscf.scf._response_functions import _gen_rhf_response
resp = _gen_rhf_response(gradh.scf_eng, mo_coeff=C, mo_occ=gradh.mo_occ, hermi=0)
resp
[30]:
<function pyscf.scf._response_functions._gen_rhf_response.<locals>.vind(dm1)>

resp 是一个通过输入矩阵可以输出计算结果的函数;它的实际作用是生成

\[\mathtt{resp}_{\mu \nu} [X_{\kappa \lambda}^\mathbb{A}] = \big( (\mu \nu | \kappa \lambda) - \frac{1}{2} (\mu \kappa| \nu \lambda) \big) X_{\kappa \lambda}^\mathbb{A}\]

但要求输入的 \(X_{\mu \nu}^\mathbb{A}\) 的维度必须是 \((\mathbb{A}, \kappa, \lambda)\) 即必须是三维张量:

[31]:
X = np.random.randn(5, nao, nao)
[32]:
np.allclose(
    resp(X),
    + np.einsum("uvkl, Akl -> Auv", eri0_ao, X)
    - 0.5 * np.einsum("ukvl, Akl -> Auv", eri0_ao, X)
)
[32]:
True

依靠该函数,我们就可以计算 \(A_{pq, mi} U_{mi}^{A_t}\) 了。

任务 (5)

利用上面提及的 \(\mathtt{resp}_{\mu \nu} [X_{\kappa \lambda}^\mathbb{A}]\),用程序求出 \(A_{pq, mi} U_{mi}^{A_t}\) 并作验证;该式对 \(m, i\) 角标求和。

最后我们指出,pyxdh 中的 resp 事实上是通过在 _gen_rhf_response 引入了参数 hermi=1 而非上面提到的 hermi=0;但这不影响我们这里的讨论。

解析 U 矩阵与 CP-HF 方程

分子轨道 Fock 矩阵导数与 U 矩阵

这一段中大部分时候所提及的 U 矩阵是未经“轨道旋转”的 U 矩阵 \(U_{pq}^\mathbb{A}\)。推导的具体细节可以参考 Yamaguchi 的书;我们也会在任务中作补充。

我们先回顾一下 Fock 矩阵全导数 (Yamaguchi, p433, W.1):

\[\frac{\partial F_{pq}}{\partial \mathbb{A}} = F_{pq}^\mathbb{A} + A_{pq, mi} U_{mi}^\mathbb{A} + F_{pm} U_{mq}^\mathbb{A} + F_{mq} U_{mp}^\mathbb{A}\]

我们知道,对于 Canonical 的 Fock 矩阵,有 \(F_{pq} = \varepsilon_p \delta_{pq}\);因此,

\[\frac{\partial F_{pq}}{\partial \mathbb{A}} = F_{pq}^\mathbb{A} - S_{pq}^\mathbb{A} \varepsilon_q - \frac{1}{2} A_{pq, kl} S_{kl}^\mathbb{A} + A_{pq, bj} U_{bj}^\mathbb{A} + (\varepsilon_p - \varepsilon_q) U_{pq}^\mathbb{A}\]

任务 (6)

请证明上述等式,并用 gradh_nr.U_1 作为 \(U_{pq}^{A_t}\),来验证当被求导量 \(\mathbb{A}\) 为原子核坐标分量 \(A_t\) 时,上述等式的正确性。

我们作补充定义上式中与 U 矩阵无关的所有项之和 B_1 \(B_{pq}^{A_t}\) (Yamaguchi, p437, X.3):

\[B_{pq}^\mathbb{A} = F_{pq}^\mathbb{A} - S_{pq}^\mathbb{A} \varepsilon_q - \frac{1}{2} A_{pq, kl} S_{kl}^\mathbb{A}\]
[33]:
B_1 = (
    + F_1_mo
    - np.einsum("Atpq, q -> Atpq", S_1_mo, e)
    - 0.5 * Ax0_Core(sa, sa, so, so)(S_1_mo[:, :, so, so])
)

在 pyxdh 中也有 B_1 property 来实现 \(B_{pq}^{A_t}\)

[34]:
np.allclose(B_1.ravel(), gradh.B_1.ravel())
[34]:
True

那么

\[\frac{\partial F_{pq}}{\partial \mathbb{A}} = B_{pq}^\mathbb{A} + A_{pq, bj} U_{bj}^\mathbb{A} + (\varepsilon_p - \varepsilon_q) U_{pq}^\mathbb{A}\]

非占-占据 U 矩阵 \(U_{ai}^\mathbb{A}\):PySCF 库函数求解

根据 Canonical RHF 的定义,\(F_{pq} = \varepsilon_{p} \delta_{pq}\);因而,非占-占据部分的 \(F_{ai} = 0\)。那么 (Yamaguchi, p437, X.1)

\[- (\varepsilon_a - \varepsilon_i) U_{ai}^\mathbb{A} - A_{ai, bj} U_{bj}^\mathbb{A} = B_{ai}^\mathbb{A}\]

上式也称为 CP-HF 方程 (Coupled Perturbed Hartree-Fock Equation),我们可以通过该式求出解析的 \(U_{pq}^\mathbb{A}\);其中上式对 \(b, j\) 两个角标求和。我们需要对 CP-HF 方程有一定的掌握。

演示 (1) 矩阵求逆求解 CP-HF 方程

我们将会通过矩阵求逆的方法,从程序上获得解析的 \(U_{ai}^{A_t}\);并与 grad_nr.U_1grad.U_1 作核对。

但如之前所述,在一般的量化程序中,RHF 方法或 GGA 方法不适合在内存中储存四维度的张量。同时,如果对 \(A_{ai, bj}\) 当作矩阵进行求逆的话,其计算消耗是处在 \(O(o^3 v^3)\) 量级,是不可接受的;因此,演示 (1) 中的方法不被一般量化程序所接受。

在 pyxdh 中,求取 CP-HF 方程的方式仅仅是借用了 PySCF 的 CP-HF 程序 cphf.solve

[35]:
from pyscf.scf import cphf
cphf.solve
[35]:
<function pyscf.scf.cphf.solve(fvind, mo_energy, mo_occ, h1, s1=None, max_cycle=20, tol=1e-09, hermi=False, verbose=2)>

其参数中,

  • fvind 表示的是用于求取 \(A_{ai, bj} U_{bj}^\mathbb{A}\) 的张量缩并函数,但要求 \(U_{bj}^\mathbb{A}\) 必须是三维度而不能更高,即不能直接传入 \((A, t, b, j)\) 维度的 U 矩阵而要传入 \((A_t, b, j)\) 维度;

  • mo_energy 表示分子轨道能量 \(e_p\)

  • mo_occ 表示分子轨道占据数 (在 RHF 中占据轨道为 2,非占轨道为 0);

  • h1 表示 CP-HF 方程等式等式右,即 \(B_{ai}^\mathbb{A}\);注意它是 \(B_{pq}^\mathbb{A}\) 在非占-占据的子张量,并且必须是 \((\mathbb{A}, a, i)\) 维度而不能是 \((A, t, a, i)\) 维度;

  • s1 表示是否额外引入 \(S_{pq}^\mathbb{A}\);但引入 s1 可能会使得程序输出较为复杂,pyxdh 中以及以后我们都不会引入该参数

其余的参数就不多作说明了。对于核坐标梯度,我们可以通过下述代码得到 \(U_{ai}^{A_t}\):(留意到我们降低了 CP-HF 方程的收敛阈值;这是为了可以与 pyxdh 的结果能通过 np.allclose;pyxdh 默认的 CP-HF 方程的收敛阈值是 \(10^{-6}\),比 PySCF 阈值要低很多)

[36]:
U_1_ai = cphf.solve(
    Ax0_Core(sv, so, sv, so, in_cphf=True),
    e,
    gradh.scf_eng.mo_occ,
    B_1[:, :, sv, so].reshape(natm * 3, nvir, nocc),
    tol=1e-6,
)[0].reshape(natm, 3, nvir, nocc)

我们可以验证,它与 pyxdh 中的 U 矩阵近乎一致:

[37]:
np.allclose(U_1_ai.ravel(), gradh.U_1[:, sv, so].ravel())
[37]:
True

由于 U 矩阵中,其非占-占据分块 \(U_{ai}^\mathbb{A}\) 的使用频率最高,因此它也会被特例地储存在 U_1_vo property 中。

[38]:
np.allclose(U_1_ai.ravel(), gradh.U_1_vo.ravel())
[38]:
True

演示 (2) Newton-Krylov 求解 CP-HF 方程

CP-HF 方程的求解可以看作是非线性方程的求解。我们将使用 SciPy 的 scipy.optimize.newton_krylov,求解 CP-HF 方程。

演示 (3) CP-HF 方程求解效率与精度

CP-HF 方程的求解是一个数值过程,伴随着收敛过程。因此,CP-HF 方程的求解存在着精度和效率问题。

我们将会展示与对比矩阵求逆、PySCF 库函数、SciPy 库函数三种方法的精度与效率。

占据-非占 U 矩阵 \(U_{ia}^\mathbb{A}\)

根据

\[S_{ai}^\mathbb{A} + U_{ai}^\mathbb{A} + U_{ia}^\mathbb{A} = 0\]

的关系,我们很容易地给出

\[U_{ia}^\mathbb{A} = - S_{ai}^\mathbb{A} - U_{ai}^\mathbb{A}\]

我们将 \(U_{ia}^{A_t}\) 定义为 U_1_ia

[39]:
U_1_ia = (- S_1_mo[:, :, sv, so] - U_1_ai).swapaxes(-1, -2)

我们也可以验证,它与 pyxdh U 矩阵中对应的部分是完全相等的:

[40]:
np.allclose(U_1_ia.ravel(), gradh.U_1[:, so, sv].ravel())
[40]:
True

占据-占据 U 矩阵 \(U_{ij}^\mathbb{A}\) 与非占-非占 \(U_{ab}^\mathbb{A}\) U 矩阵

但我们注意到,我们刚才仅仅是求取了 U 矩阵中的非占-占据部分 \(U_{ai}^\mathbb{A}\)。现在我们来处理剩下的矩阵分块。由于占据-占据 \(U_{ij}^\mathbb{A}\) 与非占-非占 \(U_{ab}^\mathbb{A}\) U 矩阵的处理方式是一样的,我们先把重心放在 \(U_{ij}^\mathbb{A}\) 的求取上。

我们再次回顾 \(F_{pq}^\mathbb{A}\) 的全导数。我们利用 \(i \neq j\) 时,\(F_{ij} = 0\) 的特性,给出

\[- (\varepsilon_i - \varepsilon_j) U_{ij}^\mathbb{A} - A_{ij, bk} U_{bk}^\mathbb{A} = B_{ij}^\mathbb{A} \quad (i \neq j)\]

我们就会注意到,既然 \(U_{bk}^\mathbb{A}\) 是 U 矩阵的非占-占据部分是已知的,那么上式中唯一未知的量是 \(U_{ij}^\mathbb{A}\)。其求取方式也很简单:

\[U_{ij}^\mathbb{A} = - \frac{B_{ij}^\mathbb{A} + A_{ij, bk} U_{bk}^\mathbb{A}}{\varepsilon_i - \varepsilon_j} \quad (i \neq j)\]

我们将上述矩阵写作 U_1_oo:(矩阵维度可能鬼畜了一些 \(=\omega=\))

[41]:
U_1_oo = - (B_1[:, :, so, so] + Ax0_Core(so, so, sv, so)(U_1_ai)) / (eo[:, None] - eo[None, :])
U_1_oo.shape
[41]:
(4, 3, 9, 9)

现在我们不妨看一下上述矩阵在索引为 0 的原子的 \(x\) 坐标分量的情况:

[42]:
U_1_oo[0, 0]
[42]:
array([[    -inf, -0.00605, -0.00509, -0.00939,  0.01131, -0.02396,  0.00299,  0.00294,  0.00048],
       [ 0.00601,      inf,  0.00081,  0.00131, -0.00209,  0.00632,  0.00031, -0.00349,  0.00244],
       [-0.00006, -0.00009,      inf, -0.20007,  0.05276, -0.07641,  0.01419, -0.04519,  0.03271],
       [ 0.00028, -0.00005,  0.15949,      inf,  0.19553, -0.3931 ,  0.03141,  0.08508, -0.04621],
       [-0.00033,  0.00011, -0.02664, -0.1179 ,      inf,  1.2955 , -0.10636, -0.1589 ,  0.1047 ],
       [ 0.00085, -0.00027,  0.0377 ,  0.22494, -1.27952,      inf,  0.27388,  0.25643, -0.21394],
       [ 0.00019, -0.     , -0.00885, -0.01896,  0.09768, -0.26466,      inf, -0.63285,  0.21533],
       [ 0.00035,  0.0001 , -0.00099, -0.06576,  0.13527, -0.2428 ,  0.54832,      inf,  0.35169],
       [-0.00014, -0.00006, -0.00084,  0.03543, -0.07674,  0.16436, -0.17985, -0.37454,      inf]])

之所以对角元上会出现 \(\inf\),是因为当 \(i = j\) 时,作为分母的 \(\varepsilon_i - \varepsilon_j = 0\),因此导致该处的值无法定义。

解决上述问题的方法,仍然是利用 \(S_{pq}^\mathbb{A} + U_{pq}^\mathbb{A} + U_{pq}^\mathbb{A} = 0\) 的特性。当 \(p = q = i\) 时,我们应当能求得

\[U_{ii}^\mathbb{A} = - \frac{1}{2} S_{ii}^\mathbb{A}\]
[43]:
for A in range(natm):
    for t in range(3):
        for i in range(nocc):
            U_1_oo[A, t, i, i] = - 0.5 * S_1_mo[A, t, i, i]

现在我们再看 U_1_oo

[44]:
U_1_oo[0, 0]
[44]:
array([[-0.00012, -0.00605, -0.00509, -0.00939,  0.01131, -0.02396,  0.00299,  0.00294,  0.00048],
       [ 0.00601, -0.     ,  0.00081,  0.00131, -0.00209,  0.00632,  0.00031, -0.00349,  0.00244],
       [-0.00006, -0.00009, -0.00816, -0.20007,  0.05276, -0.07641,  0.01419, -0.04519,  0.03271],
       [ 0.00028, -0.00005,  0.15949, -0.04709,  0.19553, -0.3931 ,  0.03141,  0.08508, -0.04621],
       [-0.00033,  0.00011, -0.02664, -0.1179 , -0.0085 ,  1.2955 , -0.10636, -0.1589 ,  0.1047 ],
       [ 0.00085, -0.00027,  0.0377 ,  0.22494, -1.27952,  0.01203,  0.27388,  0.25643, -0.21394],
       [ 0.00019, -0.     , -0.00885, -0.01896,  0.09768, -0.26466, -0.02048, -0.63285,  0.21533],
       [ 0.00035,  0.0001 , -0.00099, -0.06576,  0.13527, -0.2428 ,  0.54832, -0.00996,  0.35169],
       [-0.00014, -0.00006, -0.00084,  0.03543, -0.07674,  0.16436, -0.17985, -0.37454,  0.0155 ]])

它与 pyxdh 中 U 矩阵的结果也相同 (注意需要用 grad_nr 调出未经“轨道旋转”的 U 矩阵,因为经过轨道旋转的 \(\mathscr{U}_{ij}^\mathbb{A} = - \frac{1}{2} S_{ij}^\mathbb{A}\)):

[45]:
np.allclose(U_1_oo.ravel(), gradh_nr.U_1[:, so, so].ravel())
[45]:
True

任务 (7)

请用程序求出 U_1_vv \(U_{ab}^{A_t}\),并与 pyxdh 中对应的 U 矩阵结果作核对。

至此,我们就完成了所有 RHF 下的 U 矩阵的计算了。

简单总结

首先,我们在 数值导数求取 U 矩阵 一小节中,利用

\[U_{mp}^\mathbb{A} = (\mathbf{C}^{-1})_{m \mu} \frac{\partial C_{\mu p}}{\partial \mathbb{A}}\]

求出数值的 U 矩阵 \(U_{pq}^\mathbb{A}\) U_1_nr,并与 pyxdh 中解析的 U 矩阵 grad_nr.U_1 做了对比。同时我们意识到 \(U_{pq}^\mathbb{A}\) 存在数值问题,因此我们经常会使用 \(\mathscr{U}_{pq}^\mathbb{A}\) grad.U_1 替代 Canonical RHF 轨道下导出的 U 矩阵,并对 \(U_{pq}^\mathbb{A}\)\(\mathscr{U}_{pq}^\mathbb{A}\) 做了理论上的比较。

随后,在 Fock 矩阵导数与 A 张量 一小节中,我们引入了分子轨道下 A 张量。其引入的缘起是

\[\frac{\partial F_{pq}}{\partial {A_t}} = F_{pq}^{A_t} + A_{pq, mi} U_{mi}^{A_t} + F_{pm} U_{mq}^{A_t} + F_{mq} U_{mp}^{A_t}\]

其在 RHF 下的定义是

\[A_{pq, rs} = 4 (pq|rs) - (pr|qs) - (ps|qr)\]

但是出于内存存储的考量,我们定义了用于计算 \(A_{pq, rs} X_{rs}^\mathbb{A}\) 的程序 Ax0_Core

A 张量的主要用途是 实现 CP-HF 方程

\[- (\varepsilon_i - \varepsilon_j) U_{ij}^\mathbb{A} - A_{ij, bk} U_{bk}^\mathbb{A} = B_{ij}^\mathbb{A} \quad (i \neq j)\]

我们随后介绍了 PySCF 库函数求解 CP-HF 方程,进而求解完整的 \(U_{pq}^{A_t}\) 的过程。

演示任务

演示 (1) 矩阵求逆求解 CP-HF 方程

我们的目标是通过下式求解 \(U_{ai}^{A_t}\) (或者等价地 \(U_{jb}^{A_t}\)):

\[- (\varepsilon_a - \varepsilon_i) U_{ai}^{A_t} - A_{ai, bj} U_{bj}^{A_t} = B_{ai}^{A_t}\]

首先,我们定义如下变量:

\[\begin{split}\begin{align} D_i^a &= \varepsilon_i - \varepsilon_a = - (\varepsilon_a - \varepsilon_i) \\ \delta_{ai, bj} &= \delta_{ab} \delta_{ij} \end{align}\end{split}\]

其中,D_ai \(D_i^a\) 是以 \((a, i)\) 为储存维度,它并不代表密度矩阵;delta_aibj 代表 \(\delta_{ai, bj}\),维度为 \((a, i, b, j)\)

[46]:
D_ai = - (ev[:, None] - eo[None, :])
delta_aibj = np.eye(nvir * nocc).reshape(nvir, nocc, nvir, nocc)

那么上式可以写为

\[(D_i^a \delta_{ai, bj} - A_{ai, bj}) U_{bj}^{A_t} = B_{ai}^{A_t}\]

我们定义 Ap \(A'_{ai, bj}\)

\[A'_{ai, bj} = (D_i^a \delta_{ai, bj} - A_{ai, bj})\]
[47]:
Ap = D_ai * delta_aibj - A_0_mo[sv, so, sv, so]
Ap.shape
[47]:
(13, 9, 13, 9)

那么 CP-HF 方程化为

\[A'_{ai, bj} U_{bj}^{A_t} = B_{ai}^{A_t}\]

如果我们将双下标 \(ai, bj\) 当作单下标 \(P, Q\) 看待,那么这个问题就可以当作求矩阵求逆的问题了:

\[A'_{PQ} U_Q^{A_t} = B_P^{A_t}\]

或者写为矩阵乘积的形式,

\[\mathbf{A}' \mathbf{U}^{A_t} = \mathbf{B}^{A_t} \Leftrightarrow \mathbf{U}^{A_t} = \mathbf{A}'{}^{-1} \mathbf{B}^{A_t}\]

我们将通过上述方式生成的 \(U_{ai}^{A_t}\) 记为 U_1_ai_by_inv

[48]:
U_1_ai_by_inv = np.einsum("PQ, AtP -> AtQ", np.linalg.inv(Ap.reshape(nvir * nocc, nvir * nocc)), B_1[:, :, sv, so].reshape(natm, 3, nvir * nocc))
U_1_ai_by_inv.shape = (natm, 3, nvir, nocc)

稍微降低判据后,可以认为与 PySCF 库函数 cphf.solve 给出了近乎一致的 \(U_{ai}^{A_t}\)

[49]:
np.allclose(U_1_ai_by_inv, U_1_ai, atol=1e-5)
[49]:
True

演示 (2) Newton-Krylov 求解 CP-HF 方程

我们可以不借助于四维的 A 张量或 PySCF 的库函数,而直接使用 Ax0_Core 求解 CP-HF 方程。我们借助的是 SciPy 的 optimize.newton_krylov

[50]:
from scipy.optimize import newton_krylov
newton_krylov
[50]:
<function scipy.optimize.nonlin.newton_krylov(F, xin, iter=None, rdiff=None, method='lgmres', inner_maxiter=20, inner_M=None, outer_k=10, verbose=False, maxiter=None, f_tol=None, f_rtol=None, x_tol=None, x_rtol=None, tol_norm=None, line_search='armijo', callback=None, **kw)>

该函数所解决的是 \(f(x) = 0\) 的问题,但 \(x\) 可以延伸为矩阵或张量。在我们当前的问题中,\(x\) 就是 U 矩阵的非占-占据部分,f(X) \(f(x)\) 就是

\[f_{ai} [U_{bj}^{A_t}] = (\varepsilon_a - \varepsilon_i) U_{ai}^{A_t} + A_{ai, bj} U_{bj}^{A_t} + B_{ai}^{A_t}\]
[51]:
def f(X):
    return (
        + (ev[:, None] - eo[None, :]) * X
        + Ax0_Core(sv, so, sv, so)(X)
        + B_1[:, :, sv, so]
    )

我们注意到 newton_krylov 的必须输入参数有两个:F 可以是上面定义的 f(X);而 xin 表示的是初猜的 U 矩阵。尽管一般来说,初猜 U 矩阵定义为

\[U_{ai}^{A_t} (\mathtt{initial}) = \frac{B_{ai}^{A_t}}{\varepsilon_a - \varepsilon_i}\]

但事实上,这个初猜是通过令 \(A_{ai, bj} U_{bj}^{A_t} \simeq 0\) 的前提下给出的,因此让 U 矩阵的初猜为零初猜也未尝不可。通过上述方法获得的 U 矩阵 \(U_{ai}^{A_t}\) 记为 U_1_ai_by_scipy

[52]:
U_1_ai_by_scipy = newton_krylov(f, np.zeros((natm, 3, nvir, nocc)), verbose=True)
U_1_ai_by_scipy.shape
0:  |F(x)| = 0.000225677; step 1
1:  |F(x)| = 1.39118e-08; step 1
[52]:
(4, 3, 13, 9)

就像上一个任务一样,我们可以验证它与 PySCF 库函数所给出的 U 矩阵非占-占据部分近乎一致:

[53]:
np.allclose(U_1_ai_by_scipy, U_1_ai, atol=1e-5)
[53]:
True

演示 (3) CP-HF 方程求解效率与精度

我们先了解三种 CP-HF 方程求解的效率。在这里我们就顺便总结一下三种实现方法了。

矩阵求逆

\[\mathbf{U}^{A_t} = \mathbf{A}'{}^{-1} \mathbf{B}^{A_t}\]

但为了测评该方法的效率,我们需要尽量将耗时但预先已经计算的一些变量也放在这里考虑。

[54]:
%%timeit
eri0_ao = mol.intor("int2e")
A_0_ao = 4 * eri0_ao - eri0_ao.swapaxes(-2, -3) - eri0_ao.swapaxes(-1, -3)
A_0_aibj = np.einsum("ua, vi, uvkl, kb, lj -> aibj", Cv, Co, A_0_ao, Cv, Co)
delta_aibj = np.eye(nvir * nocc).reshape(nvir, nocc, nvir, nocc)
D_ai = - (ev[:, None] - eo[None, :])
Ap = D_ai * delta_aibj - A_0_aibj
U_1_ai_by_inv = np.einsum("PQ, AtP -> AtQ", np.linalg.inv(Ap.reshape(nvir * nocc, nvir * nocc)), B_1[:, :, sv, so].reshape(natm, 3, nvir * nocc))
U_1_ai_by_inv.shape = (natm, 3, nvir, nocc)
79.2 ms ± 9.59 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)

Newton Krylov 方法

\[f_{ai} [U_{bj}^{A_t}] = (\varepsilon_a - \varepsilon_i) U_{ai}^{A_t} + A_{ai, bj} U_{bj}^{A_t} + B_{ai}^{A_t} = 0\]

该方法可能很强地受制于算法效率。

[55]:
%%timeit
def f(X):
    return (
        + (ev[:, None] - eo[None, :]) * X
        + Ax0_Core(sv, so, sv, so)(X)
        + B_1[:, :, sv, so]
    )
U_1_ai_by_scipy = newton_krylov(f, np.zeros((natm, 3, nvir, nocc)))
The slowest run took 6.88 times longer than the fastest. This could mean that an intermediate result is being cached.
439 ms ± 379 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

PySCF 库函数

\[- (\varepsilon_a - \varepsilon_i) U_{ai}^{A_t} - A_{ai, bj} U_{bj}^{A_t} = B_{ai}^{A_t}\]

PySCF 库函数是专门求解 CP-HF 方程的程序,效率也相对来说最快,并且避免消耗 \(O(o^2 v^2)\) 大小的内存。

[56]:
%%timeit
U_1_ai = cphf.solve(
    Ax0_Core(sv, so, sv, so, in_cphf=True),
    e,
    gradh.scf_eng.mo_occ,
    B_1[:, :, sv, so].reshape(natm * 3, nvir, nocc),
    tol=1e-6,
)[0].reshape(natm, 3, nvir, nocc)
40.4 ms ± 13.2 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)

随后我们来考虑 CP-HF 方程解的精度。事实上,若 \(U_{ai}^{A_t}\) 是精确的 U 矩阵,那么在 Newton-Krylov 方法中定义的函数 \(f_{ai} [U_{bj}^{A_t}]\) 应当返回零值。因此,\(f_{ai} [U_{bj}^{A_t}]\) 约接近零值,U 矩阵就越准确。下面依次列举在上述判标下,通过矩阵求逆、Newton-Krylov 方法、PySCF 库函数方法给出的 U 矩阵的误差:

[57]:
np.abs(f(U_1_ai_by_inv)).sum()
[57]:
8.775671673583651e-12
[58]:
np.abs(f(U_1_ai_by_scipy)).sum()
[58]:
7.781896066462271e-07
[59]:
np.abs(f(U_1_ai)).sum()
[59]:
0.0013118540838382223

看起来 PySCF 库给出的 U 矩阵相当糟糕;但在不少情况下,这种大小的误差是可以接受的。若要提高 PySCF 库函数所导出的 U 矩阵精度,需要考虑两件事。首先需要更改一下 ~/.pyscf_conf.py 文件,譬如

[60]:
with open("/home/a/.pyscf_conf.py", "r") as file:
    print(file.read())
lib_linalg_helper_safe_eigh_lindep = 1e-50
lib_linalg_helper_davidson_lindep = 1e-50
lib_linalg_helper_dsolve_lindep = 1e-50
lib_linalg_helper_davidson_max_memory = 4000

其次是可以更改 cphf.solve 函数中 tol 选项;这会增加一些运行时间,但可以得到更好的收敛精度:

[61]:
U_1_ai_hightol = cphf.solve(
    Ax0_Core(sv, so, sv, so, in_cphf=True),
    e,
    gradh.scf_eng.mo_occ,
    B_1[:, :, sv, so].reshape(natm * 3, nvir, nocc),
    tol=1e-10,
)[0].reshape(natm, 3, nvir, nocc)
[62]:
np.abs(f(U_1_ai_hightol)).sum()
[62]:
5.987432392346346e-07

参考任务解答

任务 (1)

[63]:
mol_CH4 = gto.Mole()
mol_CH4.atom = """
C       0.00000000    0.00000000   -0.00000000
H      -0.00000000    0.00000000    1.08301642
H      -0.00000000   -1.02107768   -0.36100547
H       0.88427921    0.51053884   -0.36100547
H      -0.88427921    0.51053884   -0.36100547
"""
mol_CH4.basis = "6-31G"
mol_CH4.verbose = 0
mol_CH4.build()
[63]:
<pyscf.gto.mole.Mole at 0x7f6278b195e0>
[64]:
gradh_CH4 = GradSCF({"scf_eng": scf.RHF(mol_CH4)})
gradh_CH4_nr = GradSCF({"scf_eng": scf.RHF(mol_CH4), "rotation": False})
[65]:
fig, ax = plt.subplots(figsize=(4, 3))
ax.hist(abs(gradh_CH4_nr.U_1.ravel()), bins=np.logspace(np.log10(1e-4), np.log10(1e10), 50), alpha=0.5)
ax.hist(abs(gradh_CH4.U_1.ravel()), bins=np.logspace(np.log10(1e-4), np.log10(1e10), 50), alpha=0.5)
ax.set_xscale("log")
fig.tight_layout()

我们确实会发现,蓝色的条甚至可以延伸到 \(10^{10}\) 量级。经过后文的叙述,我们应当能知道,由于甲烷分子具有 \(T_d\) 对称性,因此存在三维不可约表示下的简并轨道,导致 \(U_{ij}^{A_t}\)\(U_{ab}^{A_t}\) 计算式中,处在分母的 \(\varepsilon_i - \varepsilon_j\)\(\varepsilon_a - \varepsilon_b\) 为零,从而产生巨大的数值问题。

[66]:
gradh_CH4.e
[66]:
array([-11.20416,  -0.94871,  -0.54481,  -0.54481,  -0.54481,   0.25607,   0.32516,   0.32516,   0.32516,   0.7408 ,   0.7408 ,   0.7408 ,   1.23234,
         1.23234,   1.23234,   1.25106,   1.32904])

而非对称的双氧水分子对称性较低,因此不会产生如此大的数值问题;但即使如此,若我们现在增大基组,则会增加大量的空轨道,从而缩小许多 \(\varepsilon_a - \varepsilon_b\) 的值,进而数值问题也会越严重。因此,即使非对称双氧水不具有高对称性,也仍然需要避免在实际计算中使用 \(U_{ij}^\mathbb{A}\)\(U_{ab}^\mathbb{A}\),而应用 \(S_{pq}^\mathbb{A}\)\(\mathscr{U}_{pq}^\mathbb{A}\) 替代。

任务 (2)

待证明等式是

\[\begin{split}\begin{align} \frac{\partial F_{\mu \nu}}{\partial A_t} &= 2 \left( (\mu \nu | \kappa \lambda) - \frac{1}{2} (\mu \kappa | \nu \lambda) \right) \big( C_{\kappa m} C_{\lambda i} + C_{\kappa i} C_{\lambda m} \big) U_{mi}^{A_t} \\ &= \big( 4 (\mu \nu | \kappa \lambda) - (\mu \kappa | \nu \lambda) - (\mu \lambda | \kappa \nu) \big) C_{\kappa m} C_{\lambda i} U_{mi}^{A_t} \end{align}\end{split}\]

首先,我们需要知道上面式子是对 \(\kappa, \lambda, m, i\) 四个角标求和。我们将第一行的待证式拆开来写为 (非 Einstein Summation)

\[\sum_{\kappa \lambda m i} \big( 2 (\mu \nu | \kappa \lambda) - (\mu \kappa | \nu \lambda) \big) C_{\kappa m} C_{\lambda i} U_{mi}^{A_t} + \sum_{\kappa \lambda m i} \big( 2 (\mu \nu | \kappa \lambda) - (\mu \kappa | \nu \lambda) \big) C_{\kappa i} C_{\lambda m} U_{mi}^{A_t}\]

保持上式第一项不动,第二项交换角标 \(\kappa, \lambda\) 得到

\[\sum_{\lambda \kappa m i} \big( 2 (\mu \nu | \lambda \kappa) - (\mu \lambda | \nu \kappa) \big) C_{\lambda i} C_{\kappa m} U_{mi}^{A_t}\]

我们注意到,此时两项都有相同的公因式 \(C_{\kappa m} C_{\lambda i} U_{mi}^{A_t}\) 可以提出来;剩下的部分相加即可:

\[\sum_{\kappa \lambda m i} \big( 2 (\mu \nu | \kappa \lambda) + 2 (\mu \nu | \lambda \kappa) - (\mu \kappa | \nu \lambda) - (\mu \lambda | \nu \kappa) \big) C_{\kappa m} C_{\lambda i} U_{mi}^{A_t}\]

我们注意到原子轨道具有对称性,因此 \((\mu \nu | \kappa \lambda) = (\mu \nu | \lambda \kappa)\),以及 \((\mu \lambda | \kappa \nu)\);因此,上式可以写为

\[\sum_{\kappa \lambda m i} \big( 4 (\mu \nu | \kappa \lambda) - (\mu \kappa | \nu \lambda) - (\mu \lambda | \kappa \nu) \big) C_{\kappa m} C_{\lambda i} U_{mi}^{A_t}\]

这就是待证等式的第二行了。

任务 (3)

待证等式是

\[\frac{\partial F_{pq}}{\partial {A_t}} = F_{pq}^{A_t} + A_{pq, mi} U_{mi}^{A_t} + F_{pm} U_{mq}^{A_t} + F_{mq} U_{mp}^{A_t}\]

我们的推导前提是

\[\frac{\partial F_{\mu \nu}}{\partial A_t} = F_{\mu \nu}^{A_t} + A_{\mu \nu, \kappa \lambda} C_{\kappa m} C_{\lambda i} U_{mi}^{A_t}\]

首先,通过 \(F_{pq} = C_{\mu p} F_{\mu \nu} C_{\nu q}\) 我们知道

\[\frac{\partial F_{pq}}{\partial {A_t}} = C_{\mu p} \frac{\partial F_{\mu \nu}}{\partial A_t} C_{\nu q} + F_{pm} U_{mq}^{A_t} + F_{mq} U_{mp}^{A_t}\]

其中,

\[\begin{split}\begin{align} C_{\mu p} \frac{\partial F_{\mu \nu}}{\partial A_t} C_{\nu q} &= C_{\mu p} F_{\mu \nu}^{A_t} C_{\nu q} + C_{\mu p} C_{\nu q} A_{\mu \nu, \kappa \lambda} C_{\kappa m} C_{\lambda i} U_{mi}^{A_t} \\ &= F_{pq}^{A_t} + A_{pq, mi} U_{mi}^{A_t} \end{align}\end{split}\]

证明完毕。

任务 (4)

我们要求取的全导数为

\[\frac{\partial F_{pq}}{\partial {A_t}} = F_{pq}^{A_t} + A_{pq, mi} U_{mi}^{A_t} + F_{pm} U_{mq}^{A_t} + F_{mq} U_{mp}^{A_t}\]

解析导数我们记为 d_F_0_mo \(\partial_{A_t} F_{pq}\)

[67]:
d_F_0_mo = (
    + F_1_mo
    + np.einsum("pqmi, Atmi -> Atpq", A_0_mo[:, :, :, so], to_natm_3(gradh_nr.U_1)[:, :, :, so])
    + np.einsum("pm, Atmq -> Atpq", F_0_mo, to_natm_3(gradh_nr.U_1))
    + np.einsum("mq, Atmp -> Atpq", F_0_mo, to_natm_3(gradh_nr.U_1))
)

数值导数我们记为 nd_F_0_mo

[68]:
nd_F_0_mo = NumericDiff(gradn, lambda gradh: gradh.F_0_mo).derivative.reshape(natm, 3, nao, nao)

但比较有意思的是,这次我们不能简单地通过查看图像来判断是否上述数值与解析导数是一致的:

[69]:
fig, ax = plt.subplots(figsize=(4, 3))
ax.hist(abs(d_F_0_mo.ravel() - nd_F_0_mo.ravel()), bins=np.logspace(np.log10(1e-11), np.log10(1e-1), 50), alpha=0.5)
ax.hist(abs(nd_F_0_mo.ravel()), bins=np.logspace(np.log10(1e-11), np.log10(1e-1), 50), alpha=0.5)
ax.set_xscale("log")
fig.tight_layout()

这是因为,\(\partial F_{pq} = \varepsilon_p \delta_{pq}\),因此所有非对角元都会是零值;其导数也不例外。因此,若要验证我们计算出来的解析导数,需要做两件事。

首先是判断非对角元是否近乎为零。我们先构造一个 delta_Atpq \(\delta_{pq}^{A_t}\),它会用来去除对角元的干扰;随后我们计算其余矩阵元的平均绝对值。

[70]:
delta_Atpq = np.eye(nmo).reshape(1, nmo, nmo).repeat(natm * 3, axis=0).reshape(natm, 3, nmo, nmo)
print("Average:", abs(d_F_0_mo * (1 - delta_Atpq)).sum() / (natm * 3 * nmo * (nmo - 1)))
print("Maximum:", np.max(d_F_0_mo * (1 - delta_Atpq)))
Average: 5.093565934419702e-07
Maximum: 2.5572817114088162e-05

其次再是判断对角元是否接近。我们活用 np.diagonal 函数来查看情况:

[71]:
fig, ax = plt.subplots(figsize=(4, 3))
ax.hist(abs(d_F_0_mo.diagonal(axis1=-2, axis2=-1).ravel() - nd_F_0_mo.diagonal(axis1=-2, axis2=-1).ravel()), bins=np.logspace(np.log10(1e-11), np.log10(1e-1), 50), alpha=0.5)
ax.hist(abs(nd_F_0_mo.diagonal(axis1=-2, axis2=-1).ravel()), bins=np.logspace(np.log10(1e-11), np.log10(1e-1), 50), alpha=0.5)
ax.set_xscale("log")
fig.tight_layout()

任务 (5)

我们需要利用的函数 resp 定义如下:

\[\mathtt{resp}_{\mu \nu} [X_{\kappa \lambda}^\mathbb{A}] = \big( (\mu \nu | \kappa \lambda) - \frac{1}{2} (\mu \kappa| \nu \lambda) \big) X_{\kappa \lambda}^\mathbb{A}\]
[72]:
resp = _gen_rhf_response(gradh.scf_eng, mo_coeff=C, mo_occ=gradh.mo_occ, hermi=0)

而原子轨道下的 A 矩阵定义如下:

\[A_{\mu \nu, \kappa \lambda} = 4 (\mu \nu | \kappa \lambda) - (\mu \kappa | \nu \lambda) - (\mu \lambda | \kappa \nu)\]

现在我们考虑

\[\begin{split}\begin{align} \mathtt{resp}_{\mu \nu} [X_{\kappa \lambda}^\mathbb{A} + X_{\lambda \kappa}^\mathbb{A}] &= \big( (\mu \nu | \kappa \lambda) - \frac{1}{2} (\mu \kappa| \nu \lambda) \big) X_{\kappa \lambda}^\mathbb{A} + \big( (\mu \nu | \kappa \lambda) - \frac{1}{2} (\mu \kappa| \nu \lambda) \big) X_{\lambda \kappa}^\mathbb{A} \\ &= \big( (\mu \nu | \kappa \lambda) - \frac{1}{2} (\mu \kappa| \nu \lambda) \big) X_{\kappa \lambda}^\mathbb{A} + \big( (\mu \nu | \lambda \kappa) - \frac{1}{2} (\mu \lambda| \nu \kappa) \big) X_{\kappa \lambda}^\mathbb{A} \\ &= \big( 2 (\mu \nu | \kappa \lambda) - \frac{1}{2} (\mu \kappa| \nu \lambda) - \frac{1}{2} (\mu \lambda| \nu \kappa) \big) X_{\kappa \lambda}^\mathbb{A} \\ &= \frac{1}{2} A_{\mu \nu, \kappa \lambda} X_{\kappa \lambda}^\mathbb{A} \end{align}\end{split}\]

从而,

\[\begin{split}\begin{align} A_{pq, mi} U_{mi}^\mathbb{A} &= C_{\mu p} C_{\nu q} A_{\mu \nu, \kappa \lambda} C_{\kappa m} C_{\lambda i} U_{mi}^\mathbb{A} \\ &= 2 C_{\mu p} C_{\nu q} \mathtt{resp}_{\mu \nu} [C_{\kappa m} C_{\lambda i} U_{mi}^\mathbb{A} + C_{\lambda m} C_{\kappa i} U_{mi}^\mathbb{A}] \end{align}\end{split}\]

我们下面就计算 \(2 C_{\mu p} C_{\nu q} [C_{\kappa m} C_{\lambda i} U_{mi}^\mathbb{A} + \mathrm{swap} (\kappa, \lambda)]\)。首先我们会定义一个临时变量 dmU \(U_{\kappa \lambda}^{A_t}\)

\[U_{\kappa \lambda}^{A_t} = C_{\kappa m} U_{mi}^{A_t} C_{\lambda i} + \mathrm{swap} (\kappa, \lambda)\]
[73]:
dmU = np.einsum("km, Atmi, li -> Atkl", C, to_natm_3(gradh_nr.U_1[:, :, so]), Co)
dmU += dmU.swapaxes(-1, -2)

随后,我们就可以求取 \(A_{pq, mi} U_{mi}^{A_t}\)。用 resp 函数求取的结果我们记在 AU_nr_resp 中;但需要注意到,输入给 resp 的张量必须是只有三个维度的:

[74]:
AU_nr_resp = 2 * np.einsum("up, vq, Atuv -> Atpq", C, C, to_natm_3(resp(dmU.reshape(natm * 3, nao, nao))))

最后我们验证一下上述计算是否正确。我们直接对 A_0_mo[:, :, :, so] \(A_{pq, mi}\)to_natm_3(gradh_nr.U_1[:, :, so])) \(U_{mi}^{A_t}\) 作张量缩并即可:

[75]:
np.allclose(AU_nr_resp, np.einsum("pqmi, Atmi -> Atpq", A_0_mo[:, :, :, so], to_natm_3(gradh_nr.U_1[:, :, so])))
[75]:
True

任务 (6)

待证等式是

\[\frac{\partial F_{pq}}{\partial \mathbb{A}} = F_{pq}^\mathbb{A} - S_{pq}^\mathbb{A} \varepsilon_q - \frac{1}{2} A_{pq, kl} S_{kl}^\mathbb{A} + A_{pq, bj} U_{bj}^\mathbb{A} + (\varepsilon_p - \varepsilon_q) U_{pq}^\mathbb{A}\]

证明前提是

\[\frac{\partial F_{pq}}{\partial \mathbb{A}} = F_{pq}^\mathbb{A} + A_{pq, mi} U_{mi}^\mathbb{A} + F_{pm} U_{mq}^\mathbb{A} + F_{mq} U_{mp}^\mathbb{A}\]

我们先看后面两项。根据 \(F_{pq} = \varepsilon_p \delta_{pq}\),后两项有

\[\frac{\partial F_{pq}}{\partial \mathbb{A}} \leftarrow F_{pm} U_{mq}^\mathbb{A} + F_{mq} U_{mp}^\mathbb{A} = \varepsilon_p U_{pq}^\mathbb{A} + \varepsilon_q U_{qp}^\mathbb{A}\]

利用 \(U_{pq}^\mathbb{A} + U_{qp}^\mathbb{A} + S_{pq}^\mathbb{A} = 0\),我们能推出 \(U_{qp}^\mathbb{A} = - S_{pq}^\mathbb{A} - U_{pq}^\mathbb{A}\);因此上式化为

\[\frac{\partial F_{pq}}{\partial \mathbb{A}} \leftarrow (\varepsilon_p - \varepsilon_q) U_{pq}^\mathbb{A} - S_{pq}^\mathbb{A} \varepsilon_q\]

我们再看 \(A_{pq, mi} U_{mi}^\mathbb{A}\) 一项。我们拆开 \(m\) 作为占据轨道与非占轨道的情况,给出

\[\begin{split}\begin{align} \frac{\partial F_{pq}}{\partial \mathbb{A}} \leftarrow A_{pq, mi} U_{mi}^\mathbb{A} &= A_{pq, kl} U_{kl}^\mathbb{A} + A_{pq, bj} U_{bj}^\mathbb{A} \\ &= \frac{1}{2} (A_{pq, kl} U_{kl}^\mathbb{A} + A_{pq, lk} U_{lk}^\mathbb{A}) + A_{pq, bj} U_{bj}^\mathbb{A} \\ &= \frac{1}{2} A_{pq, kl} (U_{kl}^\mathbb{A} + U_{lk}^\mathbb{A}) + A_{pq, bj} U_{bj}^\mathbb{A} \\ &= - \frac{1}{2} A_{pq, kl} S_{kl}^\mathbb{A} + A_{pq, bj} U_{bj}^\mathbb{A} \end{align}\end{split}\]

上述证明过程中,第二个等号利用到 \(k, l\) 角标可交换;第三个等号利用到 A 张量的对称性 \(A_{pq, kl} = A_{pq, lk}\);第四个等号利用到的是 \(U_{pq}^\mathbb{A} + U_{qp}^\mathbb{A} + S_{pq}^\mathbb{A} = 0\)

至此就证明完毕了。下面我们通过待证等式的等式右生成 \(\partial_{A_t} F_{pq}\),生成的结果放在 d_F_0_mo_simp 中:

\[\frac{\partial F_{pq}}{\partial A_t} = F_{pq}^\mathbb{A} - S_{pq}^{A_t} \varepsilon_q - \frac{1}{2} A_{pq, kl} S_{kl}^{A_t} + A_{pq, bj} U_{bj}^{A_t} + (\varepsilon_p - \varepsilon_q) U_{pq}^{A_t}\]
[76]:
d_F_0_mo_simp = (
    + F_1_mo
    - S_1_mo * e
    - 0.5 * Ax0_Core(sa, sa, so, so)(S_1_mo[:, :, so, so])
    + Ax0_Core(sa, sa, sv, so)(to_natm_3(gradh_nr.U_1_vo))
    + (e[:, None] - e[None, :]) * (to_natm_3(gradh_nr.U_1))
)

我们在任务 (4) 中已经生成过 d_F_0_mo \(\partial_{A_t} F_{pq}\);我们可以核验这两者是否等价:

[77]:
np.allclose(d_F_0_mo_simp, d_F_0_mo, atol=1e-5)
[77]:
True

任务 (7)

我们求取的目标 U_1_vv \(U_{ab}^{A_t}\)

\[\begin{split}\begin{equation} \left\{ \begin{matrix} U_{ab}^\mathbb{A} &= - \frac{B_{ab}^\mathbb{A} + A_{ab, ck} U_{ck}^\mathbb{A}}{\varepsilon_a - \varepsilon_b} &\quad (a \neq b) \\ U_{aa}^\mathbb{A} &= - \frac{1}{2} S_{aa}^\mathbb{A} \end{matrix} \right. \end{equation}\end{split}\]
[78]:
U_1_vv = - (B_1[:, :, sv, sv] + Ax0_Core(sv, sv, sv, so)(U_1_ai)) / (ev[:, None] - ev[None, :])
for p in range(nocc, nmo):
    U_1_vv[:, :, p - nocc, p - nocc] = - S_1_mo[:, :, p, p] / 2
U_1_vv.shape
[78]:
(4, 3, 13, 13)

我们验证一下是否与 pyxdh 的结果一致:

[79]:
np.allclose(U_1_vv.ravel(), gradh_nr.U_1[:, sv, sv].ravel())
[79]:
True

MP2 核坐标梯度

我们的最终目标是 XYG3 型泛函的梯度。我们已经讨论过 RHF 的核坐标梯度和 U 矩阵的计算了,现在我们就尝试进阶的 MP2 梯度。尽管 MP2 梯度也仍然不需要显式地使用 U 矩阵进行计算,但上一节中 U 矩阵的推导过程,对这一节的理解可以有很大帮助。

准备工作

需要注意到,我们这一节的主要目标是实现 MP2 梯度,因此前几篇文档的 GradSCF 在这里就要换成 GradMP2

[1]:
%matplotlib notebook

from pyscf import gto, scf, dft, lib
from pyscf.scf import cphf
import numpy as np
from functools import partial
import warnings
from matplotlib import pyplot as plt
from pyxdh.Utilities import NucCoordDerivGenerator, DipoleDerivGenerator, NumericDiff
from pyxdh.DerivOnce import GradSCF, GradMP2

np.einsum = partial(np.einsum, optimize=["greedy", 1024 ** 3 * 2 / 8])
np.allclose = partial(np.allclose, atol=1e-6, rtol=1e-4)
np.set_printoptions(5, linewidth=150, suppress=True)
warnings.filterwarnings("ignore")
[2]:
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()
[2]:
<pyscf.gto.mole.Mole at 0x7f93644a0b50>
[3]:
gradh = GradMP2({"scf_eng": scf.RHF(mol), "cphf_tol": 1e-12})
gradh_nr = GradMP2({"scf_eng": scf.RHF(mol), "cphf_tol": 1e-12, "rotation": False})
[4]:
nmo, nao, natm, nocc, nvir = gradh.nao, gradh.nao, gradh.natm, gradh.nocc, gradh.nvir
so, sv, sa = gradh.so, gradh.sv, gradh.sa
C, Co, Cv, e, eo, ev, D = gradh.C, gradh.Co, gradh.Cv, gradh.e, gradh.eo, gradh.ev, gradh.D
H_0_ao, S_0_ao, eri0_ao, F_0_ao = gradh.H_0_ao, gradh.S_0_ao, gradh.eri0_ao, gradh.F_0_ao
H_0_mo, S_0_mo, eri0_mo, F_0_mo = gradh.H_0_mo, gradh.S_0_mo, gradh.eri0_mo, gradh.F_0_mo
[5]:
def to_natm_3(mat: np.ndarray):
    shape = list(mat.shape)
    shape = [int(shape[0] / 3), 3] + shape[1:]
    return mat.reshape(shape)
[6]:
H_1_ao, S_1_ao, eri1_ao = to_natm_3(gradh.H_1_ao), to_natm_3(gradh.S_1_ao), to_natm_3(gradh.eri1_ao)
H_1_mo, S_1_mo, eri1_mo = to_natm_3(gradh.H_1_mo), to_natm_3(gradh.S_1_mo), to_natm_3(gradh.eri1_mo)
[7]:
def grad_generator(mol):
    scf_eng = scf.RHF(mol)
    config = {"scf_eng": scf.RHF(mol), "cphf_tol": 1e-12}
    return GradMP2(config)
[8]:
gradn = NucCoordDerivGenerator(mol, grad_generator)

除了上一篇文档在准备工作中引入的变量之外,我们再定义上一节中讨论的 U 矩阵的相关定义:

  • F_1_ao \(F_{\mu \nu}^{A_t}\), F_1_mo \(F_{pq}^{A_t}\)

  • U_1 \(\mathscr{U}_{mp}^{A_t}\), U_1_nr \(U_{mp}^{A_t}\), U_1_vo \(U_{ai}^{A_t}\)

  • B_1 \(B_{pq}^{A_t}\)

  • Ax0_Core 用来计算 A 张量的缩并

[9]:
F_1_ao, F_1_mo = to_natm_3(gradh.F_1_ao), to_natm_3(gradh.F_1_mo)
U_1, U_1_nr, U_1_vo = to_natm_3(gradh.U_1), to_natm_3(gradh_nr.U_1), to_natm_3(gradh.U_1_vo)
B_1 = to_natm_3(gradh.B_1)
Ax0_Core = gradh.Ax0_Core

MP2 相关能与直接求导

MP2 相关能

我们先回顾一下 MP2 相关能的计算,以及一些张量的定义。

\[\begin{split}\begin{align} E_\mathrm{MP2, c} &= T_{ij}^{ab} t_{ij}^{ab} D_{ij}^{ab} \\ T_{ij}^{ab} &= 2 t_{ij}^{ab} - t_{ij}^{ba} \\ t_{ij}^{ab} &= (ia|jb) / D_{ij}^{ab} \\ D_{ij}^{ab} &= \varepsilon_i - \varepsilon_a + \varepsilon_j - \varepsilon_b \end{align}\end{split}\]

在 pyxdh 程序中,T_iajb\(T_{ij}^{ab}\)t_iajb\(t_{ij}^{ab}\)D_iajb\(D_{ij}^{ab}\)。它们的维度信息都是 \((i, a, j, b)\)。MP2 相关能需要从总能量减去 RHF 的自洽场能量 gradh.eng - gradh.scf_eng.e_tot 获得。

[10]:
T_iajb, t_iajb, D_iajb = gradh.T_iajb, gradh.t_iajb, gradh.D_iajb
print("MP2 Correlation Energy:", gradh.eng - gradh.scf_eng.e_tot)
MP2 Correlation Energy: -0.2690117759995019

因此,MP2 相关能可以通过下述代码验证:

[11]:
np.allclose(
    np.einsum("iajb, iajb, iajb -> ", T_iajb, t_iajb, D_iajb),
    gradh.eng - gradh.scf_eng.e_tot
)
[11]:
True

直接求导法得到 MP2 梯度

一直以来,我们都是普通地使用链式法则求出能量梯度。事实上,MP2 梯度也可以通过直接的链式求导得到。我们下面就简单讨论这个问题。

首先,我们需要求出 \(\varepsilon_p\) 的导数。我们上一节提到过 \(\partial_{A_t} F_{pq}\) 的计算,事实上由于 \(F_{pq} = \delta_{pq} \varepsilon_p\),因此其 e_1 \(\partial_{A_t} \varepsilon_p\) 的求取也就不言而喻:

\[\varepsilon_p^{A_t} = \frac{\partial \varepsilon_p}{\partial A_t} = B_{pp}^{A_t} + A_{pp, bj} U_{bj}^{A_t}\]
[12]:
e_1 = B_1.diagonal(axis1=-2, axis2=-1) + Ax0_Core(sa, sa, sv, so)(U_1_vo).diagonal(axis1=-2, axis2=-1)
e_1.shape
[12]:
(4, 3, 22)
[13]:
nd_e_0 = NumericDiff(gradn, lambda gradh: gradh.e).derivative
fig, ax = plt.subplots(figsize=(4, 3)); ax.set_xscale("log")
ax.hist(abs(e_1.ravel() - nd_e_0.ravel()), bins=np.logspace(np.log10(1e-10), np.log10(1e-1), 50), alpha=0.5)
ax.hist(abs(nd_e_0.ravel()), bins=np.logspace(np.log10(1e-10), np.log10(1e-1), 50), alpha=0.5)
fig.tight_layout()

因此,d_D_iajb \(\partial_{A_t} D_{ij}^{ab}\) 可以写为

\[\frac{\partial D_{ij}^{ab}}{\partial A_t} = \frac{\partial \varepsilon_i}{\partial A_t} - \frac{\partial \varepsilon_a}{\partial A_t} + \frac{\partial \varepsilon_j}{\partial A_t} - \frac{\partial \varepsilon_b}{\partial A_t}\]
[14]:
d_D_iajb = e_1[:, :, so, None, None, None] - e_1[:, :, None, sv, None, None] + e_1[:, :, None, None, so, None] - e_1[:, :, None, None, None, sv]
d_D_iajb.shape
[14]:
(4, 3, 9, 13, 9, 13)
[15]:
nd_D_iajb = NumericDiff(gradn, lambda gradh: gradh.D_iajb).derivative
fig, ax = plt.subplots(figsize=(4, 3)); ax.set_xscale("log")
ax.hist(abs(d_D_iajb.ravel() - nd_D_iajb.ravel()), bins=np.logspace(np.log10(1e-10), np.log10(1e-1), 50), alpha=0.5)
ax.hist(abs(nd_D_iajb.ravel()), bins=np.logspace(np.log10(1e-10), np.log10(1e-1), 50), alpha=0.5)
fig.tight_layout()

随后我们给出导数 d_eri0_iajb \(\partial_{A_t} (ia|jb)\)

\[\partial_{A_t} (ia|jb) = (ia|jb)^{A_t} + (ma|jb) U_{mi}^{A_t} + (im|jb) U_{ma}^{A_t} + (ia|mb) U_{mj}^{A_t} + (ia|jm) U_{mb}^{A_t}\]

需要注意到,这里需要使用未经轨道旋转的 \(U_{mp}^{A_t}\) 而不能是经过轨道旋转的 \(\mathscr{U}_{mp}^{A_t}\)

[16]:
d_eri0_iajb = (
    + eri1_mo[:, :, so, sv, so, sv]
    + np.einsum("majb, Atmi -> Atiajb", eri0_mo[sa, sv, so, sv], U_1_nr[:, :, sa, so])
    + np.einsum("imjb, Atma -> Atiajb", eri0_mo[so, sa, so, sv], U_1_nr[:, :, sa, sv])
    + np.einsum("iamb, Atmj -> Atiajb", eri0_mo[so, sv, sa, sv], U_1_nr[:, :, sa, so])
    + np.einsum("iajm, Atmb -> Atiajb", eri0_mo[so, sv, so, sa], U_1_nr[:, :, sa, sv])
)
d_eri0_iajb.shape
[16]:
(4, 3, 9, 13, 9, 13)
[17]:
nd_eri0_iajb = NumericDiff(gradn, lambda gradh: gradh.eri0_mo[so, sv, so, sv]).derivative
fig, ax = plt.subplots(figsize=(4, 3)); ax.set_xscale("log")
ax.hist(abs(d_eri0_iajb.ravel() - nd_eri0_iajb.ravel()), bins=np.logspace(np.log10(1e-10), np.log10(1e-1), 50), alpha=0.5)
ax.hist(abs(nd_eri0_iajb.ravel()), bins=np.logspace(np.log10(1e-10), np.log10(1e-1), 50), alpha=0.5)
fig.tight_layout()

有了 d_D_iajb \(\partial_{A_t} D_{ij}^{ab}\)d_eri0_iajb \(\partial_{A_t} (ia|jb)\) 后,我们就可以求取 d_t_iajb \(\partial_{A_t} t_{ij}^{ab}\)

\[\frac{\partial t_{ij}^{ab}}{\partial A_t} = \frac{\partial_{A_t} (ia|jb)}{D_{ij}^{ab}} - \frac{(ia|jb) \cdot \partial_{A_t} D_{ij}^{ab}}{(D_{ij}^{ab})^2}\]
[18]:
d_t_iajb = (d_eri0_iajb / D_iajb - eri0_mo[so, sv, so, sv] * d_D_iajb / D_iajb**2)
d_t_iajb.shape
[18]:
(4, 3, 9, 13, 9, 13)
[19]:
nd_t_iajb = NumericDiff(gradn, lambda gradh: gradh.t_iajb).derivative
fig, ax = plt.subplots(figsize=(4, 3)); ax.set_xscale("log")
ax.hist(abs(d_t_iajb.ravel() - nd_t_iajb.ravel()), bins=np.logspace(np.log10(1e-10), np.log10(1e-1), 50), alpha=0.5)
ax.hist(abs(nd_t_iajb.ravel()), bins=np.logspace(np.log10(1e-10), np.log10(1e-1), 50), alpha=0.5)
fig.tight_layout()

由于 \(T_{ij}^{ab} = 2 t_{ij}^{ab} - t_{ij}^{ba}\),因此 d_T_iajb \(\partial_{A_t} T_{ij}^{ab} = 2 \partial_{A_t} t_{ij}^{ab} - \partial_{A_t} t_{ij}^{ba}\)

[20]:
d_T_iajb = 2 * d_t_iajb - d_t_iajb.swapaxes(-1, -3)

由此,我们可以得到 E_1_MP2_contrib

\[\frac{\partial E_\mathrm{MP2, c}}{\partial A_t} = \partial_{A_t} T_{ij}^{ab} t_{ij}^{ab} D_{ij}^{ab} + T_{ij}^{ab} \partial_{A_t} t_{ij}^{ab} D_{ij}^{ab} + T_{ij}^{ab} t_{ij}^{ab} \partial_{A_t} D_{ij}^{ab}\]
[21]:
E_1_MP2_contrib = (
    + d_T_iajb * t_iajb * D_iajb
    + T_iajb * d_t_iajb * D_iajb
    + T_iajb * t_iajb * d_D_iajb
).sum(axis=(-1, -2, -3, -4))
E_1_MP2_contrib
[21]:
array([[ 0.03581, -0.00086,  0.05372],
       [-0.00427,  0.02169, -0.06404],
       [-0.03018, -0.00096, -0.00777],
       [-0.00137, -0.01988,  0.01809]])

上面是 MP2 相关能的梯度贡献大小。而对于总能量而言,\(\partial_{A_t} E = \partial_{A_t} E_\mathrm{RHF} + \partial_{A_t} E_\mathrm{MP2, c}\),因此我们还需要补上 RHF 的总梯度贡献 \(\partial_{A_t} E_\mathrm{RHF}\)

这里我们可以借助于 GradMP2 继承于 GradSCF 类,使用两种程序的小技巧,来从 MP2 梯度计算实例 gradh 中提出 RHF 部分的梯度。一种方法是使用 python 自带的 super 函数调出父类的梯度计算函数 _get_E_1

[22]:
super(GradMP2, gradh)._get_E_1()
[22]:
array([[-0.06727,  0.06951,  0.0961 ],
       [ 0.01291,  0.14195, -0.11756],
       [ 0.03423,  0.01409,  0.03949],
       [ 0.02013, -0.22555, -0.01803]])

但上述方法在 菱形继承 关系下时容易出错。另一种更清晰稳妥的做法是

[23]:
GradSCF._get_E_1(gradh)
[23]:
array([[-0.06727,  0.06951,  0.0961 ],
       [ 0.01291,  0.14195, -0.11756],
       [ 0.03423,  0.01409,  0.03949],
       [ 0.02013, -0.22555, -0.01803]])

将 RHF 部分与 MP2 相关能部分的贡献相加,即可得到总 MP2 梯度:

[24]:
GradSCF._get_E_1(gradh) + E_1_MP2_contrib
[24]:
array([[-0.03146,  0.06865,  0.14982],
       [ 0.00864,  0.16364, -0.1816 ],
       [ 0.00405,  0.01313,  0.03173],
       [ 0.01876, -0.24543,  0.00006]])

这也与 pyxdh 所给出的总梯度一致:

[25]:
gradh.E_1
[25]:
array([[-0.03146,  0.06865,  0.14982],
       [ 0.00864,  0.16364, -0.1816 ],
       [ 0.00405,  0.01313,  0.03173],
       [ 0.01876, -0.24543,  0.00006]])

我们可以用总 MP2 能量的数值梯度来验证上面计算的解析梯度:

[26]:
d_E_0 = NumericDiff(gradn, lambda gradh: gradh.eng).derivative.reshape(natm, 3)
np.allclose(GradSCF._get_E_1(gradh) + E_1_MP2_contrib, d_E_0)
[26]:
True

但需要知道,上述的计算过程较为耗时,多次使用多个四维或高维张量的缩并,不适合作为一般 MP2 梯度计算的方法,因此我们会换用另一种看起来更复杂的做法。尽管我们后文所使用的计算方法也仍然是需要四维或高维张量的存储与缩并,但它可以较为容易地被量化程序优化到两维度的存储复杂度。

MP2 一阶梯度常规做法

常规来说,MP2 相关能梯度分为三部分考虑 (Aikens, eq.24)

\[\partial_\mathbb{A} E_\mathrm{MP2, c} = D_{pq}^\mathrm{MP2} h_{pq}^\mathbb{A} + W_{pq}^\mathrm{MP2} S_{pq}^\mathbb{A} + \Gamma_{\mu \nu \kappa \lambda}^\mathrm{MP2} (\mu \nu | \kappa \lambda)^\mathbb{A}\]

后文中,我们分别称三部分为弛豫密度贡献、加权密度贡献、以及双粒子密度贡献。但在 pyxdh 中,我们使用下述方式来实现 MP2 相关能梯度:

\[\partial_\mathbb{A} E_\mathrm{MP2, c} = D_{pq}^\mathrm{MP2} B_{pq}^\mathbb{A} + W_{pq}^\mathrm{MP2} [\mathrm{I}] S_{pq}^\mathbb{A} + \Gamma_{\mu \nu \kappa \lambda}^\mathrm{MP2} (\mu \nu | \kappa \lambda)^\mathbb{A} \tag{1}\]

我们将会用我们的记号,简单地推导上式并作程序实现。由于 MP2 梯度的推导几乎是连贯的,因此这一节的分段会相当少。

公式重述与 \(\Gamma_{\mu \nu \kappa \lambda}^\mathrm{MP2} (\mu \nu | \kappa \lambda)^\mathbb{A}\)

我们回顾到 MP2 相关能的另一种定义方式:

\[E_\mathrm{MP2, c} = \big( 2 (ia|jb) - (ib|ja) \big) (ia|jb) (D_{ij}^{ab})^{-1}\]
[27]:
np.einsum("iajb, iajb, iajb -> ", 2 * eri0_mo[so, sv, so, sv] - eri0_mo[so, sv, so, sv].swapaxes(-1, -3), eri0_mo[so, sv, so, sv], D_iajb**-1)
[27]:
-0.26901177599951337

以此为前提,我们推导 MP2 相关能梯度。

\[\begin{split}\begin{align} \frac{E_\mathrm{MP2, c}}{\partial \mathbb{A}} &= \big( 2 \partial_\mathbb{A} (ia|jb) - \partial_\mathbb{A} (ib|ja) \big) (ia|jb) (D_{ij}^{ab})^{-1} \\ &\quad + \big( 2 (ia|jb) - (ib|ja) \big) \partial_\mathbb{A} (ia|jb) (D_{ij}^{ab})^{-1} \\ &\quad + \big( 2 (ia|jb) - (ib|ja) \big) (ia|jb) \partial_\mathbb{A} (D_{ij}^{ab})^{-1} \tag{2} \\ \end{align}\end{split}\]

我们先讨论第一项。由于上式是对 \(i, a, j, b\) 求和的,因此角标 \(a, b\) 是可以交换的;因此

\[\begin{split}\begin{align} \frac{E_\mathrm{MP2, c}}{\partial \mathbb{A}} &\leftarrow \big( 2 \partial_\mathbb{A} (ia|jb) - \partial_\mathbb{A} (ib|ja) \big) (ia|jb) (D_{ij}^{ab})^{-1} \\ &= \big( 2 \partial_\mathbb{A} (ia|jb) \cdot (ia|jb) - \partial_\mathbb{A} (ib|ja) \cdot (ia|jb) \big) (D_{ij}^{ab})^{-1} \\ &= \big( 2 \partial_\mathbb{A} (ia|jb) \cdot (ia|jb) - \partial_\mathbb{A} (ia|jb) \cdot (ib|ja) \big) (D_{ij}^{ab})^{-1} \\ &= \big( 2 (ia|jb) - (ib|ja) \big) \partial_\mathbb{A} (ia|jb) (D_{ij}^{ab})^{-1} \\ &= T_{ij}^{ab} \partial_\mathbb{A} (ia|jb) \end{align}\end{split}\]

这就与式 (2) 贡献项中的第二项相等了。而对于式 (2) 的第三项,则有

\[\begin{split}\begin{align} \frac{E_\mathrm{MP2, c}}{\partial \mathbb{A}} &\leftarrow \big( 2 (ia|jb) - (ib|ja) \big) (ia|jb) \partial_\mathbb{A} (D_{ij}^{ab})^{-1} \\ &= - \big( 2 (ia|jb) - (ib|ja) \big) (ia|jb) \cdot (D_{ij}^{ab})^{-2} \partial_\mathbb{A} D_{ij}^{ab} \\ &= - T_{ij}^{ab} t_{ij}^{ab} (\varepsilon_i^\mathbb{A} + \varepsilon_j^\mathbb{A} - \varepsilon_a^\mathbb{A} - \varepsilon_b^\mathbb{A}) \\ &= - 2 T_{ij}^{ab} t_{ij}^{ab} (\varepsilon_i^\mathbb{A} - \varepsilon_a^\mathbb{A}) \end{align}\end{split}\]

任务 (1)

证明 (不采用 Einstein Summation)

\[\sum_{ijab} t_{ij}^{ab} T_{ij}^{ab} \varepsilon_i^\mathbb{A} = \sum_{ijab} t_{ij}^{ab} T_{ij}^{ab} \varepsilon_j^\mathbb{A}\]

综合上述两个结果,我们进一步得到

\[\frac{E_\mathrm{MP2, c}}{\partial \mathbb{A}} = 2 T_{ij}^{ab} \partial_\mathbb{A} (ia|jb) - 2 T_{ij}^{ab} t_{ij}^{ab} (\varepsilon_i^\mathbb{A} - \varepsilon_a^\mathbb{A}) \tag{3}\]

下面我们先处理 \(2 T_{ij}^{ab} \partial_\mathbb{A} (ia|jb)\) 一项:

\[\begin{split}\begin{align} \frac{E_\mathrm{MP2, c}}{\partial \mathbb{A}} &\leftarrow 2 T_{ij}^{ab} \partial_\mathbb{A} (ia|jb) \\ &= \color{red}{2 T_{ij}^{ab} (ia|jb)^\mathbb{A}} + \color{blue}{2 U_{mi}^\mathbb{A} (ma|jb) T_{ij}^{ab}} + 2 U_{ma}^\mathbb{A} (im|jb) T_{ij}^{ab} + \color{blue}{2 U_{mj}^\mathbb{A} (ia|mb) T_{ij}^{ab}} + 2 U_{mb}^\mathbb{A} (ia|jm) T_{ij}^{ab} \tag{4} \end{align}\end{split}\]

我们先看式 (4) 红色项。我们若定义

\[\Gamma_{\mu \nu \kappa \lambda}^\mathrm{MP2} = 2 T_{ij}^{ab} C_{\mu i} C_{\nu a} C_{\kappa j} C_{\lambda b} \tag{5}\]

那么,我们就给出了式 (1) 中的第 3 个 \(\partial_\mathbb{A} E_\mathrm{MP2, c}\) 贡献项 \(\Gamma_{\mu \nu \kappa \lambda}^\mathrm{MP2} (\mu \nu | \kappa \lambda)^\mathbb{A}\) E_1_MP2_contrib3。尽管在其它量化程序中可能会确实地求出 \(\Gamma_{\mu \nu \kappa \lambda}^\mathrm{MP2}\);但事实上,在 pyxdh 中该 MP2 梯度贡献还是通过 \(2 T_{ij}^{ab} (ia|jb)^\mathbb{A}\) 得到的。

[28]:
E_1_MP2_contrib3 = 2 * np.einsum("iajb, Atiajb -> At", T_iajb, eri1_mo[:, :, so, sv, so, sv])
E_1_MP2_contrib3
[28]:
array([[ 0.01888,  0.00077,  0.02607],
       [-0.00123,  0.01517, -0.04045],
       [-0.01616, -0.00135,  0.00038],
       [-0.0015 , -0.01459,  0.014  ]])

U 矩阵占据-占据、非占-非占部分的消除

之所以我们需要消除 U 矩阵占据-占据、非占-非占部分,是因为这些分块的 U 矩阵会出现数值问题。U 矩阵的数值问题我们在上一篇文档中已经有所讨论。我们这里列举两种 U 矩阵占据-占据的消除方式。

  • 利用对称性。譬如对于矩阵 \(X_{ij} = X_{ji}\)

    \[\sum_{ij} U_{ij}^\mathbb{A} X_{ij} = \frac{1}{2} \sum_{ij} U_{ij}^\mathbb{A} (X_{ij} + X_{ji}) = \frac{1}{2} \sum_{ij} (U_{ij}^\mathbb{A} + U_{ji}^\mathbb{A}) X_{ij} = - \frac{1}{2} \sum_{ij} S_{ij}^\mathbb{A} X_{ij}\]
  • 利用 \(\partial_\mathbb{A} F_{ij} = \delta_{ij} \varepsilon_i^\mathbb{A}\)

    \[(\varepsilon_i - \varepsilon_j) U_{ij}^\mathbb{A} = \sum_{ck} \big( - B_{ij}^\mathbb{A} - A_{ij, ck} U_{ck}^\mathbb{A} \big)\]

非占-非占部分的消除也是相同的。

看式 (4) 的蓝色项。首先根据求和角标 \((i, j, a, b)\) 和 ERI 积分的对称性,可知两个蓝色项完全相等,即

\[\begin{split}\begin{align} \frac{E_\mathrm{MP2, c}}{\partial \mathbb{A}} &\leftarrow 2 U_{mi}^\mathbb{A} (ma|jb) T_{ij}^{ab} + 2 U_{mj}^\mathbb{A} (ia|mb) T_{ij}^{ab} \\ &= 4 U_{mi}^\mathbb{A} (ma|jb) T_{ij}^{ab} \\ &= \color{red}{4 U_{ki}^\mathbb{A} (ka|jb) T_{ij}^{ab}} + 4 U_{ci}^\mathbb{A} (ca|jb) T_{ij}^{ab} \tag{6} \end{align}\end{split}\]

我们对红色项作进一步讨论。我们有

\[\begin{split}\begin{align} \frac{E_\mathrm{MP2, c}}{\partial \mathbb{A}} &\leftarrow 4 U_{ki}^\mathbb{A} (ka|jb) T_{ij}^{ab} \\ &= 2 U_{ki}^\mathbb{A} (ka|jb) T_{ij}^{ab} - 2 U_{ik}^\mathbb{A} (ka|jb) T_{ij}^{ab} - 2 S_{ik}^\mathbb{A} (ka|jb) T_{ij}^{ab} \\ &= 2 U_{ki}^\mathbb{A} (ka|jb) T_{ij}^{ab} - 2 U_{ki}^\mathbb{A} (ia|jb) T_{kj}^{ab} - 2 S_{ik}^\mathbb{A} (ka|jb) T_{ij}^{ab} \\ &= 2 U_{ki}^\mathbb{A} t_{kj}^{ab} D_{kj}^{ab} T_{ij}^{ab} - 2 U_{ki}^\mathbb{A} t_{ij}^{ab} D_{ij}^{ab} T_{kj}^{ab} - 2 S_{ik}^\mathbb{A} (ka|jb) T_{ij}^{ab} \\ &= 2 U_{ki}^\mathbb{A} (D_{kj}^{ab} - D_{ij}^{ab}) t_{ij}^{ab} T_{kj}^{ab} - 2 S_{ik}^\mathbb{A} (ka|jb) T_{ij}^{ab} \\ &= 2 U_{ki}^\mathbb{A} (\varepsilon_k - \varepsilon_i) t_{ij}^{ab} T_{kj}^{ab} - 2 S_{ik}^\mathbb{A} (ka|jb) T_{ij}^{ab} \\ &= 2 \big( (\varepsilon_k - \varepsilon_i) U_{ki}^\mathbb{A} + A_{ki, cl} U_{cl}^\mathbb{A} + B_{ki}^\mathbb{A} \big) t_{ij}^{ab} T_{kj}^{ab} - 2 \big( A_{ki, cl} U_{cl}^\mathbb{A} + B_{ki}^\mathbb{A} \big) t_{ij}^{ab} T_{kj}^{ab} - 2 S_{ik}^\mathbb{A} (ka|jb) T_{ij}^{ab} \\ &= 2 \delta_{ik} \varepsilon_i^\mathbb{A} t_{ij}^{ab} T_{kj}^{ab} - 2 \big( A_{ki, cl} U_{cl}^\mathbb{A} + B_{ki}^\mathbb{A} \big) t_{ij}^{ab} T_{kj}^{ab} - 2 S_{ik}^\mathbb{A} (ka|jb) T_{ij}^{ab} \\ &= 2 \varepsilon_i^\mathbb{A} t_{ij}^{ab} T_{ij}^{ab} - 2 \big( A_{ki, cl} U_{cl}^\mathbb{A} + B_{ki}^\mathbb{A} \big) t_{ij}^{ab} T_{kj}^{ab} - 2 S_{ik}^\mathbb{A} (ka|jb) T_{ij}^{ab} \\ &= 2 T_{ij}^{ab} t_{ij}^{ab} \varepsilon_i^\mathbb{A} - 2 U_{dl}^\mathbb{A} A_{dl, ij} T_{ik}^{ab} t_{jk}^{ab} - 2 B_{ij}^\mathbb{A} T_{ik}^{ab} t_{jk}^{ab} - 2 S_{ij}^\mathbb{A} T_{ik}^{ab} (ja|kb) \end{align}\end{split}\]

上式的推导很长,我们将推导利用到的结论列举如下:

  • 等号 1:利用 \(U_{ki}^\mathbb{A} + U_{ik}^\mathbb{A} + S_{ik}^\mathbb{A} = 0\)

  • 等号 2:利用求和中 \(i, k\) 角标可交换;

  • 等号 3:利用定义导出式 \((ia|jb) = t_{ij}^{ab} D_{ij}^{ab}\)

  • 等号 4:参考下面任务 (2) 的证明思路;

  • 等号 5:直接套用 \(D_{ij}^{ab} = \varepsilon_i + \varepsilon_j - \varepsilon_a - \varepsilon_b\)

  • 等号 7:利用 \(\partial_\mathbb{A} F_{ik} = (\varepsilon_k - \varepsilon_i) U_{ki}^\mathbb{A} + A_{ki, cl} U_{cl}^\mathbb{A} + B_{ki}^\mathbb{A} = \delta_{ik} \varepsilon_i^\mathbb{A}\)

  • 等号 9:仅仅是交换与更换了一些角标,并利用了 \(A_{pq, rs} = A_{rs, pq}\) 的对称性。

任务 (2)

证明 (不采用 Einstein Summation)

\[\sum_{ijkab} t_{ij}^{ab} T_{kj}^{ab} = \sum_{ijkab} t_{kj}^{ab} T_{ij}^{ab}\]

该推导很长,我们可以从程序上验证一下正确性:

\[\frac{E_\mathrm{MP2, c}}{\partial \mathbb{A}} \leftarrow 4 U_{ki}^\mathbb{A} (ka|jb) T_{ij}^{ab} = 2 T_{ij}^{ab} t_{ij}^{ab} \varepsilon_i^\mathbb{A} - 2 U_{dl}^\mathbb{A} A_{dl, ij} T_{ik}^{ab} t_{jk}^{ab} - 2 B_{ij}^\mathbb{A} T_{ik}^{ab} t_{jk}^{ab} - 2 S_{ij}^\mathbb{A} T_{ik}^{ab} (ja|kb)\]
[29]:
4 * np.einsum("Atki, kajb, iajb -> At", U_1_nr[:, :, so, so], eri0_mo[so, sv, so, sv], T_iajb)
[29]:
array([[ 0.01297, -0.00873, -0.01071],
       [-0.00166,  0.01218,  0.00496],
       [-0.01077, -0.0003 , -0.00653],
       [-0.00055, -0.00315,  0.01228]])
[30]:
(
    + 2 * np.einsum("iajb, iajb, Ati -> At", T_iajb, t_iajb, e_1[:, :, so])
    - 2 * np.einsum("Atdl, dl -> At", U_1_vo, Ax0_Core(sv, so, so, so)(np.einsum("iakb, jakb -> ij", T_iajb, t_iajb)))
    - 2 * np.einsum("Atij, iakb, jakb -> At", B_1[:, :, so, so], T_iajb, t_iajb)
    - 2 * np.einsum("Atij, iakb, jakb -> At", S_1_mo[:, :, so, so], T_iajb, eri0_mo[so, sv, so, sv])
)
[30]:
array([[ 0.01297, -0.00873, -0.01071],
       [-0.00166,  0.01218,  0.00496],
       [-0.01077, -0.0003 , -0.00653],
       [-0.00055, -0.00315,  0.01228]])

因此,我们可以将式 (4) 的蓝色项写为

\[\begin{split}\begin{align} \frac{E_\mathrm{MP2, c}}{\partial \mathbb{A}} &\leftarrow 2 U_{mi}^\mathbb{A} (ma|jb) T_{ij}^{ab} + 2 U_{mj}^\mathbb{A} (ia|mb) T_{ij}^{ab} \\ &= 2 T_{ij}^{ab} t_{ij}^{ab} \varepsilon_i^\mathbb{A} + \color{red}{U_{dl}^\mathbb{A} \big( - 2 A_{dl, ij} T_{ik}^{ab} t_{jk}^{ab} + 4 T_{lj}^{ab} (da|jb) \big) - 2 B_{ij}^\mathbb{A} T_{ik}^{ab} t_{jk}^{ab}} \color{blue}{- 2 S_{ij}^\mathbb{A} T_{ik}^{ab} (ja|kb)} \tag{7} \end{align}\end{split}\]

类似地,我们会将式 (4) 的黑色项写为

\[\begin{split}\begin{align} \frac{E_\mathrm{MP2, c}}{\partial \mathbb{A}} &\leftarrow 2 U_{ma}^\mathbb{A} (im|jb) T_{ij}^{ab} + 2 U_{mb}^\mathbb{A} (ia|jm) T_{ij}^{ab} = 4 U_{ma}^\mathbb{A} (im|jb) T_{ij}^{ab} \\ &= - 2 T_{ij}^{ab} t_{ij}^{ab} \varepsilon_a^\mathbb{A} + \color{red}{U_{dl}^\mathbb{A} \big( 2 A_{dl, ab} T_{ij}^{ac} t_{ij}^{bc} - 4 T_{ij}^{db} (il|jb) \big) + 2 B_{ab}^\mathbb{A} T_{ij}^{ac} t_{ij}^{bc}} \color{blue}{- 2 S_{ab}^\mathbb{A} T_{ij}^{ac} (ib|jc) - 4 S_{ai}^\mathbb{A} T_{jk}^{ab} (ij|bk)} \tag{8} \end{align}\end{split}\]

演示 (1) 连续公式推导与程序的相互验证

我们将会按上文的推导方式,展示式 (8) 的推导。但这么复杂的推导很容易出现失误,我们事实上可以依靠程序来增加公式推导的容错率。这种做法也相当多地应用在 pyxdh 整个程序的开发中。

现在我们综合考虑 (3), (4), (7), (8),我们就发现所有与 \(\varepsilon_p^\mathbb{A}\) 的项就都消去,并得到:

\[\begin{split}\begin{align} \frac{E_\mathrm{MP2, c}}{\partial \mathbb{A}} &= \color{green}{2 T_{ij}^{ab} \partial_\mathbb{A} (ia|jb)} \\ &\quad \color{red}{+ U_{dl}^\mathbb{A} \big( - 2 A_{dl, ij} T_{ik}^{ab} t_{jk}^{ab} + 2 A_{dl, ab} T_{ij}^{ac} t_{ij}^{bc} + 4 T_{lj}^{ab} (da|jb) - 4 T_{ij}^{db} (il|jb) \big)} \\ &\quad \color{red}{- 2 B_{ij}^\mathbb{A} T_{ik}^{ab} t_{jk}^{ab} + 2 B_{ab}^\mathbb{A} T_{ij}^{ac} t_{ij}^{bc}} \\ &\quad \color{blue}{- 2 S_{ij}^\mathbb{A} T_{ik}^{ab} (ja|kb) - 2 S_{ab}^\mathbb{A} T_{ij}^{ac} (ib|jc) - 4 S_{ai}^\mathbb{A} T_{jk}^{ab} (ij|bk)} \tag{9} \end{align}\end{split}\]

在前几篇文档所规定的符号体系下,这应当近乎于是最简化了的、消除了 \(U_{ij}^\mathbb{A}\)\(U_{ab}^\mathbb{A}\) 的 MP2 相关能梯度表达了。用程序可以编写如下:

[31]:
(
    # Line 1
    + 2 * np.einsum("iajb, Atiajb -> At", T_iajb, eri1_mo[:, :, so, sv, so, sv])
    # Line 2
    - 2 * np.einsum("Atdl, dl -> At", U_1_vo, Ax0_Core(sv, so, so, so)(np.einsum("iakb, jakb -> ij", T_iajb, t_iajb)))
    + 2 * np.einsum("Atdl, dl -> At", U_1_vo, Ax0_Core(sv, so, sv, sv)(np.einsum("iajc, ibjc -> ab", T_iajb, t_iajb)))
    + 4 * np.einsum("Atdl, lajb, dajb -> At", U_1_vo, T_iajb, eri0_mo[sv, sv, so, sv])
    - 4 * np.einsum("Atdl, idjb, iljb -> At", U_1_vo, T_iajb, eri0_mo[so, so, so, sv])
    # Line 3
    - 2 * np.einsum("Atij, iakb, jakb -> At", B_1[:, :, so, so], T_iajb, t_iajb)
    + 2 * np.einsum("Atab, iajc, ibjc -> At", B_1[:, :, sv, sv], T_iajb, t_iajb)
    # Line 4
    - 2 * np.einsum("Atij, iakb, jakb -> At", S_1_mo[:, :, so, so], T_iajb, eri0_mo[so, sv, so, sv])
    - 2 * np.einsum("Atab, iajc, ibjc -> At", S_1_mo[:, :, sv, sv], T_iajb, eri0_mo[so, sv, so, sv])
    - 4 * np.einsum("Atai, jakb, ijbk -> At", S_1_mo[:, :, sv, so], T_iajb, eri0_mo[so, so, sv, so])
)
[31]:
array([[ 0.03581, -0.00086,  0.05372],
       [-0.00427,  0.02169, -0.06404],
       [-0.03018, -0.00096, -0.00777],
       [-0.00137, -0.01988,  0.01809]])

我们能看到上述程序确实与我们曾经计算过的 \(\partial_\mathbb{A} E_\mathrm{MP2, c}\) E_1_MP2_contrib 相同。

[32]:
E_1_MP2_contrib
[32]:
array([[ 0.03581, -0.00086,  0.05372],
       [-0.00427,  0.02169, -0.06404],
       [-0.03018, -0.00096, -0.00777],
       [-0.00137, -0.01988,  0.01809]])

式 (9) 中,绿色部分就是双粒子密度贡献 \(2 T_{ij}^{ab} \partial_\mathbb{A} (ia|jb)\) E_1_MP2_contrib3,我们已经实现过了;而红色项是 \(D_{pq}^\mathrm{MP2} B_{pq}^\mathbb{A}\) E_1_MP2_contrib1,蓝色项是 \(W_{pq}^\mathrm{MP2} [\mathrm{I}] S_{pq}^\mathbb{A}\) E_1_MP2_contrib2。下面我们会定义这些变量,对 MP2 的梯度表达式进行化简。

\(W_{pq}^\mathrm{MP2} [\mathrm{I}] S_{pq}^\mathbb{A}\)

我们定义,

\[\frac{E_\mathrm{MP2, c}}{\partial \mathbb{A}} \leftarrow W_{pq}^\mathrm{MP2} [\mathrm{I}] S_{pq}^\mathbb{A} = - 2 S_{ij}^\mathbb{A} T_{ik}^{ab} (ja|kb) - 2 S_{ab}^\mathbb{A} T_{ij}^{ac} (ib|jc) - 4 S_{ai}^\mathbb{A} T_{jk}^{ab} (ij|bk)\]

其中,W_I \(W_{pq}^\mathrm{MP2} [\mathrm{I}]\) 是一个分块拼凑出来的、非对称的矩阵。它的原型是加权密度矩阵 \(W_{pq}^\mathrm{MP2}\),但 pyxdh 有意为简化公式表达,不计算全部加权密度矩阵,而只保留其中的第一部分:(Aikens, eq.181 - eq.183)

\begin{equation} \begin{aligned} W_{ij}^\mathrm{MP2} [\mathrm{I}] &= - 2 T_{ik}^{ab} (ja|kb) \\ W_{ab}^\mathrm{MP2} [\mathrm{I}] &= - 2 T_{ij}^{ac} (ib|jc) \\ W_{ai}^\mathrm{MP2} [\mathrm{I}] &= - 4 T_{jk}^{ab} (ij|bk) \\ W_{ia}^\mathrm{MP2} [\mathrm{I}] &= 0 \end{aligned} \tag{10} \end{equation}

[33]:
W_I = np.zeros((nmo, nmo))
W_I = np.zeros((nmo, nmo))
W_I[so, so] = - 2 * np.einsum("iakb, jakb -> ij", T_iajb, eri0_mo[so, sv, so, sv])
W_I[sv, sv] = - 2 * np.einsum("iajc, ibjc -> ab", T_iajb, eri0_mo[so, sv, so, sv])
W_I[sv, so] = - 4 * np.einsum("jakb, ijbk -> ai", T_iajb, eri0_mo[so, so, sv, so])
W_I.shape
[33]:
(22, 22)

留意到该矩阵与被求导量 \(\mathbb{A}\) 无关,因此可以看成与分子和方法有关的内秉性质。在 pyxdh 中,其对应的 property 是 W_I

[34]:
np.allclose(W_I, gradh.W_I)
[34]:
True

因此,E_1_MP2_contrib2 \(W_{pq}^\mathrm{MP2} [\mathrm{I}] S_{pq}^\mathbb{A}\) 可以写为

[35]:
E_1_MP2_contrib2 = np.einsum("pq, Atpq -> At", W_I, S_1_mo)
E_1_MP2_contrib2
[35]:
array([[-0.00323, -0.00809, -0.00192],
       [-0.00236, -0.00146,  0.00845],
       [ 0.0028 ,  0.00276, -0.00533],
       [ 0.00279,  0.00679, -0.0012 ]])

\(D_{pq}^\mathrm{MP2} B_{pq}^\mathbb{A}\) 与 Z-Vector 方法

我们定义,

\[\begin{split}\begin{align} \frac{E_\mathrm{MP2, c}}{\partial \mathbb{A}} &\leftarrow D_{pq}^\mathrm{MP2} B_{pq}^\mathbb{A} \\ &= + U_{dl}^\mathbb{A} \big( - 2 A_{dl, ij} T_{ik}^{ab} t_{jk}^{ab} + 2 A_{dl, ab} T_{ij}^{ac} t_{ij}^{bc} + 4 T_{lj}^{ab} (da|jb) - 4 T_{ij}^{db} (il|jb) \big) \\ &\quad - 2 B_{ij}^\mathbb{A} T_{ik}^{ab} t_{jk}^{ab} + 2 B_{ab}^\mathbb{A} T_{ij}^{ac} t_{ij}^{bc} \end{align}\end{split}\]

其中,D_r \(D_{pq}^\mathrm{MP2}\) 代表 MP2 的弛豫密度 (它不包含 SCF 密度部分)。它同 \(W_{pq} [\mathrm{I}]\) 一样,与被求导量 \(\mathbb{A}\) 无关;但它的导出稍复杂。

我们首先定义 D_r_oovv \(D_{pq}^\text{MP2, oo-vv}\)\(D_{pq}^\mathrm{MP2}\) 的占据-占据与非占-非占部分;它仅仅是为了程序方便而用:(Aikens, eq.177 - eq.178)

\begin{equation} \begin{aligned} D_{ij}^\text{MP2} &= - 2 T_{ik}^{ab} t_{jk}^{ab} \\ D_{ab}^\text{MP2} &= 2 T_{ij}^{ac} t_{ij}^{bc} \\ \end{aligned} \tag{11} \end{equation}

[36]:
D_r_oovv = np.zeros((nmo, nmo))
D_r_oovv[so, so] = - 2 * np.einsum("iakb, jakb -> ij", T_iajb, t_iajb)
D_r_oovv[sv, sv] = 2 * np.einsum("iajc, ibjc -> ab", T_iajb, t_iajb)

那么,

\[\begin{align} \frac{E_\mathrm{MP2, c}}{\partial \mathbb{A}} \leftarrow D_{pq}^\mathrm{MP2} B_{pq}^\mathbb{A} = U_{dl}^\mathbb{A} \big( A_{dl, pq} D_{pq}^\text{MP2, oo-vv} + 4 T_{lj}^{ab} (da|jb) - 4 T_{ij}^{db} (il|jb) \big) + B_{pq}^\mathbb{A} D_{pq}^\text{MP2, oo-vv} \end{align}\]

随后,我们会定义 L L 矩阵;它也与被求导量 \(\mathbb{A}\) 无关:(Aikens, eq.159)

\[\begin{split}\begin{align} L_{ai} &= A_{ai, kl} D_{kl}^\mathrm{MP2} + A_{ai, bc} D_{bc}^\mathrm{MP2} - 4 T_{jk}^{ab} (ij|bk) + 4 T_{ij}^{bc} (ab|jc) \\ &= A_{ai, pq} D_{pq}^\mathrm{MP2, oo-vv} - 4 T_{jk}^{ab} (ij|bk) + 4 T_{ij}^{bc} (ab|jc) \end{align}\end{split}\]
[37]:
L = (
    + Ax0_Core(sv, so, sa, sa)(D_r_oovv)
    - 4 * np.einsum("jakb, ijbk -> ai", T_iajb, eri0_mo[so, so, sv, so])
    + 4 * np.einsum("ibjc, abjc -> ai", T_iajb, eri0_mo[sv, sv, so, sv])
)

那么,

\[\begin{align} \frac{E_\mathrm{MP2, c}}{\partial \mathbb{A}} \leftarrow D_{pq}^\mathrm{MP2} B_{pq}^\mathbb{A} = U_{ai}^\mathbb{A} L_{ai} + B_{pq}^\mathbb{A} D_{pq}^\text{MP2, oo-vv} \end{align}\]

下面我们用 Z-Vector 方法,对上式中的 \(U_{ai}^\mathbb{A} L_{ai}\) 作处理。(Aikens, eq.58 - eq.64)

上一篇 文档 中,我们引入了记号

\[A'_{ai, bj} = (D_i^a \delta_{ai, bj} - A_{ai, bj}) = (\varepsilon_i - \varepsilon_a) \delta_{ai, bj} - A_{ai, bj}\]

这里我们仍然沿用该记号,且将 \(A'_{ai, bj}\) 当作二维的矩阵来处理,两个维度大小都是 \(n_\mathrm{occ} n_\mathrm{vir}\) 大小。那么,我们可以将 CP-HF 方程写为

\[\mathbf{A}' \mathbf{U}^\mathbb{A} = \mathbf{B}^\mathbb{A}\]

而 U 矩阵的非占-占据部分,对我们有意义的是 \(\mathrm{tr} (\mathbf{L}^\dagger \mathbf{U}^\mathbb{A})\)。我们注意到 \(\mathbf{A}'\) 是一个对称的矩阵,因此

\[\mathrm{tr} (\mathbf{L}^\dagger \mathbf{U}^\mathbb{A}) = \mathrm{tr} (\mathbf{L}^\dagger \mathbf{A}'{}^{-1} \mathbf{B}^\mathbb{A}) = \mathrm{tr} ((\mathbf{B}^\mathbb{A})^\dagger \mathbf{A}'{}^{-1} \mathbf{L})\]

我们定义,弛豫密度在非占-占据部分为 D_r_vo \(D_{pq}^\text{MP2, vo}\)

\[\mathbf{D}^\text{MP2, vo} = \mathbf{A}'{}^{-1} \mathbf{L}\]

因此,它满足下述方程:

\[\mathbf{A}'{}^{-1} \mathbf{D}^\text{MP2, vo} \mathbf{L}\]

或者等价地,

\[- (\varepsilon_a - \varepsilon_i) D_{ai}^\mathrm{MP2} - A_{ai, bj} D_{bj}^\mathrm{MP2} = L_{ai} \tag{12}\]

因此,

\[\mathrm{tr} (\mathbf{L}^\dagger \mathbf{U}^\mathbb{A}) = \mathrm{tr} ((\mathbf{B}^\mathbb{A})^\dagger \mathbf{D}^\text{MP2, vo}) = B_{ai}^\mathbb{A} D_{ai}^\mathrm{MP2}\]

我们在以后的文档中,仍然称其为 CP-HF 方程。尽管这是不恰当的:它的推演并非与得到 \(U_{ai}^\mathrm{MP2}\) 的方程的前提相同;但不论是方程形式还是求解方式,都与 CP-HF 方程无异。

[38]:
D_r_vo = cphf.solve(Ax0_Core(sv, so, sv, so), e, gradh.mo_occ, L, max_cycle=100)[0]

最后,我们定义

\[D_{ia}^\mathrm{MP2} = 0 \tag{13}\]

综合上述定义,D_r 表示为

\begin{equation} \begin{aligned} D_{ij}^\text{MP2} &= - 2 T_{ik}^{ab} t_{jk}^{ab} \\ D_{ab}^\text{MP2} &= 2 T_{ij}^{ac} t_{ij}^{bc} \\ - (\varepsilon_a - \varepsilon_i) D_{ai}^\mathrm{MP2} - A_{ai, bj} D_{bj}^\mathrm{MP2} &= L_{ai} \\ D_{ia}^\mathrm{MP2} &= 0 \end{aligned} \tag{11} \end{equation}

[39]:
D_r = np.zeros((nmo, nmo))
D_r[so, so] = - 2 * np.einsum("iakb, jakb -> ij", T_iajb, t_iajb)
D_r[sv, sv] = 2 * np.einsum("iajc, ibjc -> ab", T_iajb, t_iajb)
D_r[sv, so] = D_r_vo

在 pyxdh 中,D_r property 对应的是弛豫密度:

[40]:
np.allclose(D_r, gradh.D_r)
[40]:
True

经过上述的定义后,我们能写 E_1_MP2_contrib1 \(\partial_\mathbb{A} E_\mathrm{MP2, c} \leftarrow D_{pq}^\mathrm{MP2} B_{pq}^\mathbb{A}\)

[41]:
E_1_MP2_contrib1 = np.einsum("pq, Atpq -> At", D_r, B_1)

这里我们就验证 MP2 的所有相关能梯度贡献项作求和来收尾:

[42]:
E_1_MP2_contrib1 + E_1_MP2_contrib2 + E_1_MP2_contrib3
[42]:
array([[ 0.03581, -0.00086,  0.05372],
       [-0.00427,  0.02169, -0.06404],
       [-0.03018, -0.00096, -0.00777],
       [-0.00137, -0.01988,  0.01809]])
[43]:
np.allclose(E_1_MP2_contrib1 + E_1_MP2_contrib2 + E_1_MP2_contrib3, E_1_MP2_contrib)
[43]:
True

演示任务

演示 (1) 连续公式推导与程序的相互验证

我们推导的主要目的是将 U 矩阵的非占-非占部分消除。我们先把需要被消除的 U 矩阵部分拉出来作推导:

\[\begin{split}\begin{align} \frac{E_\mathrm{MP2, c}}{\partial \mathbb{A}} &\leftarrow 4 U_{ca}^\mathbb{A} (ic|jb) T_{ij}^{ab} \\ &= 2 U_{ca}^\mathbb{A} (ic|jb) T_{ij}^{ab} - 2 U_{ac}^\mathbb{A} (ic|jb) T_{ij}^{ab} - 2 S_{ca}^\mathbb{A} (ic|jb) T_{ij}^{ab} \\ &= 2 U_{ca}^\mathbb{A} (ic|jb) T_{ij}^{ab} - 2 U_{ca}^\mathbb{A} (ia|jb) T_{ij}^{cb} - 2 S_{ca}^\mathbb{A} (ic|jb) T_{ij}^{ab} \\ &= 2 U_{ca}^\mathbb{A} T_{ij}^{ab} t_{ij}^{cb} (D_{ij}^{cb} - D_{ij}^{ab}) - 2 S_{ca}^\mathbb{A} (ic|jb) T_{ij}^{ab} \\ &= - 2 T_{ij}^{ab} t_{ij}^{cb} (\varepsilon_c - \varepsilon_a) U_{ca}^\mathbb{A} - 2 S_{ca}^\mathbb{A} (ic|jb) T_{ij}^{ab} \\ &= 2 T_{ij}^{ab} t_{ij}^{cb} (A_{ca, dl} U_{dl}^\mathbb{A} + B_{ca}^\mathbb{A} - \delta_{ac} \varepsilon_a^\mathbb{A}) - 2 S_{ca}^\mathbb{A} (ic|jb) T_{ij}^{ab} \\ &= - 2 T_{ij}^{ab} t_{ij}^{ab} \varepsilon_a^\mathbb{A} + 2 U_{dl}^\mathbb{A} A_{dl, ab} T_{ij}^{ac} t_{ij}^{bc} + 2 B_{ab}^\mathbb{A} T_{ij}^{ac} t_{ij}^{bc} - 2 S_{ab}^\mathbb{A} T_{ij}^{ac} (ib|jc) \end{align}\end{split}\]

首先我们先求出 \(4 U_{ca}^\mathbb{A} (ic|jb) T_{ij}^{ab}\) 的具体数值:

[44]:
4 * np.einsum("Atca, icjb, iajb -> At", U_1_nr[:, :, sv, sv], eri0_mo[so, sv, so, sv], T_iajb)
[44]:
array([[-0.03207, -0.00416, -0.05835],
       [-0.00011, -0.03822,  0.07614],
       [ 0.03329, -0.00135, -0.00213],
       [-0.00111,  0.04373, -0.01566]])

下一步是利用 \(U_{ca}^\mathbb{A} + U_{ac}^\mathbb{A} + S_{ca}^\mathbb{A} = 0\) 的特性,得到 \(2 U_{ca}^\mathbb{A} (ic|jb) T_{ij}^{ab} - 2 U_{ac}^\mathbb{A} (ic|jb) T_{ij}^{ab} - 2 S_{ca}^\mathbb{A} (ic|jb) T_{ij}^{ab}\)

[45]:
(
    + 2 * np.einsum("Atca, icjb, iajb -> At", U_1_nr[:, :, sv, sv], eri0_mo[so, sv, so, sv], T_iajb)
    - 2 * np.einsum("Atac, icjb, iajb -> At", U_1_nr[:, :, sv, sv], eri0_mo[so, sv, so, sv], T_iajb)
    - 2 * np.einsum("Atca, icjb, iajb -> At", S_1_mo[:, :, sv, sv], eri0_mo[so, sv, so, sv], T_iajb)
)
[45]:
array([[-0.03207, -0.00416, -0.05835],
       [-0.00011, -0.03822,  0.07614],
       [ 0.03329, -0.00135, -0.00213],
       [-0.00111,  0.04373, -0.01566]])

下一步,我们将会交换第二项中的 \(a, c\) 角标。我们先不要删除原来的程序,而先静默注释了与 \(- 2 U_{ac}^\mathbb{A} (ic|jb) T_{ij}^{ab}\) 有关的一项,再补上 \(- 2 U_{ca}^\mathbb{A} (ia|jb) T_{ij}^{cb}\) 一项:

[46]:
(
    + 2 * np.einsum("Atca, icjb, iajb -> At", U_1_nr[:, :, sv, sv], eri0_mo[so, sv, so, sv], T_iajb)
    - 2 * np.einsum("Atca, icjb, iajb -> At", S_1_mo[:, :, sv, sv], eri0_mo[so, sv, so, sv], T_iajb)
  # - 2 * np.einsum("Atac, icjb, iajb -> At", U_1_nr[:, :, sv, sv], eri0_mo[so, sv, so, sv], T_iajb)
    - 2 * np.einsum("Atca, iajb, icjb -> At", U_1_nr[:, :, sv, sv], eri0_mo[so, sv, so, sv], T_iajb)
)
[46]:
array([[-0.03207, -0.00416, -0.05835],
       [-0.00011, -0.03822,  0.07614],
       [ 0.03329, -0.00135, -0.00213],
       [-0.00111,  0.04373, -0.01566]])

一般来说,我们将没有被修改的程序放在上方,而被修改的程序放在下方;要作好注释,这样若程序写错了,那么我们取消注释并删除更改的那行,就可以恢复原先的正确 (但仍待简化) 的程序。

再譬如,若我们为了推导:

\[\begin{split}\begin{align} \frac{E_\mathrm{MP2, c}}{\partial \mathbb{A}} &\leftarrow 2 T_{ij}^{ab} t_{ij}^{cb} (A_{ca, dl} U_{dl}^\mathbb{A} + B_{ca}^\mathbb{A} - \delta_{ac} \varepsilon_a^\mathbb{A}) - 2 S_{ca}^\mathbb{A} (ic|jb) T_{ij}^{ab} \\ &= - 2 T_{ij}^{ab} t_{ij}^{ab} \varepsilon_a^\mathbb{A} + 2 U_{dl}^\mathbb{A} A_{dl, ab} T_{ij}^{ac} t_{ij}^{bc} + 2 B_{ab}^\mathbb{A} T_{ij}^{ac} t_{ij}^{bc} - 2 S_{ab}^\mathbb{A} T_{ij}^{ac} (ib|jc) \end{align}\end{split}\]

第一行可以程序化为

[47]:
(
    + 2 * np.einsum("iajb, icjb, Atca -> At", T_iajb, t_iajb, (
        + Ax0_Core(sv, sv, sv, so)(U_1_vo)
        + B_1[:, :, sv, sv]
        - np.einsum("ac, Ata -> Atca", np.eye(nvir), e_1[:, :, sv])
    ))
    - 2 * np.einsum("Atca, icjb, iajb -> At", S_1_mo[:, :, sv, sv], eri0_mo[so, sv, so, sv], T_iajb)
)
[47]:
array([[-0.03207, -0.00416, -0.05835],
       [-0.00011, -0.03822,  0.07614],
       [ 0.03329, -0.00135, -0.00213],
       [-0.00111,  0.04373, -0.01566]])

由于这一步有拆项的目的,我们先不要一次性用程序表示最终的公式,而先把程序的第二级公式展开到第一级:

[48]:
(
    + 2 * np.einsum("iajb, icjb, Atca -> At", T_iajb, t_iajb, Ax0_Core(sv, sv, sv, so)(U_1_vo))
    + 2 * np.einsum("iajb, icjb, Atca -> At", T_iajb, t_iajb, B_1[:, :, sv, sv])
    - 2 * np.einsum("iajb, icjb, ca, Ata -> At", T_iajb, t_iajb, np.eye(nvir), e_1[:, :, sv])
    - 2 * np.einsum("Atca, icjb, iajb -> At", S_1_mo[:, :, sv, sv], eri0_mo[so, sv, so, sv], T_iajb)
)
[48]:
array([[-0.03207, -0.00416, -0.05835],
       [-0.00011, -0.03822,  0.07614],
       [ 0.03329, -0.00135, -0.00213],
       [-0.00111,  0.04373, -0.01566]])

下一步的目的是更改角标和整理公式。我们对每行都要留档,即复制一行后注释;尽量不要贪图省事,直接修改程序:

[49]:
(
  # - 2 * np.einsum("iajb, icjb, ca, Ata -> At", T_iajb, t_iajb, np.eye(nvir), e_1[:, :, sv])
    - 2 * np.einsum("Ata, iajb, iajb -> At", e_1[:, :, sv], T_iajb, t_iajb)
  # + 2 * np.einsum("iajb, icjb, Atca -> At", T_iajb, t_iajb, Ax0_Core(sv, sv, sv, so)(U_1_vo))
    + 2 * np.einsum("Atdl, dl -> At", U_1_vo, Ax0_Core(sv, so, sv, sv)(np.einsum("iajc, ibjc", T_iajb, t_iajb)))
  # + 2 * np.einsum("iajb, icjb, Atca -> At", T_iajb, t_iajb, B_1[:, :, sv, sv])
    + 2 * np.einsum("Atab, iajc, ibjc -> At", B_1[:, :, sv, sv], T_iajb, t_iajb)
  # - 2 * np.einsum("Atca, icjb, iajb -> At", S_1_mo[:, :, sv, sv], eri0_mo[so, sv, so, sv], T_iajb)
    - 2 * np.einsum("Atab, iajc, ibjc -> At", S_1_mo[:, :, sv, sv], T_iajb, eri0_mo[so, sv, so, sv])
)
[49]:
array([[-0.03207, -0.00416, -0.05835],
       [-0.00011, -0.03822,  0.07614],
       [ 0.03329, -0.00135, -0.00213],
       [-0.00111,  0.04373, -0.01566]])

这样,我们可以一步一步地,让程序与推导能相互验证,避免单纯在草纸上推演公式时容易出错、效率容易底下的情况。

式 (8) 其余部分的推导就留待读者验证了。

参考任务解答

任务 (1)

\[\sum_{ijab} t_{ij}^{ab} T_{ij}^{ab} \varepsilon_i^\mathbb{A} = \sum_{ijab} t_{ji}^{ba} T_{ji}^{ba} \varepsilon_i^\mathbb{A} = \sum_{ijab} t_{ij}^{ab} T_{ij}^{ab} \varepsilon_j^\mathbb{A}\]

这里第一个等号利用的是 \(t_{ij}^{ab} = t_{ji}^{ba}\) 的对称性,第二个等号则是交换了角标 \((ia, jb)\)。但需要留意,在我们的定义中,\(t_{ij}^{ab} \not\equiv - t_{ij}^{ba}\),因此不能利用该对称性。

任务 (2)

\[\begin{split}\begin{align} \sum_{ijkab} t_{ij}^{ab} T_{kj}^{ab} &= \sum_{ijkab} \frac{(ia|jb) \big( 2 (ka|jb) - (kb|ja) \big)}{D_{ij}^{ab} D_{kj}^{ab}} \\ &= \sum_{ijkab} \frac{2 (ia|jb) (ka|jb) - (ia|jb) (kb|ja)}{D_{ij}^{ab} D_{kj}^{ab}} \\ &= \sum_{ijkab} \frac{2 (ia|jb) (ka|jb) - (ib|ja) (ka|jb)}{D_{ij}^{ab} D_{kj}^{ab}} \\ &= \sum_{ijkab} \frac{(ka|jb) \big( 2 (ia|jb) - (ib|ja) \big)}{D_{ij}^{ab} D_{kj}^{ab}} \\ &= \sum_{ijkab} t_{kj}^{ab} T_{ij}^{ab} \end{align}\end{split}\]

GGA 泛函核坐标梯度

我们已经将 RHF 与 MP2 的核坐标梯度表达式求得了;但我们仍然没有达到求解 XYG3 型泛函的目标。从 RHF 方法到 MP2 方法是一个突跃,我们需要掌握 U 矩阵、A 张量的计算方式 (CP-HF 方程),以及对 MP2 方法公式相当繁杂的推导。XYG3 型泛函的绝大多数公式推导,都能从 MP2 公式中获得。另一个突跃会是这篇文档所述的从 RHF 到 GGA 方法;在这个过程中,我们需要对交换相关能的梯度作推演与计算。

这一节,我们首先以 B3LYP 为例 (GGA 在通篇文档中代表的是使用了 GGA 泛函的计算方法,也包括杂化泛函),计算 GGA 自洽场的核坐标梯度。

准备工作

[1]:
%matplotlib notebook

from pyscf import gto, scf, dft, dft, lib
import numpy as np
from functools import partial
import warnings
from matplotlib import pyplot as plt
from pyxdh.Utilities import NucCoordDerivGenerator, DipoleDerivGenerator, NumericDiff, GridHelper, KernelHelper
from pyxdh.DerivOnce import GradSCF

np.einsum = partial(np.einsum, optimize=["greedy", 1024 ** 3 * 2 / 8])
np.allclose = partial(np.allclose, atol=1e-6, rtol=1e-4)
np.set_printoptions(5, linewidth=150, suppress=True)
warnings.filterwarnings("ignore")
[2]:
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()
[2]:
<pyscf.gto.mole.Mole at 0x7f6dc5311b50>

需要注意到,我们在这里需要用 DFT 模块,它的定义还包含格点积分。由于格点与分子直接挂钩,因此我们使用下述 mol_to_grids 定义从分子到 (75, 302) 格点的函数:

[3]:
def mol_to_grids(mol, atom_grid=(75, 302)):
    grids = dft.Grids(mol)
    grids.atom_grid = atom_grid
    grids.becke_scheme = dft.gen_grid.stratmann
    grids.prune = None
    grids.build()
    return grids
grids = mol_to_grids(mol)

我们也需要从分子到计算实例的程序 mol_to_scf,它主要用来生成 pyxdh 的 GGA 梯度计算实例,以及数值梯度的实例:

[4]:
def mol_to_scf(mol):
    scf_eng = dft.RKS(mol)
    scf_eng.grids = mol_to_grids(mol)
    scf_eng.xc = "B3LYPg"
    scf_eng.conv_tol = 1e-10
    return scf_eng.run()

GGA 与 RHF 一样,都使用 GradSCF 来实例化。

[5]:
gradh = GradSCF({"scf_eng": mol_to_scf(mol)})
[6]:
nmo, nao, natm, nocc, nvir = gradh.nao, gradh.nao, gradh.natm, gradh.nocc, gradh.nvir
mol_slice = gradh.mol_slice
so, sv, sa = gradh.so, gradh.sv, gradh.sa
C, Co, Cv, e, eo, ev, D = gradh.C, gradh.Co, gradh.Cv, gradh.e, gradh.eo, gradh.ev, gradh.D
H_0_ao, S_0_ao, eri0_ao, F_0_ao = gradh.H_0_ao, gradh.S_0_ao, gradh.eri0_ao, gradh.F_0_ao
H_0_mo, S_0_mo, eri0_mo, F_0_mo = gradh.H_0_mo, gradh.S_0_mo, gradh.eri0_mo, gradh.F_0_mo
[7]:
def to_natm_3(mat: np.ndarray):
    shape = list(mat.shape)
    shape = [int(shape[0] / 3), 3] + shape[1:]
    return mat.reshape(shape)
[8]:
H_1_ao, S_1_ao, eri1_ao = to_natm_3(gradh.H_1_ao), to_natm_3(gradh.S_1_ao), to_natm_3(gradh.eri1_ao)
H_1_mo, S_1_mo, eri1_mo = to_natm_3(gradh.H_1_mo), to_natm_3(gradh.S_1_mo), to_natm_3(gradh.eri1_mo)
U_1 = to_natm_3(gradh.U_1)

但与 RHF 不同的是,我们需要进行格点积分。我们定义 grdh 为格点的辅助助手,kerh 为泛函核的格点。

[9]:
grdh = GridHelper(mol, grids, D)
kerh = KernelHelper(grdh, "B3LYPg")

同时,我们也需要杂化系数 cx \(c_\mathrm{x}\)

[10]:
cx = gradh.cx
cx
[10]:
0.2
[11]:
def grad_generator(mol):
    scf_eng = mol_to_scf(mol)
    config = {"scf_eng": scf_eng}
    return GradSCF(config)
gradn = NucCoordDerivGenerator(mol, grad_generator)

GGA 能量梯度

首先我们回顾 GGA (B3LYP) 的能量计算公式:

\[E_\mathrm{elec} = h_{\mu \nu} D_{\mu \nu} + \frac{1}{2} D_{\mu \nu} (\mu \nu | \kappa \lambda) D_{\kappa \lambda} - \frac{c_\mathrm{x}}{4} D_{\mu \nu} (\mu \kappa | \nu \lambda) D_{\kappa \lambda} + f \rho\]

其中,前三项分别是 Hamiltonian Core、Coulomb、Exchange 积分对总能量的贡献,使用了 Einstein Summation 进行了符号的简化;而第四项是交换相关能,所用的是本文档特化的简化,即

\[\sum_{w} w_g f_g \rho_g \Leftarrow f \rho \Rightarrow \int f[\rho] \rho(\boldsymbol{r}) \, \mathrm{d} \boldsymbol{r}\]

两种一般来说更容易接受的写法中,右边一种是积分方式,即泛函核 \(f[\rho]\) 与电子态密度 \(\rho(\boldsymbol{r})\) 乘积的积分;而左边则是将积分元 \(\mathrm{d} \boldsymbol{r}\) 离散化为带权重格点,随后对这些格点求和。我们简化的主要目的是让公式能与程序能作对应。我们不妨用下述两行代码,验证一下 B3LYP 下的分子的电子态能量 \(E_\mathrm{elec}\)

[12]:
gradh.scf_eng.energy_elec()[0]
[12]:
-189.26221747920502
[13]:
(
    + np.einsum("uv, uv -> ", H_0_ao, D)
    + 0.5 * np.einsum("uv, uvkl, kl -> ", D, eri0_ao, D)
    - 0.25 * cx * np.einsum("uv, ukvl, kl -> ", D, eri0_ao, D)
    + np.einsum("g, g -> ", kerh.exc, grdh.rho_0)
)
[13]:
-189.2622174792067

符号定义与交换相关能全导数

我们 曾经 对轨道、密度和泛函格点的符号作过补充定义,这里列举如下:

记号说明:轨道函数或格点

  • \(\phi\) 统一代表原子轨道函数,以电子坐标为自变量

  • \(\phi_\mu\) 代表原子轨道 \(\mu\) 所对应的原子轨道函数

  • \(\phi_{r \mu} = \partial_r \phi_\mu\) 代表原子轨道在电子坐标分量 \(r\) 下的偏导数

  • \(\phi_{r w \mu} = \partial_r \partial_w \phi_\mu\) 代表原子轨道在电子坐标分量 \(r\)\(w\) 下的二阶偏导数

记号说明:密度函数或格点

  • \(\rho\) 代表电子态密度密度

  • \(\rho_r = \partial_r \rho\)

  • \(\rho_{rw} = \partial_r \partial_w \rho\)

  • \(\gamma = \rho_r \rho_r\) 表示密度梯度量

记号说明:泛函格点

  • \(f\) 代表泛函核;泛函核满足关系:在函数图景下 \(E_\mathrm{xc} = \int f[\rho] \rho(\boldsymbol{r}) \, \mathrm{d} \boldsymbol{r}\),或格点积分下,\(E_\mathrm{xc} = f \rho\)

  • \(f_\rho = \partial_\rho (f \rho)\)注意不是 \(\partial_\rho f\)这种记号可能引起歧义但足够简洁

  • \(f_\gamma = \partial_\gamma (f \rho)\)

  • \(f_{\rho \gamma} = \partial_\rho \partial_\gamma (f \rho)\),其它高阶导数同理

我们以后仍然使用这些定义。但我们还会补充定义下述符号:

记号补充:导数相关与原子核相关格点

  • \(\phi_\mu^\mathbb{A} = \partial_\mathbb{A} \phi_\mu\) 表示轨道在 \(\mathbb{A}\) 下的导数

  • \(\phi_{t \mu_A}\) 表示在 \(\phi_\mu\) 关于电子坐标分量 \(t\) 下的导数,但若 \(\mu\) 作为 Gaussian 函数的中心并非 \(A\) 原子核,则值为零

  • \(\rho^\mathbb{A}_r\)\(\partial_r \rho\) 的 Skeleton 导数,而 并非 \(\partial_\mathbb{A} \partial_r \rho\)

现在我们对 \(f \rho\) 进行导数计算。依据链式法则,并且留意到 \(\gamma = \rho_r \rho_r\) (对 \(r\) 坐标分量求和),我们可以导出,

\[\begin{split}\begin{align} \frac{\partial (f \rho)}{\partial \mathbb{A}} &= \frac{\partial (f \rho)}{\partial \rho} \frac{\partial \rho}{\partial \mathbb{A}} + \frac{\partial (f \rho)}{\partial \gamma} \frac{\partial \gamma}{\partial \mathbb{A}} \\ &= \frac{\partial (f \rho)}{\partial \rho} \frac{\partial \rho}{\partial \mathbb{A}} + 2 \rho_r \frac{\partial (f \rho)}{\partial \gamma} \frac{\partial \rho_r}{\partial \mathbb{A}} \\ &= f_\rho \partial_\mathbb{A} \rho + 2 f_\gamma \rho_r \partial_\mathbb{A} \rho_r \end{align}\end{split}\]

但我们不能再推演下去了,因为我们尚不知道如何计算 \(\partial_\mathbb{A} \rho\)\(\partial_\mathbb{A} \rho_r\)

密度格点的导数

我们曾经提及过,对于密度矩阵 \(D_{\mu \nu}\),其关于 \(\partial_\mathbb{A}\) 的导数为

\[\frac{\partial D_{\mu \nu}}{\partial \mathbb{A}} = 2 U_{mi}^\mathbb{A} (C_{\mu m} C_{\nu i} + C_{\mu i} C_{\nu m})\]

上面的所有的导数结果都是非 Skeleton 的。

而在密度泛函中,密度并非是由密度矩阵 \(D_{\mu \nu}\) 所表示,而是密度格点 \(\rho\) 表示。密度格点是通过下式给出的,这我们以前也有所提及:

\[\rho = \phi_\mu \phi_\nu D_{\mu \nu}\]

由于显式地引入了轨道,因此密度的格点 存在 Skeleton 导数。我们下面就具体地讨论 \(\mathbb{A} = A_t\) 即被求导量为核坐标分量的情况。为此,我们先列举下述结论:

\[\phi_\mu^{A_t} = \partial_{A_t} \phi_\mu = - \partial_t \phi_{\mu_A} = - \phi_{t \mu_A}\]

上述结论已经在 RHF Skeleton 导数 文档中有较为详细的论述了,这里就不展开了。因此,

\[\frac{\partial \rho}{\partial A_t} = - \phi_{t \mu_A} \phi_\nu D_{\mu \nu} - \phi_\mu \phi_{t \nu_A} D_{\mu \nu} + 4 \phi_\mu \phi_\nu U_{mi}^{A_t} C_{\mu m} C_{\nu i}\]

其中,最后与 U 矩阵有关的部分我们单独考虑,并定义密度格点的 Skeleton 导数 A_rho_1 \(\rho^{A_t}\) 为上式的前两项 (维度为 \((A, t, r, g)\),其中最后一维度为格点维度)

\[\begin{split}\begin{align} \rho^{A_t} &= - \phi_{t \mu_A} \phi_\nu D_{\mu \nu} - \phi_\mu \phi_{t \nu_A} D_{\mu \nu} \\ &= - 2 \phi_{t \mu_A} \phi_\nu D_{\mu \nu} \end{align}\end{split}\]

对程序要作补充的是,尽管 \(D_{\mu \nu}\) 处并没有写成 \(D_{\mu_A \nu}\),但由于前面 \(\phi_{t \mu_A}\) 中要求 \(\mu\) 必须要在 \(A\) 原子核上,因此在实际写程序的时候确实要用 \(D_{\mu_A \nu}\)

[14]:
A_rho_1 = np.zeros((natm, 3, grdh.ngrid))
for A in range(natm):
    sA = mol_slice(A)
    A_rho_1[A] = - 2 * np.einsum("tgu, gv, uv -> tg", grdh.ao_1[:, :, sA], grdh.ao_0, D[sA])

任务 (1)

请证明上述等式的第二个等号。

pyxdh 中,A_rho_1 是用来计算 \(\rho^{A_t}\)

[15]:
np.allclose(A_rho_1, grdh.A_rho_1)
[15]:
True

密度梯度格点的导数

处理 \(\partial_{A_t} \rho_r\) 的方式也是一样的。我们先需要回顾一下 \(\rho_r\) 的定义:

\[\begin{split}\begin{align} \rho_r = \frac{\partial \rho}{\partial r} &= \phi_{r \mu} \phi_\nu D_{\mu \nu} + \phi_\mu \phi_{r \nu} D_{\mu \nu} \\ &= 2 \phi_{r \mu} \phi_\nu D_{\mu \nu} \end{align}\end{split}\]

那么,

\[\frac{\partial \rho_r}{\partial A_t} = - 2 \phi_{tr \mu_A} \phi_\nu D_{\mu \nu} - 2 \phi_{r \mu} \phi_{t \nu_A} D_{\mu \nu} + 4 \phi_{r \mu} \phi_\nu U_{mi}^{A_t} (C_{\mu m} C_{\nu i} + C_{\mu i} C_{\nu m})\]

我们定义 A_rho_2 \(\partial_r^{A_t}\) (维度为 \((A, t, r, g)\)) 为

\[\rho_r^{A_t} = - 2 \phi_{tr \mu_A} \phi_\nu D_{\mu \nu} - 2 \phi_{r \mu} \phi_{t \nu_A} D_{\mu \nu}\]
[16]:
A_rho_2 = np.zeros((natm, 3, 3, grdh.ngrid))
for A in range(natm):
    sA = mol_slice(A)
    A_rho_2[A]  = - 2 * np.einsum("trgu, gv, uv -> trg", grdh.ao_2[:, :, :, sA], grdh.ao_0, D[sA])
    A_rho_2[A] += - 2 * np.einsum("rgu, tgv, uv -> trg", grdh.ao_1, grdh.ao_1[:, :, sA], D[:, sA])

在 pyxdh 中,有 A_rho_2 与之对应:

[17]:
np.allclose(A_rho_2, grdh.A_rho_2)
[17]:
True

任务 (2)

我们曾经不加证明地在 \(\partial_{A_t} \rho\) 表达式中,利用到

\[\begin{split}\begin{align} \partial_{A_t} \rho &\leftarrow 2 \phi_\mu \phi_\nu U_{mi}^{A_t} (C_{\mu m} C_{\nu i} + C_{\mu i} C_{\nu m}) \\ &= 4 \phi_\mu \phi_\nu U_{mi}^{A_t} C_{\mu m} C_{\nu i} \end{align}\end{split}\]

但对于 \(\partial_{A_t} \rho_r\),我们并没有作简化:

\[\partial_{A_t} \rho_r \leftarrow 4 \phi_{r \mu} \phi_\nu U_{mi}^{A_t} (C_{\mu m} C_{\nu i} + C_{\mu i} C_{\nu m})\]

请简述原因并用程序验证。

后文可能会经常作一些与对称性有关的变换,譬如 \(\partial_{A_t} \rho_r\) 的 U 导数还可以表示为

\[\partial_{A_t} \rho_r \leftarrow 4 (\phi_{r \mu} \phi_\nu + \phi_\mu \phi_{r \nu}) U_{mi}^{A_t} C_{\mu m} C_{\nu i}\]

读者可能需要熟悉和适应这种变化。

U 导数与 Fock 矩阵的关系

我们再回到

\[\begin{split}\begin{align} \partial_{A_t} E_\mathrm{elec} \xleftarrow{\text{GGA part}} \frac{\partial (f \rho)}{\partial A_t} &= f_\rho \partial_{A_t} \rho + 2 f_\gamma \rho_r \partial_{A_t} \rho_r \\ &= f_\rho \rho^{A_t} + 2 f_\gamma \rho_r \rho_r^{A_t} + (f_\rho \phi_\mu \phi_\nu + 2 f_\gamma \rho_r \phi_{r \mu} \phi_\nu + 2 f_\gamma \rho_r \phi_\mu \phi_{r \nu}) \cdot 2 (C_{\mu m} C_{\nu i} + C_{\mu i} C_{\nu p}) U_{mi}^{A_t} \end{align}\end{split}\]

我们会发现,上式中出现了

\[v_{\mu \nu}^\mathrm{xc} [\rho] = f_\rho \phi_\mu \phi_\nu + 2 f_\gamma \rho_r (\phi_{r \mu} \phi_{\nu} + \phi_{\mu} \phi_{r \nu})\]

因此,U 矩阵导数部分有

\[\partial_{A_t} E_\mathrm{elec} \xleftarrow{\text{GGA part}} \partial_{A_t} (f \rho) \xleftarrow{\text{U derivative}} v_{\mu \nu}^\mathrm{xc} \cdot 2 (C_{\mu m} C_{\nu i} + C_{\mu i} C_{\nu p}) U_{mi}^{A_t} = v_{\mu \nu}^\mathrm{xc} \partial_{A_t} D_{\mu \nu}\]

当我们联系到类似于 HF 部分的贡献为

\[E_\mathrm{elec} \xleftarrow{\text{HF part}} h_{\mu \nu} D_{\mu \nu} + \frac{1}{2} D_{\mu \nu} (\mu \nu | \kappa \lambda) D_{\kappa \lambda} - \frac{c_\mathrm{x}}{4} D_{\mu \nu} (\mu \kappa | \nu \lambda) D_{\kappa \lambda}\]

及其 U 导数

\[\partial_{A_t} E_\mathrm{elec} \xleftarrow{\text{HF part}} \big( h_{\mu \nu} + (\mu \nu | \kappa \lambda) D_{\kappa \lambda} - \frac{c_\mathrm{x}}{2} (\mu \kappa | \nu \lambda) D_{\kappa \lambda} \big) \partial_{A_t} D_{\mu \nu}\]

因此,电子态总能量全部的 U 导数可以写为

\[\partial_{A_t} E_\mathrm{elec} \xleftarrow{\text{U derivative}} \big( h_{\mu \nu} + (\mu \nu | \kappa \lambda) D_{\kappa \lambda} - \frac{c_\mathrm{x}}{2} (\mu \kappa | \nu \lambda) D_{\kappa \lambda} + v_{\mu \nu}^\mathrm{xc} \big) \partial_{A_t} D_{\mu \nu} = F_{\mu \nu} \partial_{A_t} D_{\mu \nu} = - 2 F_{ij} S_{ij}^{A_t}\]

我们再将 \(\partial_{A_t} E_\mathrm{elec}\) 的 Skeleton 导数部分列举如下:

\[\partial_{A_t} E_\mathrm{elec} \xleftarrow{\text{Skeleton derivative}} h_{\mu \nu}^{A_t} D_{\mu \nu} + \frac{1}{2} D_{\mu \nu} (\mu \nu | \kappa \lambda)^{A_t} D_{\kappa \lambda} - \frac{c_\mathrm{x}}{4} D_{\mu \nu} (\mu \kappa | \nu \lambda)^{A_t} D_{\kappa \lambda} + f_\rho \rho^{A_t} + 2 f_\gamma \rho_r \rho_r^{A_t}\]

电子态能量总导数

有了上面的推导之后,我们就可以一口气地将所有电子态贡献项列出:

\[\partial_{A_t} E_\mathrm{elec} = h_{\mu \nu}^{A_t} D_{\mu \nu} + \frac{1}{2} D_{\mu \nu} (\mu \nu | \kappa \lambda)^{A_t} D_{\kappa \lambda} - \frac{c_\mathrm{x}}{4} D_{\mu \nu} (\mu \kappa | \nu \lambda)^{A_t} D_{\kappa \lambda} + f_\rho \rho^{A_t} + 2 f_\gamma \rho_r \rho_r^{A_t} - 2 F_{ij} S_{ij}^{A_t}\]
[18]:
E_1 = (
    + np.einsum("Atuv, uv -> At", H_1_ao, D)
    + 0.5 * np.einsum("uv, Atuvkl, kl -> At", D, eri1_ao, D)
    - 0.25 * cx * np.einsum("uv, Atukvl, kl -> At", D, eri1_ao, D)
    + np.einsum("g, Atg -> At", kerh.fr, A_rho_1)
    + 2 * np.einsum("g, rg, Atrg -> At", kerh.fg, grdh.rho_1, A_rho_2)
    - 2 * np.einsum("ij, Atij -> At", F_0_mo[so, so], S_1_mo[:, :, so, so])
)
E_1
[18]:
array([[-2.27471, -0.79557, -9.07091],
       [-0.37246, -2.30276, 10.1379 ],
       [ 2.70067, -0.03745, -0.61219],
       [-0.05351,  3.13578, -0.4548 ]])

我们可以用数值导数来验证上述结果:

[19]:
nd_E_0 = NumericDiff(gradn, lambda gradh: gradh.scf_eng.energy_elec()[0]).derivative
nd_E_0.reshape(natm, 3)
[19]:
array([[-2.2747 , -0.79556, -9.07092],
       [-0.37246, -2.30276, 10.1379 ],
       [ 2.70066, -0.03746, -0.61219],
       [-0.0535 ,  3.13578, -0.4548 ]])
[20]:
np.allclose(E_1, nd_E_0.reshape(natm, 3))
[20]:
True

参考任务解答

任务 (1)

由于待证等式对 \(\mu, \nu\) 求和,那么我们将 \(\phi_\mu \phi_{t \nu_A} D_{\mu \nu}\) 中的 \(\mu, \nu\) 角标对换一下,并且利用 \(D_{\mu \nu}\) 的对称性,就能立即得到 \(\phi_{t \mu_A} \phi_\nu D_{\mu \nu}\)

任务 (2)

首先,我们需要说明

\[\begin{split}\begin{align} \partial_{A_t} \rho &\leftarrow 2 \phi_\mu \phi_\nu U_{mi}^{A_t} (C_{\mu m} C_{\nu i} + C_{\mu i} C_{\nu m}) \\ &= 4 \phi_\mu \phi_\nu U_{mi}^{A_t} C_{\mu m} C_{\nu i} \end{align}\end{split}\]

程序如下:

[21]:
np.allclose(
    + 2 * np.einsum("gu, gv, Atmi, um, vi -> Atg", grdh.ao_0, grdh.ao_0, U_1[:, :, :, so], C, Co)
    + 2 * np.einsum("gu, gv, Atmi, ui, vm -> Atg", grdh.ao_0, grdh.ao_0, U_1[:, :, :, so], Co, C),
    + 4 * np.einsum("gu, gv, Atmi, um, vi -> Atg", grdh.ao_0, grdh.ao_0, U_1[:, :, :, so], C, Co)
)
[21]:
True

证明应当是很简单的:我们只要根据求和角标可交换,交换一下 \(\mu, \nu\) 即可。

但我们注意到

\[\begin{split}\begin{align} \partial_{A_t} \rho_r &\leftarrow 4 \phi_{r \mu} \phi_\nu U_{mi}^{A_t} (C_{\mu m} C_{\nu i} + C_{\mu i} C_{\nu m}) \\ &\not\equiv 8 \phi_{r \mu} \phi_\nu U_{mi}^{A_t} C_{\mu m} C_{\nu i} \end{align}\end{split}\]
[22]:
np.allclose(
    + 4 * np.einsum("rgu, gv, Atmi, um, vi -> Atgr", grdh.ao_1, grdh.ao_0, U_1[:, :, :, so], C, Co)
    + 4 * np.einsum("rgu, gv, Atmi, ui, vm -> Atgr", grdh.ao_1, grdh.ao_0, U_1[:, :, :, so], Co, C),
    + 8 * np.einsum("rgu, gv, Atmi, um, vi -> Atgr", grdh.ao_1, grdh.ao_0, U_1[:, :, :, so], C, Co)
)
[22]:
False

之所以上述不恒等号成立,我们仍然是先看看,交换 \(\phi_{r \mu} \phi_\nu U_{mi}^{A_t} C_{\mu i} C_{\nu m}\) 一项中的 \(\mu, \nu\) 角标后,得到 \(\phi_\mu \phi_{r \nu} U_{mi}^{A_t} C_{\mu m} C_{\nu i}\);它并不等价于 \(\phi_{r \mu} \phi_\nu U_{mi}^{A_t} C_{\mu m} C_{\nu i}\).

我们一般来说,总是希望将表达式化简来推导公式或编写程序;但这类看起来相当微妙的相等或不等关系,在处理的时候需要当心。

GGA 方法 U 矩阵与 B2PLYP 型泛函核坐标梯度

我们进一步讨论 GGA 方法的 U 矩阵求取方式。在求取 U 矩阵的过程中,我们利用到 GGA 方法下的 CP-HF 方程,进而可以用来求取含有 GGA 的 B2PLYP 型泛函的弛豫密度矩阵 \(D_{pq}^\mathrm{MP2}\),进而得到 B2PLYP 型泛函核坐标梯度。

在 pyxdh 中,B2PLYP 型泛函与 MP2 方法使用相同的流程进行处理;这两者相当相似。这一节的大多数公式和做法都可以参考 MP2 梯度部分;但在一些公式细节上,需要留意细微的差别。

这一节中提到的 U 矩阵求取仅仅包括非占-占据部分 \(U_{ai}^{A_t}\)

准备工作

计算 B2PLYP 型泛函的梯度,我们用与 MP2 方法一样的 GradMP2 类。由于涉及到大批格点的计算,我们这里暂时假定供给 np.einsum 的内存大小是足够大的。

[1]:
%matplotlib notebook

from pyscf import gto, scf, dft, dft, lib, hessian
from pyscf.scf import cphf
import numpy as np
from functools import partial
import warnings
from matplotlib import pyplot as plt
from pyxdh.Utilities import NucCoordDerivGenerator, DipoleDerivGenerator, NumericDiff, GridHelper, KernelHelper
from pyxdh.DerivOnce import GradMP2, GradSCF

np.einsum = partial(np.einsum, optimize=["greedy", 1024 ** 3 * 1000 / 8])
np.einsum_path = partial(np.einsum_path, optimize=["greedy", 1024 ** 3 * 1000 / 8])
np.allclose = partial(np.allclose, atol=1e-6, rtol=1e-4)
np.set_printoptions(5, linewidth=150, suppress=True)
warnings.filterwarnings("ignore")
[2]:
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()
[2]:
<pyscf.gto.mole.Mole at 0x7fcdc537f400>
[3]:
def mol_to_grids(mol, atom_grid=(75, 302)):
    grids = dft.Grids(mol)
    grids.atom_grid = atom_grid
    grids.becke_scheme = dft.gen_grid.stratmann
    grids.prune = None
    grids.build()
    return grids
grids = mol_to_grids(mol)

但需要注意到,既然我们计算的是 B2PLYP,那么其泛函也需要更改为 B2PLYP 的自洽场过程中使用的泛函:

[4]:
def mol_to_scf(mol):
    scf_eng = dft.RKS(mol)
    scf_eng.grids = mol_to_grids(mol)
    scf_eng.xc = "0.53*HF + 0.47*B88, 0.73*LYP"
    scf_eng.conv_tol = 1e-10
    return scf_eng.run()

与 MP2 方法不同的是,我们需要额外定义 PT2 相关能系数 cc \(c_\mathrm{c}\)

[5]:
gradh = GradMP2({"scf_eng": mol_to_scf(mol), "cc": 0.27})
cc = gradh.cc
[6]:
nmo, nao, natm, nocc, nvir, cx = gradh.nao, gradh.nao, gradh.natm, gradh.nocc, gradh.nvir, gradh.cx
mol_slice = gradh.mol_slice
so, sv, sa = gradh.so, gradh.sv, gradh.sa
C, Co, Cv, e, eo, ev, D = gradh.C, gradh.Co, gradh.Cv, gradh.e, gradh.eo, gradh.ev, gradh.D
H_0_ao, S_0_ao, eri0_ao, F_0_ao = gradh.H_0_ao, gradh.S_0_ao, gradh.eri0_ao, gradh.F_0_ao
H_0_mo, S_0_mo, eri0_mo, F_0_mo = gradh.H_0_mo, gradh.S_0_mo, gradh.eri0_mo, gradh.F_0_mo
[7]:
def to_natm_3(mat: np.ndarray):
    shape = list(mat.shape)
    shape = [int(shape[0] / 3), 3] + shape[1:]
    return mat.reshape(shape)
[8]:
H_1_ao, S_1_ao, eri1_ao = to_natm_3(gradh.H_1_ao), to_natm_3(gradh.S_1_ao), to_natm_3(gradh.eri1_ao)
H_1_mo, S_1_mo, eri1_mo = to_natm_3(gradh.H_1_mo), to_natm_3(gradh.S_1_mo), to_natm_3(gradh.eri1_mo)
[9]:
grdh = GridHelper(mol, grids, D)
kerh = KernelHelper(grdh, "0.53*HF + 0.47*B88, 0.73*LYP")

为了简化后面程序代码,我们将一部分与格点相关的 (基于 \(D_{\mu \nu}\) 自洽场密度给出的) 变量定义如下:

[10]:
ao_0, ao_1, ao_2 = grdh.ao_0, grdh.ao_1, grdh.ao_2
rho_1, rho_2 = grdh.rho_1, grdh.rho_2
A_rho_1, A_rho_2 = grdh.A_rho_1, grdh.A_rho_2
fr, fg, frr, frg, fgg = kerh.fr, kerh.fg, kerh.frr, kerh.frg, kerh.fgg
[11]:
def grad_generator(mol):
    scf_eng = mol_to_scf(mol)
    config = {"scf_eng": mol_to_scf(mol), "cc": 0.27}
    return GradMP2(config)
gradn = NucCoordDerivGenerator(mol, grad_generator)

GGA 方法 U 矩阵的求取

Fock Skeleton 导数

求取 U 矩阵 (非占-占据部分) 的推导前提是 Fock 矩阵 \(F_{ai} = 0\)。我们首先回顾原子轨道下,GGA 的 Fock 矩阵定义:

\[F_{\mu \nu} = h_{\mu \nu} + (\mu \nu | \kappa \lambda) D_{\kappa \lambda} - \frac{c_\mathrm{x}}{2} (\mu \kappa | \nu \lambda) D_{\kappa \lambda} + v_{\mu \nu}^\mathrm{xc}\]

前三项我们已经在 RHF 的 U 矩阵计算时知道了求取方法;现在我们要考虑第四项 \(v_{\mu \nu}^\mathrm{xc}\) 导数的计算。我们再回顾 \(v_{\mu \nu}^{\mathrm{xc}}\) 的定义:

\[v_{\mu \nu}^\mathrm{xc} = f_\rho \phi_\mu \phi_\nu + 2 f_\gamma \rho_r (\phi_{r \mu} \phi_{\nu} + \phi_{\mu} \phi_{r \nu})\]

我们对 Skeleton 导数的定义是不包含 \(C_{\mu p}\) 的导数;剩下的部分则归为 U 矩阵导数。我们将 \(v_{\mu \nu}^{\mathrm{xc}}\) 的 Skeleton 导数写为 \(v_{\mu \nu}^{\mathrm{xc}, A_t}\)

我们先依据链式法则,考察 \(f_\rho\) 的全导数:

\[\frac{\partial f_\rho}{\partial A_t} = f_{\rho \rho} \frac{\partial \rho}{\partial A_t} + 2 f_{\rho \gamma} \rho_r \frac{\partial \rho_r}{\partial A_t}\]

我们已经对 \(\partial_{A_t} \rho\)\(\partial_{A_t} \rho_r\) 作过讨论:

\[\begin{split}\begin{align} \frac{\partial \rho}{\partial A_t} &= - \phi_{t \mu_A} \phi_\nu D_{\mu \nu} - \phi_\mu \phi_{t \nu_A} D_{\mu \nu} + 4 \phi_\mu \phi_\nu U_{mi}^{A_t} C_{\mu m} C_{\nu i} \\ &= \rho^{A_t} + 4 \phi_\mu \phi_\nu U_{mi}^{A_t} C_{\mu m} C_{\nu i} \\ \frac{\partial \rho_r}{\partial A_t} &= - 2 \phi_{tr \mu_A} \phi_\nu D_{\mu \nu} - 2 \phi_{r \mu} \phi_{t \nu_A} D_{\mu \nu} + 4 (\phi_{r \mu} \phi_\nu + \phi_\mu \phi_{r \nu}) U_{mi}^{A_t} C_{\mu m} C_{\nu i} \\ &= \rho_r^{A_t} + 4 (\phi_{r \mu} \phi_\nu + \phi_\mu \phi_{r \nu}) U_{mi}^{A_t} C_{\mu m} C_{\nu i} \end{align}\end{split}\]

对于 \(f_\gamma\) 的全导数也是相同的:

\[\frac{\partial f_\gamma}{\partial A_t} = f_{\rho \gamma} \frac{\partial \rho}{\partial A_t} + 2 f_{\gamma \gamma} \rho_r \frac{\partial \rho_r}{\partial A_t}\]

上述出现了 U 矩阵的部分看作是 U 导数,否则为 Skeleton 导数。那么,\(v_{\mu \nu}^\mathrm{xc}\) 的所有贡献项就容易地用链式法则表示如下:

\[\begin{split}\begin{align} \partial_{A_t} v_{\mu \nu}^\mathrm{xc} \xleftarrow{\text{Skeleton derivative}} v_{\mu \nu}^{\mathrm{xc}, A_t} &= (f_{\rho \rho} \rho^{A_t} + 2 f_{\rho \gamma} \rho_w \rho_w^{A_t}) \phi_\mu \phi_\nu + 2 (f_{\rho \gamma} \rho^{A_t} + 2 f_{\gamma \gamma} \rho_w \rho_w^{A_t}) \rho_r \rho_r^{A_t} (\phi_{r \mu} \phi_{\nu} + \phi_{\mu} \phi_{r \nu}) \\ &\quad + f_\rho \phi_\mu^{A_t} \phi_\nu + f_\rho \phi_\mu \phi_\nu^{A_t} \\ &\quad + 2 f_\gamma \rho_r^{A_t} (\phi_{r \mu} \phi_{\nu} + \phi_{\mu} \phi_{r \nu}) + 2 f_\gamma \rho_r (\phi_{r \mu}^{A_t} \phi_{\nu} + \phi_{\mu}^{A_t} \phi_{r \nu} + \phi_{r \mu} \phi_{\nu}^{A_t} + \phi_{\mu} \phi_{r \nu}^{A_t}) \end{align}\end{split}\]

上述出现了 U 矩阵的部分看作是 U 导数,否则为 Skeleton 导数。那么,\(v_{\mu \nu}^\mathrm{xc}\) 的所有贡献项就容易地用链式法则表示如下:

\[\begin{split}\begin{align} \partial_{A_t} v_{\mu \nu}^\mathrm{xc} \xleftarrow{\text{Skeleton derivative}} v_{\mu \nu}^{\mathrm{xc}, A_t} &= (f_{\rho \rho} \rho^{A_t} + 2 f_{\rho \gamma} \rho_w \rho_w^{A_t}) \phi_\mu \phi_\nu \\ &\quad + 2 (f_{\rho \gamma} \rho^{A_t} + 2 f_{\gamma \gamma} \rho_w \rho_w^{A_t}) \rho_r (\phi_{r \mu} \phi_{\nu} + \phi_{\mu} \phi_{r \nu}) \\ &\quad + f_\rho \phi_\mu^{A_t} \phi_\nu + f_\rho \phi_\mu \phi_\nu^{A_t} \\ &\quad + 2 f_\gamma \rho_r^{A_t} (\phi_{r \mu} \phi_{\nu} + \phi_{\mu} \phi_{r \nu}) + 2 f_\gamma \rho_r (\phi_{r \mu}^{A_t} \phi_{\nu} + \phi_{\mu}^{A_t} \phi_{r \nu} + \phi_{r \mu} \phi_{\nu}^{A_t} + \phi_{\mu} \phi_{r \nu}^{A_t}) \\ &= \frac{1}{2} f_{\rho \rho} \rho^{A_t} \phi_\mu \phi_\nu + f_{\rho \gamma} \rho_w \rho_w^{A_t} \phi_\mu \phi_\nu \\ &\quad + 2 f_{\rho \gamma} \rho^{A_t} \rho_r \phi_{r \mu} \phi_{\nu} + 4 f_{\gamma \gamma} \rho_w \rho_w^{A_t} \rho_r \phi_{r \mu} \phi_{\nu} \\ &\quad + f_\rho \phi_\mu^{A_t} \phi_\nu \\ &\quad + 2 f_\gamma \rho_r^{A_t} \phi_{r \mu} \phi_{\nu} + 2 f_\gamma \rho_r \phi_{r \mu}^{A_t} \phi_{\nu} + 2 f_\gamma \rho_r \phi_{\mu}^{A_t} \phi_{r \nu} \\ &\quad + \mathrm{swap} (\mu, \nu) \end{align}\end{split}\]

利用 \(\partial_{A_t} \phi_\mu = - \phi_{t \mu_A}\) 等结论,我们会将上式写为

\[\begin{split}\begin{align} \partial_{A_t} v_{\mu \nu}^\mathrm{xc} \xleftarrow{\text{Skeleton derivative}} v_{\mu \nu}^{\mathrm{xc}, A_t} &= \frac{1}{2} f_{\rho \rho} \rho^{A_t} \phi_\mu \phi_\nu + f_{\rho \gamma} \rho_w \rho_w^{A_t} \phi_\mu \phi_\nu \\ &\quad + 2 f_{\rho \gamma} \rho^{A_t} \rho_r \phi_{r \mu} \phi_{\nu} + 4 f_{\gamma \gamma} \rho_w \rho_w^{A_t} \rho_r \phi_{r \mu} \phi_{\nu} \\ &\quad + 2 f_\gamma \rho_r^{A_t} \phi_{r \mu} \phi_{\nu} \\ &\quad - f_\rho \phi_{t \mu_A} \phi_\nu - 2 f_\gamma \rho_r \phi_{tr \mu_A} \phi_{\nu} - 2 f_\gamma \rho_r \phi_{t \mu_A} \phi_{r \nu} \\ &\quad + \mathrm{swap} (\mu, \nu) \end{align}\end{split}\]

我们定义 F_1_ao_GGA \(v_{\mu \nu}^{\mathrm{xc}, A_t}\) 为 (维度为 \((A, t, \mu, \nu)\))

[12]:
F_1_ao_GGA = (
    + 0.5 * np.einsum("g, Atg, gu, gv -> Atuv", frr, A_rho_1, ao_0, ao_0)
    + np.einsum("g, wg, Atwg, gu, gv -> Atuv", frg, rho_1, A_rho_2, ao_0, ao_0)
    + 2 * np.einsum("g, Atg, rg, rgu, gv -> Atuv", frg, A_rho_1, rho_1, ao_1, ao_0)
    + 4 * np.einsum("g, wg, Atwg, rg, rgu, gv -> Atuv", fgg, rho_1, A_rho_2, rho_1, ao_1, ao_0)
    + 2 * np.einsum("g, Atrg, rgu, gv -> Atuv", fg, A_rho_2, ao_1, ao_0)
)
for A in range(natm):
    sA = mol_slice(A)
    F_1_ao_GGA[A, :, sA, :] -= np.einsum("g, tgu, gv -> tuv", fr, ao_1[:, :, sA], ao_0)
    F_1_ao_GGA[A, :, sA, :] -= 2 * np.einsum("g, rg, trgu, gv -> tuv", fg, rho_1, ao_2[:, :, :, sA], ao_0)
    F_1_ao_GGA[A, :, sA, :] -= 2 * np.einsum("g, rg, tgu, rgv -> tuv", fg, rho_1, ao_1[:, :, sA], ao_1)
F_1_ao_GGA += F_1_ao_GGA.swapaxes(-1, -2)  # swap (u, v)
F_1_ao_GGA.shape
[12]:
(4, 3, 22, 22)

我们再考虑 Fock 矩阵的 Skeleton 导数 F_1_ao

\[F_{\mu \nu}^{A_t} = h_{\mu \nu}^{A_t} + (\mu \nu | \kappa \lambda)^{A_t} D_{\kappa \lambda} - \frac{c_\mathrm{x}}{2} (\mu \kappa | \nu \lambda)^{A_t} D_{\kappa \lambda} + v_{\mu \nu}^{\mathrm{xc}, A_t}\]
[13]:
F_1_ao = (
    + H_1_ao
    + np.einsum("Atuvkl, kl -> Atuv", eri1_ao, D)
    - 0.5 * cx * np.einsum("Atukvl, kl -> Atuv", eri1_ao, D)
    + F_1_ao_GGA
)
F_1_ao.shape
[13]:
(4, 3, 22, 22)

pyxdh 中的 F_1_ao 也可以用来验证上述的生成结果:

[14]:
np.allclose(F_1_ao, to_natm_3(gradh.F_1_ao))
[14]:
True

在 PySCF 中,也可以用 make_h1 来验证上述结果:

[15]:
np.allclose(F_1_ao, hessian.rks.Hessian(gradh.scf_eng).make_h1(C, gradh.mo_occ))
[15]:
True

那么分子轨道下的 \(F_{pq}^{A_t}\) 可以表示为:

\[F_{pq}^{A_t} = C_{\mu p} F_{\mu \nu}^{A_t} C_{\nu q}\]
[16]:
F_1_mo = np.einsum("up, Atuv, vq -> Atpq", C, F_1_ao, C)

A 张量

我们已经对 \(\partial_{A_t} \rho\)\(\partial_{A_t} \rho_r\) 作过讨论:

\[\begin{split}\begin{align} \frac{\partial \rho}{\partial A_t} &= \rho^{A_t} + 4 \phi_\mu \phi_\nu U_{mi}^{A_t} C_{\mu m} C_{\nu i} \\ \frac{\partial \rho_r}{\partial A_t} &= \rho_r^{A_t} + 4 (\phi_{r \mu} \phi_\nu + \phi_\mu \phi_{r \nu}) U_{mi}^{A_t} C_{\mu m} C_{\nu i} \end{align}\end{split}\]

从 RHF 推导 A 张量的经验,我们知道,A 张量的定义应是 Fock 矩阵的 U 导数所产生的贡献:

\[\frac{\partial F_{\mu \nu}}{\partial A_t} \xleftarrow{\text{U derivative}} A_{\mu \nu, \kappa \lambda} C_{\kappa m} C_{\lambda i} U_{mi}^{A_t}\]

我们仍然根据定义

\[v_{\mu \nu}^\mathrm{xc} = f_\rho \phi_\mu \phi_\nu + 2 f_\gamma \rho_r (\phi_{r \mu} \phi_{\nu} + \phi_{\mu} \phi_{r \nu})\]

可以推知其 U 矩阵的贡献是

\[\begin{split}\begin{align} \partial_{A_t} v_{\mu \nu}^\mathrm{xc} \xleftarrow{\text{U derivative}} &\quad \big[ 4 (f_{\rho \rho} \phi_\kappa \phi_\lambda + 2 f_{\rho \gamma} \rho_r \phi_{r \kappa} \phi_\lambda + 2 f_{\rho \gamma} \rho_r \phi_\kappa \phi_{r \lambda}) \phi_\mu \phi_\nu \\ &\quad + 8 (f_{\rho \gamma} \phi_\kappa \phi_\lambda + 2 f_{\gamma \gamma} \rho_w \phi_{w \kappa} \phi_\lambda + 2 f_{\gamma \gamma} \rho_r \phi_\kappa \phi_{r \lambda}) \rho_r (\phi_{r \mu} \phi_{\nu} + \phi_{\mu} \phi_{r \nu}) \\ &\quad + 8 f_\gamma (\phi_{r \kappa} \phi_\lambda + \phi_\kappa \phi_{r \lambda}) (\phi_{r \mu} \phi_{\nu} + \phi_{\mu} \phi_{r \nu}) \big] U_{mi}^{A_t} C_{\mu m} C_{\nu i} \end{align}\end{split}\]

上式中,方括号所包含的部分就是 GGA 对 A 张量 \(A_{\mu \nu, \kappa \lambda}\) 的贡献;但这个表达式实在是相当长,我们可以依靠对称性作一些简化:

\[\begin{split}\begin{align} A_{\mu \nu, \kappa \lambda} \xleftarrow{\text{GGA contrib}} A_{\mu \nu, \kappa \lambda}^\text{GGA} &= f_{\rho \rho} \phi_\kappa \phi_\lambda \phi_\mu \phi_\nu + 4 f_{\rho \gamma} \rho_r \phi_{r \kappa} \phi_\lambda \phi_\mu \phi_\nu \\ & + 4 f_{\rho \gamma} \phi_\kappa \phi_\lambda \rho_r \phi_{r \mu} \phi_{\nu} + 16 f_{\gamma \gamma} \rho_w \phi_{w \kappa} \phi_\lambda \rho_r \phi_{r \mu} \phi_{\nu} \\ & + 8 f_\gamma \phi_{r \kappa} \phi_\lambda \phi_{r \mu} \phi_{\nu} \\ & + \mathrm{swap} (\mu, \nu) + \mathrm{swap} (\kappa, \lambda) \end{align}\end{split}\]
[17]:
A_0_ao_GGA = (
    + np.einsum("g, gk, gl, gu, gv -> uvkl", frr, ao_0, ao_0, ao_0, ao_0)
    + 4 * np.einsum("g, rg, rgk, gl, gu, gv -> uvkl", frg, rho_1, ao_1, ao_0, ao_0, ao_0)
    + 4 * np.einsum("g, gk, gl, rg, rgu, gv -> uvkl", frg, ao_0, ao_0, rho_1, ao_1, ao_0)
    + 16 * np.einsum("g, wg, wgk, gl, rg, rgu, gv -> uvkl", fgg, rho_1, ao_1, ao_0, rho_1, ao_1, ao_0)
    + 8 * np.einsum("g, rgk, gl, rgu, gv -> uvkl", fg, ao_1, ao_0, ao_1, ao_0)
)
A_0_ao_GGA += A_0_ao_GGA.swapaxes(-1, -2)
A_0_ao_GGA += A_0_ao_GGA.swapaxes(-3, -4)
A_0_ao_GGA.shape
[17]:
(22, 22, 22, 22)

随后我们就可以给出 GGA 下的 A 张量:

\[A_{\mu \nu, \kappa \lambda} = 4 (\mu \nu | \kappa \lambda) - c_\mathrm{x} (\mu \kappa | \nu \lambda) - c_\mathrm{x} (\mu \lambda | \nu \kappa) + A_{\mu \nu, \kappa \lambda}^\mathrm{GGA}\]
[18]:
A_0_ao = 4 * eri0_ao - cx * eri0_ao.swapaxes(-2, -3) - cx * eri0_ao.swapaxes(-1, -3) + A_0_ao_GGA

我们可以拿现成的程序来验证上述 A 张量是否生成正确。pyxdh 中,它仍然可以用 Ax0_Core 与张量作乘积得到:(下式对除了 \(p, q\) 之外的角标求和)

\[A_{pq, mi} X_{mi} = C_{\mu p} C_{\nu q} A_{\mu \nu, \kappa \lambda} C_{\kappa m} X_{mi} C_{\lambda i}\]
[19]:
X = np.random.randn(nmo, nocc)
[20]:
np.allclose(
    np.einsum("up, vq, uvkl, km, mi, li -> pq", C, C, A_0_ao, C, X, Co),
    gradh.Ax0_Core(sa, sa, sa, so)(X)
)
[20]:
True

演示 A 张量 GGA 贡献部分的缩并效率

你可能会意识到上述生成 A_0_ao_GGA 的耗时相当长,但 Ax0_Core 的耗时却很短。请尝试用 np.einsum_path 分析时间复杂度,并指出一种策略,使得计算 \(A_{pq, mi} X_{mi}\) 的耗时与 Ax0_Core 相对接近。

CP-HF 方程

既然我们已经知道 A 张量的计算方式,那么就可以通过 CP-HF 方程对 U 矩阵进行求解。首先我们仍然可以通过下式给出 B 矩阵:

\[B_{pq}^\mathbb{A} = F_{pq}^\mathbb{A} - S_{pq}^\mathbb{A} \varepsilon_q - \frac{1}{2} A_{pq, kl} S_{kl}^\mathbb{A}\]
[21]:
B_1 = (
    + F_1_mo
    - np.einsum("Atpq, q -> Atpq", S_1_mo, e)
    - 0.5 * gradh.Ax0_Core(sa, sa, so, so)(S_1_mo[:, :, so, so])
)

随后就可以通过 CP-HF 方程求解 U_1_vo \(U_{ai}^{A_t}\) (在这份文档中,我们一般统一称 CP-KS 方程为 CP-HF 方程,因为解法与意义都几乎一致):

\[- (\varepsilon_a - \varepsilon_i) U_{ai}^\mathbb{A} - A_{ai, bj} U_{bj}^\mathbb{A} = B_{ai}^\mathbb{A}\]
[22]:
U_1_vo = cphf.solve(
    gradh.Ax0_Core(sv, so, sv, so, in_cphf=True),
    e,
    gradh.scf_eng.mo_occ,
    B_1[:, :, sv, so].reshape(natm * 3, nvir, nocc),
    tol=1e-6,
)[0].reshape(natm, 3, nvir, nocc)

我们可以将其与 pyxdh 所给出的 U 矩阵作验证:

[23]:
np.allclose(U_1_vo.ravel(), gradh.U_1_vo.ravel())
[23]:
True

我们也可以用数值的 U 矩阵中非占-占据的分块来验证上述解析 U 矩阵:

[24]:
nd_C = NumericDiff(gradn, lambda gradh: gradh.C).derivative.reshape((natm, 3, nao, nao))
nd_U_1_vo = np.einsum("mu, Atup -> Atmp", np.linalg.inv(C), nd_C)[:, :, sv, so]
fig, ax = plt.subplots(figsize=(4, 3)); ax.set_xscale("log")
ax.hist(abs(U_1_vo.ravel() - nd_U_1_vo.ravel()), bins=np.logspace(np.log10(1e-9), np.log10(1e-1), 50), alpha=0.5)
ax.hist(abs(nd_U_1_vo.ravel()), bins=np.logspace(np.log10(1e-9), np.log10(1e-1), 50), alpha=0.5)
fig.tight_layout()

尽管在求解 B2PLYP 型泛函时,我们不一定要使用 U 矩阵;但上面的过程可以确认 CP-HF 方程可以在 GGA 下用程序实现,进而使用 Z-Vector 方法,求解双杂化泛函下的弛豫密度的非占-占据分块 \(D_{ai}^\mathrm{PT2}\)

B2PLYP 型泛函核坐标梯度

这里我们就几乎是再回顾一遍 MP2 核坐标梯度流程了。尽管 B2PLYP 型泛函在一些变量上定义与 MP2 较之略有区别,但大体上是相通的。

PT2 相关能

B2PLYP 型泛函的能量分为自洽场部分 (SCF) 与二阶微扰部分 (PT2),其中 PT2 部分定义如下:

\[\begin{split}\begin{align} E_\mathrm{PT2} &= T_{ij}^{ab} t_{ij}^{ab} D_{ij}^{ab} \\ T_{ij}^{ab} &= c_\mathrm{c} (2 t_{ij}^{ab} - t_{ij}^{ba}) \\ t_{ij}^{ab} &= (ia|jb) / D_{ij}^{ab} \\ D_{ij}^{ab} &= \varepsilon_i - \varepsilon_a + \varepsilon_j - \varepsilon_b \end{align}\end{split}\]

除了在 \(T_{ij}^{ab}\) 处引入了相关系数 \(c_\mathrm{c}\) 之外,就与 MP2 近乎没有区别了。在 B2PLYP 中,该系数大小为 0.27。

[25]:
T_iajb, t_iajb, D_iajb = gradh.T_iajb, gradh.t_iajb, gradh.D_iajb
[26]:
np.allclose(cc * 2 * t_iajb - cc * t_iajb.swapaxes(-1, -3), T_iajb)
[26]:
True

我们不妨再验证一下 B2PLYP 总能量的计算。

\[E_\mathrm{tot} = E_\mathrm{nuc} + E_\mathrm{SCF} + E_\mathrm{PT2}\]
[27]:
gradh.scf_eng.energy_nuc() + gradh.scf_eng.energy_elec()[0] + (T_iajb * t_iajb * D_iajb).sum()
[27]:
-151.20399655987893

pyxdh 所直接给出的 B2PLYP 总能量则为:

[28]:
gradh.eng
[28]:
-151.20399655987893

PT2 能量梯度

如 MP2 一样,PT2 能量梯度也一样能写为

\[\partial_{A_t} E_\mathrm{PT2} = D_{pq}^\mathrm{PT2} B_{pq}^{A_t} + W_{pq}^\mathrm{PT2} [\mathrm{I}] S_{pq}^{A_t} + 2 T_{ij}^{ab} (ia|jb)^{A_t}\]

其中,弛豫密度 \(D_{pq}^\text{PT2}\) 定义仍然是

\[\begin{split}\begin{aligned} D_{ij}^\text{PT2} &= - 2 T_{ik}^{ab} t_{jk}^{ab} \\ D_{ab}^\text{PT2} &= 2 T_{ij}^{ac} t_{ij}^{bc} \\ - (\varepsilon_a - \varepsilon_i) D_{ai}^\mathrm{PT2} - A_{ai, bj} D_{bj}^\mathrm{PT2} &= L_{ai} \\ D_{ia}^\mathrm{PT2} &= 0 \end{aligned}\end{split}\]

其中,

\[\begin{align} L_{ai} &= A_{ai, kl} D_{kl}^\mathrm{PT2} + A_{ai, bc} D_{bc}^\mathrm{PT2} - 4 T_{jk}^{ab} (ij|bk) + 4 T_{ij}^{bc} (ab|jc) \end{align}\]
[29]:
D_r = np.zeros((nmo, nmo))
D_r[so, so] = - 2 * np.einsum("iakb, jakb -> ij", T_iajb, t_iajb)
D_r[sv, sv] = 2 * np.einsum("iajc, ibjc -> ab", T_iajb, t_iajb)
L = (
    + gradh.Ax0_Core(sv, so, sa, sa)(D_r)
    - 4 * np.einsum("jakb, ijbk -> ai", T_iajb, eri0_mo[so, so, sv, so])
    + 4 * np.einsum("ibjc, abjc -> ai", T_iajb, eri0_mo[sv, sv, so, sv])
)
D_r[sv, so] = cphf.solve(gradh.Ax0_Core(sv, so, sv, so), e, gradh.mo_occ, L, max_cycle=100)[0]
[30]:
np.allclose(D_r, gradh.D_r)
[30]:
True

以及 \(W_{pq}^\mathrm{PT2} [\mathrm{I}]\) 定义仍然是

\[\begin{split}\begin{align} W_{ij}^\mathrm{PT2} [\mathrm{I}] &= - 2 T_{ik}^{ab} (ja|kb) \\ W_{ab}^\mathrm{PT2} [\mathrm{I}] &= - 2 T_{ij}^{ac} (ib|jc) \\ W_{ai}^\mathrm{PT2} [\mathrm{I}] &= - 4 T_{jk}^{ab} (ij|bk) \\ W_{ia}^\mathrm{PT2} [\mathrm{I}] &= 0 \end{align}\end{split}\]
[31]:
W_I = np.zeros((nmo, nmo))
W_I = np.zeros((nmo, nmo))
W_I[so, so] = - 2 * np.einsum("iakb, jakb -> ij", T_iajb, eri0_mo[so, sv, so, sv])
W_I[sv, sv] = - 2 * np.einsum("iajc, ibjc -> ab", T_iajb, eri0_mo[so, sv, so, sv])
W_I[sv, so] = - 4 * np.einsum("jakb, ijbk -> ai", T_iajb, eri0_mo[so, so, sv, so])
[32]:
np.allclose(W_I, gradh.W_I)
[32]:
True

因此,总 PT2 能量的导数可以生成如下:

[33]:
E_1_MP2_contrib = (
    + np.einsum("pq, Atpq -> At", D_r, B_1)
    + np.einsum("pq, Atpq -> At", W_I, S_1_mo)
    + 2 * np.einsum("iajb, Atiajb -> At", T_iajb, eri1_mo[:, :, so, sv, so, sv])
)
E_1_MP2_contrib
[33]:
array([[ 0.01457, -0.00054,  0.02555],
       [-0.00174,  0.00928, -0.03034],
       [-0.01221, -0.00043, -0.00322],
       [-0.00061, -0.00832,  0.00801]])

而自洽场总能量导数可以使用父类 GradSCF 的函数生成:

[34]:
E_1_SCF_contrib = GradSCF._get_E_1(gradh)
E_1_SCF_contrib
[34]:
array([[-0.04938,  0.06774,  0.1109 ],
       [ 0.01107,  0.15143, -0.1389 ],
       [ 0.01952,  0.01315,  0.03539],
       [ 0.01879, -0.23232, -0.00739]])

总的能量梯度则表示如下:

[35]:
E_1_SCF_contrib + E_1_MP2_contrib
[35]:
array([[-0.03482,  0.0672 ,  0.13645],
       [ 0.00933,  0.16072, -0.16924],
       [ 0.00731,  0.01272,  0.03217],
       [ 0.01818, -0.24064,  0.00062]])

在 pyxdh 中,E_1 可以用来生成总能量梯度:

[36]:
gradh.E_1
[36]:
array([[-0.03482,  0.0672 ,  0.13645],
       [ 0.00933,  0.16072, -0.16924],
       [ 0.00731,  0.01272,  0.03217],
       [ 0.01818, -0.24064,  0.00062]])

我们也可以使用数值导数的方法生成总能量梯度:

[37]:
nd_E_0 = NumericDiff(gradn, lambda gradh: gradh.eng).derivative
nd_E_0.reshape(natm, 3)
[37]:
array([[-0.03481,  0.0672 ,  0.13644],
       [ 0.00933,  0.16072, -0.16923],
       [ 0.00731,  0.01272,  0.03217],
       [ 0.01818, -0.24064,  0.00062]])
[38]:
np.allclose(E_1_SCF_contrib + E_1_MP2_contrib, nd_E_0.reshape(natm, 3))
[38]:
False

演示任务:A 张量 GGA 贡献部分的缩并效率

在这里,我们统一处理的是 \(A_{ai, bj}^\mathrm{GGA} X_{bj}^\mathbb{A}\) 问题 (作为结果的张量是 \((\mathbb{A}, a, i)\) 维度的),其中 \(\mathbb{A}\) 代表的维度数量是 5。

[39]:
X = np.random.randn(5, nvir, nocc)

原子轨道下 A 张量的缩并

我们先回顾一下原子轨道下的缩并情况:

\[\begin{split}\begin{align} A_{\mu \nu, \kappa \lambda} \xleftarrow{\text{GGA contrib}} A_{\mu \nu, \kappa \lambda}^\text{GGA} &= f_{\rho \rho} \phi_\kappa \phi_\lambda \phi_\mu \phi_\nu + 4 f_{\rho \gamma} \rho_r \phi_{r \kappa} \phi_\lambda \phi_\mu \phi_\nu \\ & + 4 f_{\rho \gamma} \phi_\kappa \phi_\lambda \rho_r \phi_{r \mu} \phi_{\nu} + 16 f_{\gamma \gamma} \rho_w \phi_{w \kappa} \phi_\lambda \rho_r \phi_{r \mu} \phi_{\nu} \\ & + 8 f_\gamma \phi_{r \kappa} \phi_\lambda \phi_{r \mu} \phi_{\nu} \\ & + \mathrm{swap} (\mu, \nu) + \mathrm{swap} (\kappa, \lambda) \end{align}\end{split}\]

如果我们要求 \(A_{ai, bj}^\mathrm{GGA} X_{bj}^\mathbb{A}\),那么我们还要经过一次张量缩并:

\[A_{ai, bj}^\mathrm{GGA} X_{bj}^\mathbb{A} = C_{\mu a} C_{\nu i} A_{\mu \nu, \kappa \lambda}^\text{GGA} C_{\kappa b} X_{bj}^\mathbb{A} C_{\lambda j}\]

这样一个过程相当花时间,因此我们只进行一次运行输出时间信息:

[40]:
%%time
A_0_ao_GGA = (
    +      np.einsum("g, gk, gl, gu, gv -> uvkl"          , frr, ao_0, ao_0, ao_0, ao_0)
    + 4  * np.einsum("g, rg, rgk, gl, gu, gv -> uvkl"     , frg, rho_1, ao_1, ao_0, ao_0, ao_0)
    + 4  * np.einsum("g, gk, gl, rg, rgu, gv -> uvkl"     , frg, ao_0, ao_0, rho_1, ao_1, ao_0)
    + 16 * np.einsum("g, wg, wgk, gl, rg, rgu, gv -> uvkl", fgg, rho_1, ao_1, ao_0, rho_1, ao_1, ao_0)
    + 8  * np.einsum("g, rgk, gl, rgu, gv -> uvkl"        , fg, ao_1, ao_0, ao_1, ao_0)
)
A_0_ao_GGA += A_0_ao_GGA.swapaxes(-1, -2)
A_0_ao_GGA += A_0_ao_GGA.swapaxes(-3, -4)
Ax_GGA = np.einsum("ua, vi, uvkl, kb, Abj, lj -> Aai", Cv, Co, A_0_ao_GGA, Cv, X, Co)
CPU times: user 7.53 s, sys: 3.78 s, total: 11.3 s
Wall time: 12.6 s

我们来看一下为何需要消耗这么长时间。我们不妨先定义一个函数 get_FLOP,它能导出 np.einsum 在足够内存下会选用的张量缩并路径下,所预期的数值运算次数:

[41]:
def get_FLOP(*args):
    return float(np.einsum_path(*args)[1].split("\n")[4].split(":")[1])

譬如说,对于 \(f_{\rho \rho} \phi_\kappa \phi_\lambda \phi_\mu \phi_\nu\) 的缩并,np.einsum 预期会执行大约 4.254e+10 次的运算:

[42]:
get_FLOP("g, gk, gl, gu, gv -> uvkl", frr, ao_0, ao_0, ao_0, ao_0)
[42]:
42540000000.0

因此,上述张量缩并总共需要大约 2.130e+11 次的数值运算。

[43]:
(
    + get_FLOP("g, gk, gl, gu, gv -> uvkl", frr, ao_0, ao_0, ao_0, ao_0)
    + get_FLOP("g, rg, rgk, gl, gu, gv -> uvkl", frg, rho_1, ao_1, ao_0, ao_0, ao_0)
    + get_FLOP("g, gk, gl, rg, rgu, gv -> uvkl", frg, ao_0, ao_0, rho_1, ao_1, ao_0)
    + get_FLOP("g, wg, wgk, gl, rg, rgu, gv -> uvkl", fgg, rho_1, ao_1, ao_0, rho_1, ao_1, ao_0)
    + get_FLOP("g, rgk, gl, rgu, gv -> uvkl", fg, ao_1, ao_0, ao_1, ao_0)
    + get_FLOP("ua, vi, uvkl, kb, Abj, lj -> Aai", Cv, Co, A_0_ao_GGA, Cv, X, Co)
)
[43]:
212967700000.0

完整的缩并

刚才我们是先求出了 \(A_{\mu \nu, \kappa \lambda}^\mathrm{GGA}\) 以进行缩并计算;但注意到我们的目标是 \(A_{ai, bj}^\mathrm{GGA} X_{bj}^\mathbb{A}\),因此不妨将剩余的项都一起缩并,即

\[\begin{split}\begin{align} A_{ai, bj}^\mathrm{GGA} X_{bj}^\mathbb{A} &=\quad C_{\mu a} C_{\nu i} C_{\kappa b} X_{bj}^\mathbb{A} C_{\lambda j} \cdot f_{\rho \rho} \phi_\kappa \phi_\lambda \phi_\mu \phi_\nu \\ &\quad + C_{\mu a} C_{\nu i} C_{\kappa b} X_{bj}^\mathbb{A} C_{\lambda j} \cdot 4 f_{\rho \gamma} \rho_r \phi_{r \kappa} \phi_\lambda \phi_\mu \phi_\nu \\ &\quad + C_{\mu a} C_{\nu i} C_{\kappa b} X_{bj}^\mathbb{A} C_{\lambda j} \cdot 4 f_{\rho \gamma} \phi_\kappa \phi_\lambda \rho_r \phi_{r \mu} \phi_{\nu} \\ &\quad + C_{\mu a} C_{\nu i} C_{\kappa b} X_{bj}^\mathbb{A} C_{\lambda j} \cdot 16 f_{\gamma \gamma} \rho_w \phi_{w \kappa} \phi_\lambda \rho_r \phi_{r \mu} \phi_{\nu} \\ &\quad + C_{\mu a} C_{\nu i} C_{\kappa b} X_{bj}^\mathbb{A} C_{\lambda j} \cdot 8 f_\gamma \phi_{r \kappa} \phi_\lambda \phi_{r \mu} \phi_{\nu} \\ &\quad + \ldots \end{align}\end{split}\]

其中,省略号表示的是各种依靠角标对称性进行的计算。程序表达如下:

[44]:
%%time
Ax_GGA_complicated = (
    +      np.einsum("ua, vi, kb, Abj, lj, g,      gk, gl,      gu, gv -> Aai", Cv, Co, Cv, X, Co, frr, ao_0, ao_0, ao_0, ao_0)
    + 4  * np.einsum("ua, vi, kb, Abj, lj, g, rg, rgk, gl,      gu, gv -> Aai", Cv, Co, Cv, X, Co, frg, rho_1, ao_1, ao_0, ao_0, ao_0)
    + 4  * np.einsum("ua, vi, kb, Abj, lj, g,      gk, gl, rg, rgu, gv -> Aai", Cv, Co, Cv, X, Co, frg, ao_0, ao_0, rho_1, ao_1, ao_0)
    + 16 * np.einsum("ua, vi, kb, Abj, lj, g, wg, wgk, gl, rg, rgu, gv -> Aai", Cv, Co, Cv, X, Co, fgg, rho_1, ao_1, ao_0, rho_1, ao_1, ao_0)
    + 8  * np.einsum("ua, vi, kb, Abj, lj, g,     rgk, gl,     rgu, gv -> Aai", Cv, Co, Cv, X, Co, fg, ao_1, ao_0, ao_1, ao_0)
    +      np.einsum("va, ui, kb, Abj, lj, g,      gk, gl,      gu, gv -> Aai", Cv, Co, Cv, X, Co, frr, ao_0, ao_0, ao_0, ao_0)
    + 4  * np.einsum("va, ui, kb, Abj, lj, g, rg, rgk, gl,      gu, gv -> Aai", Cv, Co, Cv, X, Co, frg, rho_1, ao_1, ao_0, ao_0, ao_0)
    + 4  * np.einsum("va, ui, kb, Abj, lj, g,      gk, gl, rg, rgu, gv -> Aai", Cv, Co, Cv, X, Co, frg, ao_0, ao_0, rho_1, ao_1, ao_0)
    + 16 * np.einsum("va, ui, kb, Abj, lj, g, wg, wgk, gl, rg, rgu, gv -> Aai", Cv, Co, Cv, X, Co, fgg, rho_1, ao_1, ao_0, rho_1, ao_1, ao_0)
    + 8  * np.einsum("va, ui, kb, Abj, lj, g,     rgk, gl,     rgu, gv -> Aai", Cv, Co, Cv, X, Co, fg, ao_1, ao_0, ao_1, ao_0)
    +      np.einsum("ua, vi, lb, Abj, kj, g,      gk, gl,      gu, gv -> Aai", Cv, Co, Cv, X, Co, frr, ao_0, ao_0, ao_0, ao_0)
    + 4  * np.einsum("ua, vi, lb, Abj, kj, g, rg, rgk, gl,      gu, gv -> Aai", Cv, Co, Cv, X, Co, frg, rho_1, ao_1, ao_0, ao_0, ao_0)
    + 4  * np.einsum("ua, vi, lb, Abj, kj, g,      gk, gl, rg, rgu, gv -> Aai", Cv, Co, Cv, X, Co, frg, ao_0, ao_0, rho_1, ao_1, ao_0)
    + 16 * np.einsum("ua, vi, lb, Abj, kj, g, wg, wgk, gl, rg, rgu, gv -> Aai", Cv, Co, Cv, X, Co, fgg, rho_1, ao_1, ao_0, rho_1, ao_1, ao_0)
    + 8  * np.einsum("ua, vi, lb, Abj, kj, g,     rgk, gl,     rgu, gv -> Aai", Cv, Co, Cv, X, Co, fg, ao_1, ao_0, ao_1, ao_0)
    +      np.einsum("va, ui, lb, Abj, kj, g,      gk, gl,      gu, gv -> Aai", Cv, Co, Cv, X, Co, frr, ao_0, ao_0, ao_0, ao_0)
    + 4  * np.einsum("va, ui, lb, Abj, kj, g, rg, rgk, gl,      gu, gv -> Aai", Cv, Co, Cv, X, Co, frg, rho_1, ao_1, ao_0, ao_0, ao_0)
    + 4  * np.einsum("va, ui, lb, Abj, kj, g,      gk, gl, rg, rgu, gv -> Aai", Cv, Co, Cv, X, Co, frg, ao_0, ao_0, rho_1, ao_1, ao_0)
    + 16 * np.einsum("va, ui, lb, Abj, kj, g, wg, wgk, gl, rg, rgu, gv -> Aai", Cv, Co, Cv, X, Co, fgg, rho_1, ao_1, ao_0, rho_1, ao_1, ao_0)
    + 8  * np.einsum("va, ui, lb, Abj, kj, g,     rgk, gl,     rgu, gv -> Aai", Cv, Co, Cv, X, Co, fg, ao_1, ao_0, ao_1, ao_0)
)
print(np.allclose(Ax_GGA_complicated, Ax_GGA))
True
CPU times: user 1.81 s, sys: 484 ms, total: 2.3 s
Wall time: 2.5 s

很显然上面的式子太过冗长,一眼就像对其作公因式提取的处理,并且也比 Ax_GGA 消耗更多的代码;但它意外地节省时间。我们拿出其中的五行出来观察浮点计算次数:

[45]:
(
    + get_FLOP("ua, vi, kb, Abj, lj, g, gk, gl, gu, gv -> Aai"          , Cv, Co, Cv, X, Co, frr, ao_0, ao_0, ao_0, ao_0)
    + get_FLOP("ua, vi, kb, Abj, lj, g, rg, rgk, gl, gu, gv -> Aai"     , Cv, Co, Cv, X, Co, frg, rho_1, ao_1, ao_0, ao_0, ao_0)
    + get_FLOP("ua, vi, kb, Abj, lj, g, gk, gl, rg, rgu, gv -> Aai"     , Cv, Co, Cv, X, Co, frg, ao_0, ao_0, rho_1, ao_1, ao_0)
    + get_FLOP("ua, vi, kb, Abj, lj, g, wg, wgk, gl, rg, rgu, gv -> Aai", Cv, Co, Cv, X, Co, fgg, rho_1, ao_1, ao_0, rho_1, ao_1, ao_0)
    + get_FLOP("ua, vi, kb, Abj, lj, g, rgk, gl, rgu, gv -> Aai"        , Cv, Co, Cv, X, Co, fg, ao_1, ao_0, ao_1, ao_0)
)
[45]:
2315900000.0

上述浮点数乘以 4 即是 Ax_GGA_complicated 所需要消耗的计算量了,为 9.2636e+09 次。而 Ax_GGA 尽管有更短的代码和简洁的公式,却需要消耗 2.130e+11 次。

作为一个例子,我们看一下第二项 \(C_{\mu a} C_{\nu i} C_{\kappa b} X_{bj}^\mathbb{A} C_{\lambda j} \cdot 4 f_{\rho \gamma} \rho_r \phi_{r \kappa} \phi_\lambda \phi_\mu \phi_\nu\) 是如何进行张量缩并的:

[46]:
print(np.einsum_path("ua, vi, kb, Abj, lj, g, rg, rgk, gl, gu, gv -> Aai", Cv, Co, Cv, X, Co, frg, rho_1, ao_1, ao_0, ao_0, ao_0)[1])
  Complete contraction:  ua,vi,kb,Abj,lj,g,rg,rgk,gl,gu,gv->Aai
         Naive scaling:  11
     Optimized scaling:  4
      Naive FLOP count:  4.794e+16
  Optimized FLOP count:  4.124e+08
   Theoretical speedup:  116236931.476
  Largest intermediate:  4.077e+06 elements
--------------------------------------------------------------------------
scaling                  current                                remaining
--------------------------------------------------------------------------
   3                  rgk,rg->kg       ua,vi,kb,Abj,lj,g,gl,gu,gv,kg->Aai
   3                   gv,vi->ig          ua,kb,Abj,lj,g,gl,gu,kg,ig->Aai
   3                   gl,lj->jg             ua,kb,Abj,g,gu,kg,ig,jg->Aai
   3                   gu,ua->ag                kb,Abj,g,kg,ig,jg,ag->Aai
   3                   kg,kb->bg                   Abj,g,ig,jg,ag,bg->Aai
   2                    ig,g->ig                     Abj,jg,ag,bg,ig->Aai
   4                 bg,Abj->Ajg                        jg,ag,ig,Ajg->Aai
   3                  Ajg,jg->Ag                            ag,ig,Ag->Aai
   3                  Ag,ig->Aig                              ag,Aig->Aai
   4                 Aig,ag->Aai                                 Aai->Aai

其大致思路是,首先将原子轨道格点缩并到分子轨道格点:

\[\phi_p = \phi_\mu C_{\mu p}\]

但这其中存在例外:

\[\begin{split}\begin{align} (\nabla_\rho \cdot \nabla \phi)_\mu &= \rho_r \phi_{r \mu} \\ (\nabla_\rho \cdot \nabla \phi)_k &= (\nabla_\rho \cdot \nabla \phi)_\mu C_{\mu k} \end{align}\end{split}\]

如此缩并的原因单纯地是因为效率高。随后优先缩并含 \(b, j\) 两个角标:

\[X_{bj}^\mathbb{A} (\nabla_\rho \cdot \nabla \phi)_k \phi_j \rightarrow \varphi^\mathbb{A}\]

最后对剩下的项依次缩并即可。

pyxdh 所使用的缩并方式

需要说明的是,pyxdh 在求取 \(A_{ai, bj} X_{bj}^\mathbb{A}\) 问题时,所使用的仍然是 PySCF 的程序。这一段是为了以后 pyxdh 中 Ax1_Core 张量缩并函数 \(A_{ai, bj}^\mathbb{B} X_{bj}^\mathbb{A}\) 作准备的。

我们不采取上面相对来说激进的缩并方式,即仍然保留原子轨道格点;但我们借助这些原子轨道格点,生成与自洽场密度有别的类密度量。我们先定义原子轨道下的类密度 dmX:

\[X_{\kappa \lambda}^\mathbb{A} = C_{\kappa b} X_{bj}^\mathbb{A} C_{\lambda j} + \mathrm{swap} (\kappa, \lambda)\]
[47]:
dmX = np.einsum("kb, Abj, lj -> Akl", Cv, X, Co)
dmX += dmX.swapaxes(-1, -2)

以上述类密度为前提,我们可以生成类密度的密度格点 rho_X_0

\[\varrho^\mathbb{A} = X_{\kappa \lambda}^\mathbb{A} \phi_\kappa \phi_\lambda\]

及其梯度 rho_X_1

\[\varrho_r^\mathbb{A} = 2 X_{\kappa \lambda}^\mathbb{A} \phi_{r \kappa} \phi_\lambda\]
[48]:
tmp_K = np.einsum("Akl, gl -> Agk", dmX, ao_0)
rho_X_0 = np.einsum("gk, Agk -> Ag", ao_0, tmp_K)
rho_X_1 = 2 * np.einsum("rgk, Agk -> Arg", ao_1, tmp_K)

我们额外定义密度梯度的一种导出量 gamma_XD

\[\varrho_r^\mathbb{A} \rho_r \, (\text{summed by } r)\]
[49]:
gamma_XD = np.einsum("Arg, rg -> Ag", rho_X_1, rho_1)

随后我们就将 \(A_{ai, bj}^\mathrm{GGA} X_{bj}^\mathbb{A}\) 所有缩并项作化简;化简的原则是将所有 \(b, j, \kappa, \lambda\) 的角标先求和。在这种化简的思路下,我们定义两个临时的变量 M_0

\[M^\mathbb{A} = f_{\rho \rho} \varrho^\mathbb{A} + 2 f_{\rho \gamma} (\varrho_w^\mathbb{A} \rho_w)\]

以及 M_1

\[M_r^\mathbb{A} = 4 f_{\rho \gamma} \varrho^\mathbb{A} \rho_r + 8 f_{\rho \gamma} (\varrho_w^\mathbb{A} \rho_w) \rho_r + 4 f_\gamma \varrho_r^\mathbb{A}\]

我们回顾到

\[\begin{split}\begin{align} A_{\mu \nu, \kappa \lambda} \xleftarrow{\text{GGA contrib}} A_{\mu \nu, \kappa \lambda}^\text{GGA} &= f_{\rho \rho} \phi_\kappa \phi_\lambda \phi_\mu \phi_\nu + 4 f_{\rho \gamma} \rho_r \phi_{r \kappa} \phi_\lambda \phi_\mu \phi_\nu \\ & + 4 f_{\rho \gamma} \phi_\kappa \phi_\lambda \rho_r \phi_{r \mu} \phi_{\nu} + 16 f_{\gamma \gamma} \rho_w \phi_{w \kappa} \phi_\lambda \rho_r \phi_{r \mu} \phi_{\nu} \\ & + 8 f_\gamma \phi_{r \kappa} \phi_\lambda \phi_{r \mu} \phi_{\nu} \\ & + \mathrm{swap} (\mu, \nu) + \mathrm{swap} (\kappa, \lambda) \end{align}\end{split}\]

其中,\(M^\mathbb{A}\) 所包含的项与上式的第一行有关,\(M_r^\mathbb{A}\) 则与第二、三行有关。

[50]:
M_0 = (
    np.einsum("g, Ag -> Ag", frr, rho_X_0)
    + 2 * np.einsum("g, Ag -> Ag", frg, gamma_XD)
)
M_1 = (
    + 4 * np.einsum("g, Ag, rg -> Arg", frg, rho_X_0, rho_1)
    + 8 * np.einsum("g, Ag, rg -> Arg", fgg, gamma_XD, rho_1)
    + 4 * np.einsum("g, Arg -> Arg", fg, rho_X_1)
)

最后,

\[\begin{split}\begin{align} A_{\mu \nu, \kappa \lambda} X_{\kappa \lambda}^\mathbb{A} &= M^\mathbb{A} \phi_\mu \phi_\nu + M_r^\mathbb{A} \phi_{r \mu} \phi_\nu + \mathrm{swap} (\mu, \nu) \\ A_{ai, bj}^\mathrm{GGA} X_{bj}^\mathbb{A} &= A_{\mu \nu, \kappa \lambda} X_{\kappa \lambda}^\mathbb{A} C_{\mu a} C_{\nu i} \end{align}\end{split}\]
[51]:
Ax_ao_GGA = (
    + np.einsum("Ag, gu, gv -> Auv", M_0, ao_0, ao_0)
    + np.einsum("Arg, rgu, gv -> Auv", M_1, ao_1, ao_0)
)
Ax_ao_GGA += Ax_ao_GGA.swapaxes(-1, -2)
Ax_GGA_modified = np.einsum("ua, Auv, vi -> Aai", Cv, Ax_ao_GGA, Co)

我们可以验证上述的计算过程是正确的:

[52]:
np.allclose(Ax_GGA, Ax_GGA_modified)
[52]:
True

其消耗时长可以用下面的程序给出:

[53]:
%%time
dmX = np.einsum("kb, Abj, lj -> Akl", Cv, X, Co)
dmX += dmX.swapaxes(-1, -2)
tmp_K = np.einsum("Akl, gl -> Agk", dmX, ao_0)
rho_X_0 = np.einsum("gk, Agk -> Ag", ao_0, tmp_K)
rho_X_1 = 2 * np.einsum("rgk, Agk -> Arg", ao_1, tmp_K)
gamma_XD = np.einsum("Arg, rg -> Ag", rho_X_1, rho_1)
M_0 = (
    np.einsum("g, Ag -> Ag", frr, rho_X_0)
    + 2 * np.einsum("g, Ag -> Ag", frg, gamma_XD)
)
M_1 = (
    + 4 * np.einsum("g, Ag, rg -> Arg", frg, rho_X_0, rho_1)
    + 8 * np.einsum("g, Ag, rg -> Arg", fgg, gamma_XD, rho_1)
    + 4 * np.einsum("g, Arg -> Arg", fg, rho_X_1)
)
Ax_ao_GGA = (
    + np.einsum("Ag, gu, gv -> Auv", M_0, ao_0, ao_0)
    + np.einsum("Arg, rgu, gv -> Auv", M_1, ao_1, ao_0)
)
Ax_ao_GGA += Ax_ao_GGA.swapaxes(-1, -2)
Ax_GGA_modified = np.einsum("ua, Auv, vi -> Aai", Cv, Ax_ao_GGA, Co)
CPU times: user 281 ms, sys: 312 ms, total: 594 ms
Wall time: 609 ms

这种方式在张量运算过程中,所消耗的浮点运算数量大约是 1.4756e+09 次:

[54]:
(
    + get_FLOP("kb, Abj, lj -> Akl", Cv, X, Co)
    + get_FLOP("Akl, gl -> Agk", dmX, ao_0)
    + get_FLOP("gk, Agk -> Ag", ao_0, tmp_K)
    + get_FLOP("rgk, Agk -> Arg", ao_1, tmp_K)
    + get_FLOP("Arg, rg -> Ag", rho_X_1, rho_1)
    + get_FLOP("g, Ag -> Ag", frr, rho_X_0)
    + get_FLOP("g, Ag -> Ag", frg, gamma_XD)
    + get_FLOP("g, Ag, rg -> Arg", frg, rho_X_0, rho_1)
    + get_FLOP("g, Ag, rg -> Arg", fgg, gamma_XD, rho_1)
    + get_FLOP("g, Arg -> Arg", fg, rho_X_1)
    + get_FLOP("Ag, gu, gv -> Auv", M_0, ao_0, ao_0)
    + get_FLOP("Arg, rgu, gv -> Auv", M_1, ao_1, ao_0)
    + get_FLOP("ua, Auv, vi -> Aai", Cv, Ax_ao_GGA, Co)
)
[54]:
1475587600.0

其计算速度之所以可以更快,是因为对不少可以提取出来的中间变量作临时储存,并且利用到了角标对称性。

PySCF 的内置程序事实上可以在 \(\mathbb{A}\) 所代表的维度较大时有更好的表现,这可能是因为 PySCF 使用到了 C 语言的底层。

XYG3 型泛函核坐标梯度与核坐标梯度 CheatSheet

这一节我们会先简单讨论 XYG3 核坐标梯度的计算方式;随后完整地从程序上完整地整理一遍核坐标梯度的编写过程。这一节希望这里的内容也可以作为能快速翻阅查找具体矩阵或张量计算方法的工具。

准备工作

[1]:
%matplotlib notebook

from pyscf import gto, scf, dft, lib, grad, hessian
from pyscf.scf import cphf
import numpy as np
from functools import partial
import warnings
from matplotlib import pyplot as plt
from pyxdh.Utilities import NucCoordDerivGenerator, DipoleDerivGenerator, NumericDiff, GridHelper, KernelHelper
from pyxdh.DerivOnce import GradSCF, GradMP2, GradXDH

np.einsum = partial(np.einsum, optimize=["greedy", 1024 ** 3 * 2 / 8])
np.allclose = partial(np.allclose, atol=1e-6, rtol=1e-4)
np.set_printoptions(5, linewidth=150, suppress=True)
warnings.filterwarnings("ignore")
[2]:
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()
[2]:
<pyscf.gto.mole.Mole at 0x7fa5377020a0>
[3]:
def mol_to_grids(mol, atom_grid=(75, 302)):
    grids = dft.Grids(mol)
    grids.atom_grid = atom_grid
    grids.becke_scheme = dft.gen_grid.stratmann
    grids.prune = None
    grids.build()
    return grids
grids = mol_to_grids(mol)

我们知道 XYG3 分为自洽场部分与非自洽部分;其自洽场部分使用 B3LYP 泛函导出电子态密度 \(D_{\mu \nu}\),进而代入密度到非自洽部分。因此,我们需要有自洽场与非自洽两个计算实例。其中,B3LYP 的自洽场实例就与以前定义的方式相同:

[4]:
def mol_to_scf(mol):
    scf_eng = dft.RKS(mol)
    scf_eng.grids = mol_to_grids(mol)
    scf_eng.xc = "B3LYPg"
    scf_eng.conv_tol = 1e-10
    return scf_eng.run()

而非自洽部分则定义为 XYG3 除开 PT2 部分贡献之外的泛函,但该计算实例不应通过自洽场运算 (即运行 .run 成员函数):

[5]:
def mol_to_nc(mol):
    nc_eng = dft.RKS(mol)
    nc_eng.grids = mol_to_grids(mol)
    nc_eng.xc = "0.8033*HF - 0.0140*LDA + 0.2107*B88, 0.6789*LYP"
    nc_eng.conv_tol = 1e-10
    return nc_eng

XYG3 型泛函所使用的梯度计算类是 GradXDH;它所必须需要的输入参数是 scf_eng 自洽场运算实例与 nc_eng 非自洽运算实例:

[6]:
scf_eng = mol_to_scf(mol)
nc_eng = mol_to_nc(mol)
gradh = GradXDH({"scf_eng": scf_eng, "nc_eng": nc_eng, "cc": 0.3211, "cphf_tol": 1e-12})

与自洽场有关的各种矩阵会定义如下:

[7]:
nmo, nao, natm, nocc, nvir, cx, cc = gradh.nao, gradh.nao, gradh.natm, gradh.nocc, gradh.nvir, gradh.cx, gradh.cc
mol_slice = gradh.mol_slice
so, sv, sa = gradh.so, gradh.sv, gradh.sa
C, Co, Cv, e, eo, ev, D = gradh.C, gradh.Co, gradh.Cv, gradh.e, gradh.eo, gradh.ev, gradh.D
H_0_ao, S_0_ao, eri0_ao, F_0_ao = gradh.H_0_ao, gradh.S_0_ao, gradh.eri0_ao, gradh.F_0_ao
H_0_mo, S_0_mo, eri0_mo, F_0_mo = gradh.H_0_mo, gradh.S_0_mo, gradh.eri0_mo, gradh.F_0_mo
T_iajb, t_iajb, D_iajb = gradh.T_iajb, gradh.t_iajb, gradh.D_iajb

注意到上面是自洽场过程给出的结果;而对于非自洽部分,我们需要非自洽交换积分系数 cx_n \(c_\mathrm{x}^\mathrm{n}\)

[8]:
cx_n = gradh.nc_deriv.cx
cx_n
[8]:
0.8033

对于自洽或非自洽过程,原子轨道或涉及到密度的、核坐标梯度的格点都是相同的:

[9]:
grdh = GridHelper(mol, grids, D)
ao_0, ao_1, ao_2 = grdh.ao_0, grdh.ao_1, grdh.ao_2
rho_0, rho_1, rho_2 = grdh.rho_0, grdh.rho_1, grdh.rho_2

但涉及到泛函核的部分,我们就需要区分对待了。kerh 储存的是自洽泛函即 B3LYP 的泛函核格点,kerh_n 储存的是非自洽部分的泛函核格点。

[10]:
kerh = KernelHelper(grdh, "B3LYPg")
kerh_n = KernelHelper(grdh, "0.8033*HF - 0.0140*LDA + 0.2107*B88, 0.6789*LYP")

最后我们给出 XYG3 用于数值求导的实例:

[11]:
def grad_generator(mol):
    return GradXDH({"scf_eng": mol_to_scf(mol), "nc_eng": mol_to_nc(mol), "cc": 0.3211, "cphf_tol": 1e-12})
gradn = NucCoordDerivGenerator(mol, grad_generator)

由于这一节的特殊性,我们特定一个函数用于绘制数值与解析矩阵之间的差距:

[12]:
def plot_diff(anal_mat, num_mat):
    fig, ax = plt.subplots(figsize=(2.4, 1.8)); ax.set_xscale("log")
    ax.hist(abs(anal_mat.ravel() - num_mat.ravel()), bins=np.logspace(np.log10(1e-10), np.log10(1e-1), 50), alpha=0.5)
    ax.hist(abs(num_mat.ravel()), bins=np.logspace(np.log10(1e-10), np.log10(1e-1), 50), alpha=0.5)
    return fig.tight_layout()

XYG3 型核坐标梯度:与 B2PLYP 型梯度的差异

XYG3 能量

我们首先回顾一下 XYG3 能量的计算:

\[E = h_{\mu \nu} D_{\mu \nu} + \frac{1}{2} D_{\mu \nu} (\mu \nu | \kappa \lambda) D_{\kappa \lambda} - \frac{c_\mathrm{x}^\mathrm{n}}{4} D_{\mu \nu} (\mu \kappa | \nu \lambda) D_{\kappa \lambda} + f^\mathrm{n} \rho + T_{ij}^{ab} t_{ij}^{ab} D_{ij}^{ab} + E_\mathrm{nuc}\]
[13]:
(
    + np.einsum("uv, uv -> ", H_0_ao, D)
    + 0.5 * np.einsum("uv, uvkl, kl -> ", D, eri0_ao, D)
    - 0.25 * cx_n * np.einsum("uv, ukvl, kl -> ", D, eri0_ao, D)
    + np.einsum("g, g -> ", kerh_n.exc, rho_0)
    + np.einsum("iajb, iajb, iajb -> ", T_iajb, t_iajb, D_iajb)
    + gradh.scf_eng.energy_nuc()
)
[13]:
-151.1962817631275

我们注意到,上述计算中,\(h_{\mu \nu}, (\mu \nu | \kappa \lambda), E_\mathrm{nuc}\) 为分子构型决定量,\(D_{\mu \nu}, \rho, D_{ij}^{ab}, t_{ij}^{ab}\) 来自于自洽场计算或其导出量,\(c_\mathrm{x}, T_{ij}^{ab}, f^\mathrm{n}\) 是非自洽导出量。若我们要求取梯度时,其余的量都可以参考 B2PLYP 的做法;但涉及到非自洽过程的量就需要额外注意。

XYG3 核坐标梯度:理论讨论

我们需要先对 XYG3 能量作拆分,其中的 GGA 部分能量记为

\[E_\mathrm{GGA} = h_{\mu \nu} D_{\mu \nu} + \frac{1}{2} D_{\mu \nu} (\mu \nu | \kappa \lambda) D_{\kappa \lambda} - \frac{c_\mathrm{x}^\mathrm{n}}{4} D_{\mu \nu} (\mu \kappa | \nu \lambda) D_{\kappa \lambda} + f^\mathrm{n} \rho\]

其 Skeleton 导数与 U 导数我们就仿照以前的文档进行处理,可以得到

\[\partial_{A_t} E_\mathrm{GGA} \xleftarrow{\text{Skeleton derivative}} h_{\mu \nu}^{A_t} D_{\mu \nu} + \frac{1}{2} D_{\mu \nu} (\mu \nu | \kappa \lambda)^{A_t} D_{\kappa \lambda} - \frac{c_\mathrm{x}^\mathrm{n}}{4} D_{\mu \nu} (\mu \kappa | \nu \lambda)^{A_t} D_{\kappa \lambda} + f_\rho^\mathrm{n} \rho^{A_t} + f_\gamma^\mathrm{n} \gamma_r^{A_t}\]

U 导数则为

\[\partial_{A_t} E_\mathrm{GGA} \xleftarrow{\text{U derivative}} 4 F_{pi}^\mathrm{n} U_{pi}^{A_t} = - 2 S_{ij}^{A_t} F_{ij}^\mathrm{n} + 4 F_{ai}^\mathrm{n} U_{ai}^{A_t}\]

我们曾经提到过,对于 Canonical SCF 方法,Fock 矩阵是对角矩阵;但对于非自洽泛函而言,由于轨道系数并非是通过 Fock 对角化得到,因而 \(F_{pq}^\mathrm{n}\) 也显然未必是对角矩阵,即使它仍然具有对称的性质。那么,\(F_{ai}^\mathrm{n}\) 也就是非零的,因此 GGA 的 U 导数部分比起之前文档中,似乎多了 \(4 F_{ai}^\mathrm{n} U_{ai}^\mathrm{n}\) 的贡献。

随后,我们考虑 PT2 部分的能量导数。尽管 \(T_{ij}^{ab}\) 确实包含了非自洽的贡献,但也仅限于相关系数 \(c_\mathrm{c}^\mathrm{n}\);因此我们求取 PT2 部分能量的导数比起 B2PLYP 来没有实质变化:

\[\partial_{A_t} E_\mathrm{PT2} = D_{pq}^\mathrm{PT2} B_{pq}^{A_t} + W_{pq}^\mathrm{PT2} [\mathrm{I}] S_{pq}^{A_t} + 2 T_{ij}^{ab} (ia|jb)^{A_t}\]

但这里我们需要作补充。我们知道,\(D_{ai}^\mathrm{PT2}\) 是通过 Z-Vector 方法给出的,\(D_{ai}^\mathrm{PT2} B_{ai}^{A_t}\) 在经历 Z-Vector 之前是 \(U_{ai}^{A_t} L_{ai}\)。因此,上式还可以写为

\[\partial_{A_t} E_\mathrm{PT2} = U_{ai}^{A_t} L_{ai} + D_{ij}^\mathrm{PT2} B_{ij}^{A_t} + D_{ab}^\mathrm{PT2} B_{ab}^{A_t} + W_{pq}^\mathrm{PT2} [\mathrm{I}] S_{pq}^{A_t} + 2 T_{ij}^{ab} (ia|jb)^{A_t}\]

我们整合上式中出现 U 矩阵的部分,与 GGA 能量的 U 导数中 U 矩阵的部分,得到

\[\partial_{A_t} E_\mathrm{GGA} \leftarrow U_{ai}^{A_t} (4 F_{ai}^\mathrm{n} + L_{ai})\]

我们知道,直接求取 U 矩阵是相对来说耗时的方法,Z-Vector 方程则可以降低计算的消耗。因此,我们再补充定义了 \(L_{ai}^\mathrm{PT2+}\):(Su, eq.50)

\[L_{ai}^\mathrm{PT2+} = L_{ai} + 4 F_{ai}^\mathrm{n}\]

从而使用 Z-Vector 方法,给出的弛豫密度定义为 \(D_{ai}^\mathrm{PT2+}\)

\[- (\varepsilon_a - \varepsilon_i) D_{ai}^\mathrm{MP2+} - A_{ai, bj} D_{bj}^\mathrm{MP2+} = L_{ai}^\mathrm{PT2+}\]

我们进而定义

\[D_{ij}^\mathrm{PT2+} = D_{ij}^\mathrm{PT2}, D_{ab}^\mathrm{PT2+} = D_{ab}^\mathrm{PT2}, D_{ia}^\mathrm{PT2+} = D_{ia}^\mathrm{PT2} = 0\]

因此,我们最终得到了 XYG3 型泛函的导数:

\[\begin{split}\begin{align} \partial_{A_t} E_\mathrm{tot} &= h_{\mu \nu}^{A_t} D_{\mu \nu} + \frac{1}{2} D_{\mu \nu} (\mu \nu | \kappa \lambda)^{A_t} D_{\kappa \lambda} - \frac{c_\mathrm{x}^\mathrm{n}}{4} D_{\mu \nu} (\mu \kappa | \nu \lambda)^{A_t} D_{\kappa \lambda} + f_\rho^\mathrm{n} \rho^{A_t} + f_\gamma^\mathrm{n} \gamma^{A_t} - 2 S_{ij}^{A_t} F_{ij}^\mathrm{n} \\ &\quad + D_{pq}^\mathrm{PT2+} B_{pq}^{A_t} + W_{pq}^\mathrm{PT2} [\mathrm{I}] S_{pq}^{A_t} + 2 T_{ij}^{ab} (ia|jb)^{A_t} + \partial_{A_t} E_\mathrm{nuc} \end{align}\end{split}\]

至此,理论上的讨论就结束了。我们下面就将计算该导数所需要使用到的矩阵和张量的计算方式重新回顾一遍,并实现 XYG3 型泛函的梯度。

XYG3 型核坐标梯度:程序实现 CheatSheet

The following equations and code is utilized for XYG3 (or other XYG3-type double hybrid, such as XYGJ-OS or xDH-PBE0, with minimal modification to definition of \(T_{ij}^{ab}\)); however, most functionals could be calculated by formulation degradation:

  • for B2PLYP-type double hybrid, set \(c_\mathrm{x}^\mathrm{n}, f^\mathrm{n}\) set as its self-consistent alternative, and remove \(F_{ai}^\mathrm{n}\) from \(L_{ai}^\mathrm{PT2+}\);

  • for MP2, remove all contribution from exchange-correlation kernel, and set \(c_\mathrm{x} = 1, c_\mathrm{c} = 1\);

  • for non-consistent GGA, set \(c_\mathrm{c} = 0\) or remove all terms that contains \(T_{ij}^{ab}\);

  • for GGA, further remove contribution of response density matrix from that of non-consistent GGA;

  • for RHF, further remove contribution of exchange-correlation kernel from that of GGA, and set \(c_\mathrm{x} = 1\).

nc_F_0_mo \(F_{pq}^\mathrm{n}\) Non-consistent (hybrid) GGA Fock Matrix

  • nc_F_0_ao \(F_{\mu \nu}^\mathrm{n}\); dim: \((\mu, \nu)\); symm: \(F_{\mu \nu}^\mathrm{n} = F_{\nu \mu}^\mathrm{n}\)

\[F_{\mu \nu}^\mathrm{n} = h_{\mu \nu} + (\mu \nu | \kappa \lambda) D_{\kappa \lambda} - \frac{c_\mathrm{x}^\mathrm{n}}{2} (\mu \kappa | \nu \lambda) D_{\kappa \lambda} + f_\rho^\mathrm{n} \phi_\mu \phi_\nu + 2 f_\gamma^\mathrm{n} \rho_r (\phi_{r \mu} \phi_{\nu} + \phi_{\mu} \phi_{r \nu})\]
[14]:
nc_F_0_ao = (
    + H_0_ao
    + np.einsum("uvkl, kl -> uv", eri0_ao, D)
    - 0.5 * cx_n * np.einsum("ukvl, kl -> uv", eri0_ao, D)
    + np.einsum("g, gu, gv -> uv", kerh_n.fr, ao_0, ao_0)
    + 2 * np.einsum("g, rg, rgu, gv -> uv", kerh_n.fg, rho_1, ao_1, ao_0)
    + 2 * np.einsum("g, rg, gu, rgv -> uv", kerh_n.fg, rho_1, ao_0, ao_1)
)
nc_F_0_ao.shape
[14]:
(22, 22)
[15]:
np.allclose(nc_F_0_ao, nc_F_0_ao.swapaxes(-1, -2))  # symm check
[15]:
True
[16]:
np.allclose(nc_F_0_ao, gradh.nc_deriv.F_0_ao)  # pyxdh approach
[16]:
True
[17]:
np.allclose(nc_F_0_ao, nc_eng.get_fock(dm=D))  # PySCF approach
[17]:
True
  • nd_F_0_mo \(F_{pq}^\mathrm{n}\); dim: \((p, q)\); symm: \(F_{pq}^\mathrm{n} = F_{qp}^\mathrm{n}\); additional: \(F_{ai}^\mathrm{n} \not \equiv 0\)

\[F_{pq}^\mathrm{n} = C_{\mu p} F_{\mu \nu}^\mathrm{n} C_{\nu q}\]
[18]:
nc_F_0_mo = np.einsum("up, uv, vq -> pq", C, nc_F_0_ao, C)
nc_F_0_mo.shape
[18]:
(22, 22)
[19]:
np.allclose(nc_F_0_mo, nc_F_0_mo.swapaxes(-1, -2))  # symm check
[19]:
True
[20]:
np.abs(nc_F_0_mo[sv, so]).sum()  # non-zero value check
[20]:
0.7106166623512785
[21]:
np.abs(F_0_mo[sv, so]).sum()  # Compare with F_{ai} derived from self-consistent functional
[21]:
1.0579646672548194e-06
[22]:
np.allclose(nc_F_0_mo, gradh.nc_deriv.F_0_mo)  # pyxdh approach
[22]:
True

S_1_ao \(S_{\mu \nu}^{A_t}\) Overlap Integral Skeleton

  • int1e_ipovlp \(\langle \partial_t \mu | \nu \rangle\); dim \((t, \mu, \nu)\); symm \(\langle \partial_t \mu | \nu \rangle = - \langle \mu | \partial_t \nu \rangle\)

[23]:
int1e_ipovlp = mol.intor("int1e_ipovlp")
int1e_ipovlp.shape
[23]:
(3, 22, 22)
[24]:
np.allclose(int1e_ipovlp, - int1e_ipovlp.swapaxes(-1, -2))
[24]:
True
  • S_1_ao \(S_{\mu \nu}^{A_t}\); dim \((A, t, \mu, \nu)\) in document, \((A_t, \mu, \nu)\) in pyxdh; symm: \(S_{\mu \nu}^{A_t} = S_{\nu \mu}^{A_t}\)

\[S_{\mu \nu}^{A_t} = \frac{\partial}{\partial A_t} S_{\mu \nu} = - \langle \partial_t \mu_A | \nu \rangle + \mathrm{swap} (\mu, \nu)\]
[25]:
S_1_ao = np.zeros((natm, 3, nao, nao))
for A in range(natm):
    sA = mol_slice(A)
    S_1_ao[A, :, sA, :] = - int1e_ipovlp[:, sA, :]
S_1_ao += S_1_ao.swapaxes(-1, -2)
S_1_ao.shape
[25]:
(4, 3, 22, 22)
[26]:
np.allclose(S_1_ao, S_1_ao.swapaxes(-1, -2))  # symm check
[26]:
True
[27]:
np.allclose(S_1_ao, gradh.S_1_ao.reshape(natm, 3, nao, nao))  # pyxdh approach
[27]:
True
[28]:
nd_S_0_ao = NumericDiff(gradn, lambda gradh: gradh.S_0_ao).derivative
plot_diff(S_1_ao, nd_S_0_ao)
  • S_1_mo \(S_{pq}^{A_t}\); dim \((A, t, p, q)\) in document, \((A_t, p, q)\) in pyxdh; symm: \(S_{pq}^{A_t} = S_{qp}^{A_t}\)

\[S_{pq}^{A_t} = C_{\mu p} S_{\mu \nu}^{A_t} C_{\nu q}\]
[29]:
S_1_mo = np.einsum("up, Atuv, vq -> Atpq", C, S_1_ao, C)
S_1_mo.shape
[29]:
(4, 3, 22, 22)
[30]:
np.allclose(S_1_mo, S_1_mo.swapaxes(-1, -2))  # symm check
[30]:
True
[31]:
np.allclose(S_1_mo, gradh.S_1_mo.reshape(natm, 3, nao, nao))  # pyxdh approach
[31]:
True

H_1_ao \(h_{\mu \nu}^{A_t}\) Hamiltonian Core Skeleton

  • int1e_ipkin \(\langle \partial_t \mu | \hat t | \nu \rangle\); dim: \((t, \mu, \nu)\); symm: \(\langle \partial_t \mu | \hat t | \nu \rangle = - \langle \mu | \hat t | \partial_t \nu \rangle\)

[32]:
int1e_ipkin = mol.intor("int1e_ipkin")
int1e_ipkin.shape
[32]:
(3, 22, 22)
[33]:
np.allclose(int1e_ipkin, - int1e_ipkin.swapaxes(-1, -2))  # symm check
[33]:
True
  • int1e_ipkin \(\langle \partial_t \mu | \hat v_\mathrm{nuc} | \nu \rangle\); dim: \((t, \mu, \nu)\); symm: no

[34]:
int1e_ipnuc = mol.intor("int1e_ipnuc")
int1e_ipnuc.shape
[34]:
(3, 22, 22)
  • H_1_ao \(h_{\mu \nu}^{A_t}\); dim: \((A, t, \mu, \nu)\) in document, \((A_t, \mu, \nu)\) in pyxdh; symm: \(h_{\mu \nu}^{A_t} = h_{\nu \mu}^{A_t}\)

\[h_{\mu \nu}^{A_t} = \frac{\partial}{\partial A_t} h_{\mu \nu} = - \langle \partial_t \mu_A | \hat t | \nu \rangle - \langle \partial_t \mu_A | \hat v_\mathrm{nuc} | \nu \rangle - \langle \partial_t \mu | \frac{Z_A}{| \boldsymbol{r} - \boldsymbol{A} |} | \nu \rangle + \mathrm{swap} (\mu, \nu)\]
[35]:
Z_A = mol.atom_charges()
H_1_ao = np.zeros((natm, 3, nao, nao))
for A in range(natm):
    sA = mol_slice(A)
    H_1_ao[A, :, sA, :] -= int1e_ipkin[:, sA, :]
    H_1_ao[A, :, sA, :] -= int1e_ipnuc[:, sA, :]
    with mol.with_rinv_as_nucleus(A):
        H_1_ao[A] -= Z_A[A] * mol.intor("int1e_iprinv")
H_1_ao += H_1_ao.swapaxes(-1, -2)
H_1_ao.shape
[35]:
(4, 3, 22, 22)
[36]:
np.allclose(H_1_ao, H_1_ao.swapaxes(-1, -2))  # symm check
[36]:
True
[37]:
np.allclose(H_1_ao, gradh.H_1_ao.reshape(natm, 3, nao, nao))  # pyxdh approach
[37]:
True
[38]:
np.allclose(H_1_ao, np.array([grad.rhf.hcore_generator(scf_eng.nuc_grad_method())(A) for A in range(natm)]))  # PySCF approach
[38]:
True
[39]:
nd_H_0_ao = NumericDiff(gradn, lambda gradh: gradh.H_0_ao).derivative
plot_diff(H_1_ao, nd_H_0_ao)
  • H_1_mo \(h_{pq}^{A_t}\); dim: \((A, t, p, q)\) in document, \((A_t, p, q)\) in pyxdh; symm: \(h_{pq}^{A_t} = h_{qp}^{A_t}\)

\[h_{pq}^{A_t} = C_{\mu p} h_{\mu \nu}^{A_t} C_{\nu q}\]
[40]:
H_1_mo = np.einsum("up, Atuv, vq -> Atpq", C, H_1_ao, C)
H_1_mo.shape
[40]:
(4, 3, 22, 22)
[41]:
np.allclose(H_1_mo, gradh.H_1_mo.reshape(natm, 3, nmo, nmo))  # pyxdh approach
[41]:
True

eri1_ao \((\mu \nu | \kappa \lambda)^{A_t}\) Electron-repulsion Integral Skeleton

  • int2e_ip1 \((\partial_t \mu \nu | \kappa \lambda)\); dim: \((A, t, \mu, \nu, \kappa, \lambda)\); symm: \((\partial_t \mu \nu | \kappa \lambda) = (\partial_t \mu \nu | \lambda \kappa)\)

[42]:
int2e_ip1 = mol.intor("int2e_ip1")
int2e_ip1.shape
[42]:
(3, 22, 22, 22, 22)
[43]:
np.allclose(int2e_ip1, int2e_ip1.swapaxes(-1, -2))  # symm check
[43]:
True
  • eri1_ao \((\mu \nu | \kappa \lambda)^{A_t}\); dim: \((A, t, \mu, \nu, \kappa, \lambda)\) in document, \((A_t, \mu, \nu, \kappa, \lambda)\) in pyxdh; symm: \((\mu \nu | \kappa \lambda)^{A_t} = (\mu \nu | \lambda \kappa)^{A_t} = (\kappa \lambda | \mu \nu)^{A_t}\) (8-fold)

\[\begin{split}\begin{split}\begin{align} (\mu \nu | \kappa \lambda)^{A_t} = \frac{\partial}{\partial A_t} (\mu \nu | \kappa \lambda) &= - \big[ (\partial_t \mu_A \nu | \kappa \lambda) + (\mu \partial_t \nu_A | \kappa \lambda) + (\mu \nu | \partial_t \kappa_A \lambda) + (\mu \nu | \kappa \partial_t \lambda_A) \big] \\ &= - (\partial_t \mu_A \nu | \kappa \lambda) + \mathrm{swap} (\mu, \nu) + \mathrm{swap} (\mu \nu, \kappa \lambda) \end{align}\end{split}\end{split}\]
[44]:
eri1_ao = np.zeros((natm, 3, nao, nao, nao, nao))
for A in range(natm):
    sA = mol_slice(A)
    eri1_ao[A, :, sA, :, :, :] -= int2e_ip1[:, sA]
eri1_ao += eri1_ao.swapaxes(-3, -4)
eri1_ao += eri1_ao.swapaxes(-1, -3).swapaxes(-2, -4)
eri1_ao.shape
[44]:
(4, 3, 22, 22, 22, 22)
[45]:
np.allclose(eri1_ao, eri1_ao.swapaxes(-1, -2))  # symm check
[45]:
True
[46]:
np.allclose(eri1_ao, eri1_ao.swapaxes(-3, -4))  # symm check
[46]:
True
[47]:
np.allclose(eri1_ao, eri1_ao.swapaxes(-1, -3).swapaxes(-2, -4))  # symm check
[47]:
True
[48]:
np.allclose(eri1_ao, gradh.eri1_ao.reshape(natm, 3, nao, nao, nao, nao))  # pyxdh approach
[48]:
True
[49]:
nd_eri0_ao = NumericDiff(gradn, lambda gradh: gradh.eri0_ao).derivative
plot_diff(eri1_ao, nd_eri0_ao)
  • eri1_mo \((pq|rs)^{A_t}\); dim: \((A, t, p, q, r, s)\) in document, \((A_t, p, q, r, s)\) in pyxdh; symm: \((pq|rs)^{A_t} = (pq|sr)^{A_t} = (rs|pq)^{A_t}\) (8-fold)

\[(pq|rs)^{A_t} = C_{\mu p} C_{\nu q} (\mu \nu | \kappa \lambda)^{A_t} C_{\kappa r} C_{\lambda s}\]
[50]:
eri1_mo = np.einsum("up, vq, Atuvkl, kr, ls -> Atpqrs", C, C, eri1_ao, C, C)
eri1_mo.shape
[50]:
(4, 3, 22, 22, 22, 22)
[51]:
np.allclose(eri1_mo, eri1_mo.swapaxes(-1, -2))  # symm check
[51]:
True
[52]:
np.allclose(eri1_mo, eri1_mo.swapaxes(-3, -4))  # symm check
[52]:
True
[53]:
np.allclose(eri1_mo, eri1_mo.swapaxes(-1, -3).swapaxes(-2, -4))  # symm check
[53]:
True
[54]:
np.allclose(eri1_mo, gradh.eri1_mo.reshape(natm, 3, nao, nao, nao, nao))  # pyxdh approach
[54]:
True

A_rho_1 \(\rho^{A_t}\) Density Grid Skeleton

  • A_rho_1 \(\rho^{A_t}\); dim: \((A, t, g)\)

\[\rho^{A_t} = - 2 \phi_{t \mu_A} \phi_\nu D_{\mu \nu}\]
[55]:
A_rho_1 = np.zeros((natm, 3, grdh.ngrid))
for A in range(natm):
    sA = mol_slice(A)
    A_rho_1[A] = - 2 * np.einsum("tgu, gv, uv -> tg", ao_1[:, :, sA], ao_0, D[sA])
A_rho_1.shape
[55]:
(4, 3, 90600)
[56]:
np.allclose(A_rho_1, grdh.A_rho_1)  # pyxdh approach
[56]:
True

A_rho_2 \(\rho_r^{A_t}\) Density Derivative Grid Skeleton

  • A_rho_2 \(\rho_r^{A_t}\); dim: \((A, t, r, g)\)

\[\rho_r^{A_t} = - 2 \phi_{tr \mu_A} \phi_\nu D_{\mu \nu} - 2 \phi_{r \mu} \phi_{t \nu_A} D_{\mu \nu}\]
[57]:
A_rho_2 = np.zeros((natm, 3, 3, grdh.ngrid))
for A in range(natm):
    sA = mol_slice(A)
    A_rho_2[A]  = - 2 * np.einsum("trgu, gv, uv -> trg", ao_2[:, :, :, sA], ao_0, D[sA])
    A_rho_2[A] += - 2 * np.einsum("rgu, tgv, uv -> trg", ao_1, ao_1[:, :, sA], D[:, sA])
A_rho_2.shape
[57]:
(4, 3, 3, 90600)
[58]:
np.allclose(A_rho_2, grdh.A_rho_2)  # pyxdh approach
[58]:
True

A_gamma_1 \(\gamma^{A_t}\) Skeleton

  • A_gamma_1 \(\gamma^{A_t}\); dim: \((A, t, g)\)

\[\gamma^{A_t} = 2 \rho_r \rho_r^{A_t}\]
[59]:
A_gamma_1 = 2 * np.einsum("rg, Atrg -> Atg", rho_1, A_rho_2)
A_gamma_1.shape
[59]:
(4, 3, 90600)
[60]:
np.allclose(A_gamma_1, grdh.A_gamma_1)  # pyxdh approach
[60]:
True

F_1_ao \(F_{\mu \nu}^{A_t}\) (Self-consistent) Fock Skeleton

  • F_1_ao_GGA \(v_{\mu \nu}^\mathrm{xc}\); dim: \((A, t, \mu, \nu)\); symm: \(v_{\mu \nu}^\mathrm{xc} = v_{\nu \mu}^\mathrm{xc}\)

\[\begin{split}\begin{align} \partial_{A_t} v_{\mu \nu}^\mathrm{xc} &= \frac{1}{2} f_{\rho \rho} \rho^{A_t} \phi_\mu \phi_\nu + \frac{1}{2} f_{\rho \gamma} \gamma^{A_t} \phi_\mu \phi_\nu \\ &\quad + 2 f_{\rho \gamma} \rho^{A_t} \rho_r \phi_{r \mu} \phi_{\nu} + 2 f_{\gamma \gamma} \gamma^{A_t} \rho_r \phi_{r \mu} \phi_{\nu} \\ &\quad + 2 f_\gamma \rho_r^{A_t} \phi_{r \mu} \phi_{\nu} \\ &\quad - f_\rho \phi_{t \mu_A} \phi_\nu - 2 f_\gamma \rho_r \phi_{tr \mu_A} \phi_{\nu} - 2 f_\gamma \rho_r \phi_{t \mu_A} \phi_{r \nu} \end{align}\end{split}\]
[61]:
F_1_ao_GGA = (
    + 0.5 * np.einsum("g, Atg, gu, gv -> Atuv", kerh.frr, A_rho_1, ao_0, ao_0)
    + 0.5 * np.einsum("g, Atg, gu, gv -> Atuv", kerh.frg, A_gamma_1, ao_0, ao_0)
    + 2 * np.einsum("g, Atg, rg, rgu, gv -> Atuv", kerh.frg, A_rho_1, rho_1, ao_1, ao_0)
    + 2 * np.einsum("g, Atg, rg, rgu, gv -> Atuv", kerh.fgg, A_gamma_1, rho_1, ao_1, ao_0)
    + 2 * np.einsum("g, Atrg, rgu, gv -> Atuv", kerh.fg, A_rho_2, ao_1, ao_0)
)
for A in range(natm):
    sA = mol_slice(A)
    F_1_ao_GGA[A, :, sA, :] -= np.einsum("g, tgu, gv -> tuv", kerh.fr, ao_1[:, :, sA], ao_0)
    F_1_ao_GGA[A, :, sA, :] -= 2 * np.einsum("g, rg, trgu, gv -> tuv", kerh.fg, rho_1, ao_2[:, :, :, sA], ao_0)
    F_1_ao_GGA[A, :, sA, :] -= 2 * np.einsum("g, rg, tgu, rgv -> tuv", kerh.fg, rho_1, ao_1[:, :, sA], ao_1)
F_1_ao_GGA += F_1_ao_GGA.swapaxes(-1, -2)  # swap (u, v)
F_1_ao_GGA.shape
[61]:
(4, 3, 22, 22)
[62]:
np.allclose(F_1_ao_GGA, F_1_ao_GGA.swapaxes(-1, -2))  # symm check
[62]:
True
  • F_1_ao \(F_{\mu \nu}^{A_t}\); dim: \((A, t, \mu, \nu)\) in document, \((A_t, \mu, \nu)\) in pyxdh; symm: \(F_{\mu \nu}^{A_t} = F_{\nu \mu}^{A_t}\)

\[F_{\mu \nu}^{A_t} = h_{\mu \nu}^{A_t} + (\mu \nu | \kappa \lambda)^{A_t} D_{\kappa \lambda} - \frac{c_\mathrm{x}}{2} (\mu \kappa | \nu \lambda)^{A_t} D_{\kappa \lambda} + v_{\mu \nu}^{\mathrm{xc}, A_t}\]
[63]:
F_1_ao = (
    + H_1_ao
    + np.einsum("Atuvkl, kl -> Atuv", eri1_ao, D)
    - 0.5 * cx * np.einsum("Atukvl, kl -> Atuv", eri1_ao, D)
    + F_1_ao_GGA
)
F_1_ao.shape
[63]:
(4, 3, 22, 22)
[64]:
np.allclose(F_1_ao, F_1_ao.swapaxes(-1, -2))  # symm check
[64]:
True
[65]:
np.allclose(F_1_ao, gradh.F_1_ao.reshape(natm, 3, nao, nao))  # pyxdh approach
[65]:
True
[66]:
np.allclose(F_1_ao, np.array(hessian.rks.Hessian(scf_eng).make_h1(C, gradh.mo_occ)))  # PySCF approach
[66]:
True
  • F_1_mo \(F_{pq}^{A_t}\); dim: \((A, t, p, q)\) in document, \((A_t, p, q)\) in pyxdh; summ: \(F_{pq}^{A_t} = F_{qp}^{A_t}\)

\[F_{pq}^{A_t} = C_{\mu p} F_{\mu \nu}^{A_t} C_{\nu q}\]
[67]:
F_1_mo = np.einsum("up, Atuv, vq -> Atpq", C, F_1_ao, C)
F_1_mo.shape
[67]:
(4, 3, 22, 22)
[68]:
np.allclose(F_1_mo, F_1_mo.swapaxes(-1, -2))  # symm check
[68]:
True
[69]:
np.allclose(F_1_mo, gradh.F_1_mo.reshape(natm, 3, nmo, nmo))  # pyxdh approach
[69]:
True

Ax0_Core \(A_{pq, rs}\) (Self-consistent) A Tensor (G Response) Contraction

  • Ax0_Core \(A_{pq, rs}\) tensor contraction: input \(X_{rs}^\mathbb{A}\) \((\mathbb{A}, r, s)\), output \(A_{pq, rs} X_{rs}^\mathbb{A}\) \((\mathbb{A}, p, q)\), where here \(p, q, r, s\) does not necessarily mean all molecular orbitals

  • function initialization sp, sq, sr, ss orbital slice of \(p, q, r, s\)

    • dmX \(X_{\kappa \lambda}^\mathbb{A} = C_{\kappa r} X_{rs}^\mathbb{X} C_{\lambda} + \mathrm{swap} (\kappa, \lambda)\); dim \((\mathbb{A}, \kappa, \lambda)\)

    • rho_X_0 \(\varrho^\mathbb{A} = X_{\kappa \lambda}^\mathbb{A} \phi_\kappa \phi_\lambda\); dim \((\mathbb{A}, g)\)

    • rho_X_1 \(\varrho_r^\mathbb{A} = 2 X_{\kappa \lambda}^\mathbb{A} \phi_{r \kappa} \phi_\lambda\); dim \((\mathbb{A}, r, g)\)

    • gamma_XD \(\varrho_w^\mathbb{A} \rho_w\), index \(w\) summed; dim \((\mathbb{A}, g)\)

    • M_0 \(M^\mathbb{A} = f_{\rho \rho} \varrho^\mathbb{A} + 2 f_{\rho \gamma} (\varrho_w^\mathbb{A} \rho_w)\); dim \((\mathbb{A}, g)\)

    • M_1 \(M_r^\mathbb{A} = 4 f_{\rho \gamma} \varrho^\mathbb{A} \rho_r + 8 f_{\rho \gamma} (\varrho_w^\mathbb{A} \rho_w) \rho_r + 4 f_\gamma \varrho_r^\mathbb{A}\); dim \((\mathbb{A}, r, g)\)

    • ax_ao \(A_{\mu \nu, \kappa \lambda} X_{\kappa \lambda}^\mathbb{A}\); dim \((\mathbb{A}, \mu, \nu)\)

      \[A_{\mu \nu, \kappa \lambda} X_{\kappa \lambda}^\mathbb{A} = (\mu \nu | \kappa \lambda) X_{\kappa \lambda}^\mathbb{A} - \frac{c_\mathrm{x}}{2} (\mu \kappa | \nu \lambda) X_{\kappa \lambda}^\mathbb{A} + M^\mathbb{A} \phi_\mu \phi_\nu + M_r^\mathbb{A} \phi_{r \mu} \phi_\nu + \mathrm{swap} (\mu, \nu)\]
    • ax_mo \(A_{pq, rs} X_{rs}^\mathbb{A}\); dim \((\mathbb{A}, p, q)\)

[70]:
def Ax0_Core(sp, sq, sr, ss):
    def fx(X):
        X_ = X.view().reshape(-1, X.shape[-2], X.shape[-1])
        dmX = C[:, sr] @ X_ @ C[:, ss].T
        dmX += dmX.swapaxes(-1, -2)
        tmp_K = np.einsum("Akl, gl -> Agk", dmX, ao_0)
        rho_X_0 = np.einsum("gk, Agk -> Ag", grdh.ao_0, tmp_K)
        rho_X_1 = 2 * np.einsum("rgk, Agk -> Arg", grdh.ao_1, tmp_K)
        gamma_XD = np.einsum("Arg, rg -> Ag", rho_X_1, rho_1)
        M_0 = (
            np.einsum("g, Ag -> Ag", kerh.frr, rho_X_0)
            + 2 * np.einsum("g, Ag -> Ag", kerh.frg, gamma_XD)
        )
        M_1 = (
            + 4 * np.einsum("g, Ag, rg -> Arg", kerh.frg, rho_X_0, rho_1)
            + 8 * np.einsum("g, Ag, rg -> Arg", kerh.fgg, gamma_XD, rho_1)
            + 4 * np.einsum("g, Arg -> Arg", kerh.fg, rho_X_1)
        )
        ax_ao = (
            + 1 * np.einsum("uvkl, Akl -> Auv", eri0_ao, dmX)
            - 0.5 * cx * np.einsum("ukvl, Akl -> Auv", eri0_ao, dmX)
            + np.einsum("Ag, gu, gv -> Auv", M_0, ao_0, ao_0)
            + np.einsum("Arg, rgu, gv -> Auv", M_1, ao_1, ao_0)
        )
        ax_ao += ax_ao.swapaxes(-1, -2)
        ax_mo = C[:, sp].T @ ax_ao @ C[:, sq]
        X_shape = list(X.shape)
        X_shape[-2:] = ax_mo.shape[-2:]
        return ax_mo.reshape(X_shape)
    return fx
[71]:
X = np.random.randn(4, 5, nmo, nocc)
[72]:
Ax0_Core(sv, so, sa, so)(X).shape
[72]:
(4, 5, 13, 9)
[73]:
np.allclose(Ax0_Core(sv, so, sa, so)(X), gradh.Ax0_Core(sv, so, sa, so)(X))  # pyxdh approach
[73]:
True
[74]:
np.allclose(
    Ax0_Core(sv, so, sa, so)(X),
    hessian.rhf.gen_vind(scf_eng, C, gradh.mo_occ)(X.reshape(-1, nmo, nocc)).reshape(4, 5, nmo, nocc)[:, :, sv, so]
)  # PySCF approach
[74]:
True

B_1 \(B_{pq}^{A_t}\) B Matrix (CP-HF Equation RHS)

  • B_1 \(B_{pq}^{A_t}\); dim: \((A, t, p, q)\) in document, \((A_t, p, q)\) in pyxdh; symm: no

\[B_{pq}^{A_t} = F_{pq}^{A_t} - S_{pq}^{A_t} \varepsilon_q - \frac{1}{2} A_{pq, kl} S_{kl}^{A_t}\]
[75]:
B_1 = (
    + F_1_mo
    - np.einsum("Atpq, q -> Atpq", S_1_mo, e)
    - 0.5 * Ax0_Core(sa, sa, so, so)(S_1_mo[:, :, so, so])
)
B_1.shape
[75]:
(4, 3, 22, 22)
[76]:
np.allclose(B_1, gradh.B_1.reshape(natm, 3, nmo, nmo))  # pyxdh approach
[76]:
True

U_1_vo \(U_{ai}^{A_t}\) U Matrix Virt-Occ Block (Unnecessary for xDH Gradient)

  • U_1_vo \(U_{ai}^{A_t}\); dim: \((A, t, p, q)\) in document, \((A_t, p, q)\) in pyxdh

\[- (\varepsilon_a - \varepsilon_i) U_{ai}^{A_t} - A_{ai, bj} U_{bj}^{A_t} = B_{ai}^{A_t}\]
[77]:
U_1_vo = cphf.solve(
    Ax0_Core(sv, so, sv, so),
    e,
    gradh.mo_occ,
    B_1[:, :, sv, so].reshape(-1, nvir, nocc),
    tol=1e-12,
)[0].reshape(natm, 3, nvir, nocc)
U_1_vo.shape
[77]:
(4, 3, 13, 9)
[78]:
np.allclose(
    B_1[:, :, sv, so] + (ev[:, None] - eo[None, :]) * U_1_vo + Ax0_Core(sv, so, sv, so)(U_1_vo),
    np.zeros((natm, 3, nvir, nocc))
)  # check sanity by CP-HF equation
[78]:
True
[79]:
nd_C = NumericDiff(gradn, lambda gradh: gradh.C).derivative.reshape((natm, 3, nao, nao))
nd_U_1_vo = np.einsum("mu, Atup -> Atmp", np.linalg.inv(C), nd_C)[:, :, sv, so]
plot_diff(U_1_vo, nd_U_1_vo)

PT2 Tensors

  • D_iajb \(D_{ij}^{ab}\); dim: \((i, a, j, b)\); symm: \(D_{ij}^{ab} = D_{ji}^{ab} = D_{ij}^{ba}\) (4-fold)

\[D_{ij}^{ab} = \varepsilon_i - \varepsilon_a + \varepsilon_j - \varepsilon_b\]
[80]:
D_iajb = eo[:, None, None, None] - ev[None, :, None, None] + eo[None, None, :, None] - ev[None, None, None, :]
D_iajb.shape
[80]:
(9, 13, 9, 13)
[81]:
np.allclose(D_iajb, D_iajb.swapaxes(-1, -3))  # symm check
[81]:
True
[82]:
np.allclose(D_iajb, D_iajb.swapaxes(-2, -4))  # symm check
[82]:
True
  • t_iajb \(t_{ij}^{ab}\); dim: \((i, a, j, b)\); symm: \(t_{ij}^{ab} = t_{ji}^{ba}\)

\[t_{ij}^{ab} = (ia|jb) / D_{ij}^{ab}\]
[83]:
t_iajb = eri0_mo[so, sv, so, sv] / D_iajb
t_iajb.shape
[83]:
(9, 13, 9, 13)
[84]:
np.allclose(t_iajb, t_iajb.swapaxes(-1, -3).swapaxes(-2, -4))  # symm check
[84]:
True
  • T_iajb \(T_{ij}^{ab}\); dim: \((i, a, j, b)\); symm: \(T_{ij}^{ab} = T_{ji}^{ba}\)

\[T_{ij}^{ab} = c_\mathrm{c}^\mathrm{n} (2 t_{ij}^{ab} - t_{ij}^{ba})\]
[85]:
T_iajb = cc * (2 * t_iajb - t_iajb.swapaxes(-1, -3))
T_iajb.shape
[85]:
(9, 13, 9, 13)
[86]:
np.allclose(T_iajb, T_iajb.swapaxes(-1, -3).swapaxes(-2, -4))  # symm check
[86]:
True
  • Energy check: \(E_\mathrm{PT2} = T_{ij}^{ab} t_{ij}^{ab} D_{ij}^{ab}\)

[87]:
np.einsum("iajb, iajb, iajb -> ", T_iajb, t_iajb, D_iajb)
[87]:
-0.13594842684204672
[88]:
gradh.eng - gradh.nc_deriv.scf_eng.energy_tot(dm=D)
[88]:
-0.13594842684204878

W_I \(W_{pq}^\mathrm{PT2} [\mathrm{I}]\) Part I of Weighted Density Matrix

  • W_I \(W_{pq}^\mathrm{PT2} [\mathrm{I}]\); dim: \((p, q)\); symm: no

\[\begin{split}\begin{align} W_{ij}^\mathrm{PT2} [\mathrm{I}] &= - 2 T_{ik}^{ab} (ja|kb) \\ W_{ab}^\mathrm{PT2} [\mathrm{I}] &= - 2 T_{ij}^{ac} (ib|jc) \\ W_{ai}^\mathrm{PT2} [\mathrm{I}] &= - 4 T_{jk}^{ab} (ij|bk) \\ W_{ia}^\mathrm{PT2} [\mathrm{I}] &= 0 \end{align}\end{split}\]
[89]:
W_I = np.zeros((nmo, nmo))
W_I = np.zeros((nmo, nmo))
W_I[so, so] = - 2 * np.einsum("iakb, jakb -> ij", T_iajb, eri0_mo[so, sv, so, sv])
W_I[sv, sv] = - 2 * np.einsum("iajc, ibjc -> ab", T_iajb, eri0_mo[so, sv, so, sv])
W_I[sv, so] = - 4 * np.einsum("jakb, ijbk -> ai", T_iajb, eri0_mo[so, so, sv, so])
W_I.shape
[89]:
(22, 22)

D_r \(D_{pq}^\mathrm{PT2+}\) Relaxed Density Matrix: Block-Diagonal Part

  • D_r \(D_{pq}^\mathrm{PT2+}\); dim: \((p, q)\) (pre-definition); actual total symm: no

[90]:
D_r = np.zeros((nmo, nmo))
  • D_r[so, so] \(D_{ij}^\mathrm{PT2+}\); dim: \((i, j)\); symm: \(D_{ij}^\mathrm{PT2+} = D_{ji}^\mathrm{PT2+}\)

\[D_{ij}^\mathrm{PT2+} = - 2 T_{ik}^{ab} t_{jk}^{ab}\]
[91]:
D_r[so, so] = - 2 * np.einsum("iakb, jakb -> ij", T_iajb, t_iajb)
[92]:
np.allclose(D_r[so, so], D_r[so, so].T)  # symm check
[92]:
True
  • D_r[sv, sv] \(D_{ab}^\mathrm{PT2+}\); dim: \((a, b)\); symm: \(D_{ab}^\mathrm{PT2+} = D_{ba}^\mathrm{PT2+}\)

\[D_{ab}^\mathrm{PT2+} = 2 T_{ij}^{ac} t_{ij}^{bc}\]
[93]:
D_r[sv, sv] = 2 * np.einsum("iajc, ibjc -> ab", T_iajb, t_iajb)
[94]:
np.allclose(D_r[sv, sv], D_r[sv, sv].T)  # symm check
[94]:
True

L \(L_{ai}^\mathrm{PT2+}\) PT2+ Total Lagrangian

  • L \(L_{ai}^\mathrm{PT2+}\); dim \((a, i)\)

\[L_{ai}^\mathrm{PT2+} = A_{ai, kl} D_{kl}^\mathrm{PT2} + A_{ai, bc} D_{bc}^\mathrm{PT2} - 4 T_{jk}^{ab} (ij|bk) + 4 T_{ij}^{bc} (ab|jc) + 4 F_{ai}^\mathrm{n}\]

Up to now, D_r only contains occ-occ and vir-vir block, so technically we can just use D_r to generate L. Reason of introducing term \(4 F_{ai}^\mathrm{n}\) has been stated previously in this section.

[95]:
L = (
    + Ax0_Core(sv, so, sa, sa)(D_r)
    - 4 * np.einsum("jakb, ijbk -> ai", T_iajb, eri0_mo[so, so, sv, so])
    + 4 * np.einsum("ibjc, abjc -> ai", T_iajb, eri0_mo[sv, sv, so, sv])
    + 4 * nc_F_0_mo[sv, so]
)
[96]:
np.allclose(L, gradh.L)  # pyxdh approach
[96]:
True

D_r \(D_{pq}^\mathrm{PT2+}\) Relaxed Density Matrix: vir-occ Part

  • D_r[sv, so] \(D_{ai}^\mathrm{PT2+}\); dim: \((a, i)\)

\[- (\varepsilon_a - \varepsilon_i) D_{ai}^\mathrm{PT2+} - A_{ai, bj} D_{bj}^\mathrm{PT2+} = L_{ai}^\mathrm{PT2+}\]
[97]:
D_r[sv, so] = cphf.solve(Ax0_Core(sv, so, sv, so), e, gradh.mo_occ, L, tol=1e-12, max_cycle=100)[0]
[98]:
np.allclose(
    L + (ev[:, None] - eo[None, :]) * D_r[sv, so] + Ax0_Core(sv, so, sv, so)(D_r[sv, so]),
    np.zeros((nvir, nocc))
)  # check sanity by CP-HF equation
[98]:
True
[99]:
np.allclose(D_r, gradh.D_r)  # pyxdh approach
[99]:
True

E_1_nuc \(\partial_{A_t} E_\mathrm{nuc}\) Nucleus Repulsion Energy Derivative

  • nuc_Z \(Z_{AB}\); dim: \((A, B)\); symm: \(Z_{AB} = Z_{BA}\)

\[Z_{AB} = Z_A Z_B\]
[100]:
nuc_Z = np.einsum("A, B -> AB", mol.atom_charges(), mol.atom_charges())
nuc_Z.shape
[100]:
(4, 4)
[101]:
np.allclose(nuc_Z, nuc_Z.swapaxes(0, 1))
[101]:
True
  • nuc_V \(V_{ABt}\); dim: \((A, B, t)\); symm: \(V_{ABt} = - V_{BAt}\)

\[V_{ABt} = A_t - B_t\]
[102]:
nuc_V = lib.direct_sum("Mt - Nt -> MNt", mol.atom_coords(), mol.atom_coords())
nuc_V.shape
[102]:
(4, 4, 3)
[103]:
np.allclose(nuc_V, - nuc_V.swapaxes(0, 1))
[103]:
True
  • nuc_rinv \(r_{AB}^{-1}\); dim: \((A, B)\); symm: \(r_{AB}^{-1} = r_{BA}^{-1}\)

\[\begin{split}r_{AB}^{-1} = \left\{ \begin{matrix} | \boldsymbol{A} - \boldsymbol{B} |^{-1}, & \quad A \neq B \\ 0, & \quad A = B \end{matrix} \right.\end{split}\]
[104]:
nuc_rinv = 1 / (np.linalg.norm(nuc_V, axis=2) + np.diag([np.inf] * natm))
nuc_rinv.shape
[104]:
(4, 4)
  • E_1_nuc \(\partial_{A_t} E_\mathrm{nuc}\); dim: \((A, t)\)

\[\partial_{A_t} E_\mathrm{nuc} = - \sum_M Z_{AM} r_{AM}^{-3} V_{AMt}\]
[105]:
E_1_nuc = - np.einsum("AM, AM, AMt -> At", nuc_Z, nuc_rinv**3, nuc_V)
E_1_nuc
[105]:
array([[  2.24023,   0.86221,   9.19698],
       [  0.38236,   2.46344, -10.29839],
       [ -2.69385,   0.04989,   0.6448 ],
       [  0.07127,  -3.37554,   0.45661]])

E_1 \(\partial E_\mathrm{tot}\) Total XYG3 Energy Derivative

  • E_1 \(\partial E_\mathrm{tot}\); dim: \((A, t)\)

\[\begin{split}\begin{align} \partial_{A_t} E_\mathrm{tot} &= h_{\mu \nu}^{A_t} D_{\mu \nu} + \frac{1}{2} D_{\mu \nu} (\mu \nu | \kappa \lambda)^{A_t} D_{\kappa \lambda} - \frac{c_\mathrm{x}^\mathrm{n}}{4} D_{\mu \nu} (\mu \kappa | \nu \lambda)^{A_t} D_{\kappa \lambda} + f_\rho^\mathrm{n} \rho^{A_t} + f_\gamma^\mathrm{n} \gamma^{A_t} - 2 S_{ij}^{A_t} F_{ij}^\mathrm{n} \\ &\quad + D_{pq}^\mathrm{PT2+} B_{pq}^{A_t} + W_{pq}^\mathrm{PT2} [\mathrm{I}] S_{pq}^{A_t} + 2 T_{ij}^{ab} (ia|jb)^{A_t} + \partial_{A_t} E_\mathrm{nuc} \end{align}\end{split}\]
[106]:
E_1 = (
    # Line 1
    + np.einsum("Atuv, uv -> At", H_1_ao, D)
    + 0.5 * np.einsum("uv, Atuvkl, kl -> At", D, eri1_ao, D)
    - 0.25 * cx_n * np.einsum("uv, Atukvl, kl -> At", D, eri1_ao, D)
    + np.einsum("g, Atg -> At", kerh_n.fr, A_rho_1)
    + np.einsum("g, Atg -> At", kerh_n.fg, A_gamma_1)
    - 2 * np.einsum("Atij, ij -> At", S_1_mo[:, :, so, so], nc_F_0_mo[so, so])
    # Line 2
    + np.einsum("pq, Atpq -> At", D_r, B_1)
    + np.einsum("pq, Atpq -> At", W_I, S_1_mo)
    + 2 * np.einsum("iajb, Atiajb -> At", T_iajb, eri1_mo[:, :, so, sv, so, sv])
    + E_1_nuc
)
E_1
[106]:
array([[-0.03968,  0.06718,  0.14149],
       [ 0.00877,  0.15758, -0.17124],
       [ 0.01226,  0.01305,  0.0318 ],
       [ 0.01864, -0.23781, -0.00205]])
[107]:
np.allclose(E_1, gradh.E_1)  # pyxdh approach
[107]:
True
[108]:
nd_E_0 = NumericDiff(gradn, lambda gradh: gradh.eng).derivative
np.allclose(E_1, nd_E_0.reshape(natm, 3), rtol=2e-4)  # numerical derivative check
[108]:
True

梯度中间量应用:原子的电子态密度测评程序

我们这一节将会回顾梯度中间量:弛豫密度 \(D_{\mu \nu}^\mathrm{PT2+}\) 的应用。这也是作者曾经辅助完成的一项工作:

  • Su

    这篇文档讨论双杂化,特别是 XYG3 型泛函在闭壳层原子体系下,密度与能量均有较好表现;展示了双杂化密度泛函在当前的测评体系下,确实地沿着 Predew 提出的 Jacob 阶梯进展的现状。

    Su, N. Q.; Zhu, Z. & Xu, X.

    Doubly hybrid density functionals that correctly describe both density and energy for atoms

    Proc. Natl. Acad. Sci. U.S.A. 2018, 115, 2287-2292

    doi: 10.1073/pnas.1713047115

这篇文章与下述文章有直接的关系:

  • Medvedev

    这篇文档系统地测评了各种泛函在闭壳层原子体系下的密度与能量,指出一般地泛函沿着 Jacob 阶梯进展,但由于不同的泛函设计理念,导致许多 (特别是近年) 泛函存在比较严重、违背物理的设计问题。

    Medvedev, M. G.; Bushmarinov, I. S.; Sun, J.; Perdew, J. P. & Lyssenko, K. A.

    Density functional theory is straying from the path toward the exact functional

    Science 2017, 355, 49-52

    doi: 10.1126/science.aah5975

在这份程序文档中,我们仅仅打算回顾作者在 Su 文章中所作的工作的部分,即绘制各种泛函 (包括 XYG3 型泛函) 的密度格点图进行比较。我们在这里仅仅讨论技术上的问题。原文使用的是 NWChem 进行计算,我们将会用 PySCF 与 pyxdh 重现这部分计算过程。

准备工作

这一节的测评将会计算 CCSD 的弛豫密度;事实上作者目前还尚未仔细推导过 CCSD 的密度公式,我们就借用 PySCF 中的耦合簇模块 cc 来生成 CCSD 的弛豫密度。对于 MP2 方法或 XYG3 型泛函,我们使用 GradMP2GradXDH生成弛豫密度。

[1]:
%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
from IPython.display import Image
from pyxdh.DerivOnce import GradSCF, GradMP2, GradXDH

np.einsum = partial(np.einsum, optimize=["greedy", 1024 ** 3 * 20 / 8])
np.allclose = partial(np.allclose, atol=1e-6, rtol=1e-4)
np.set_printoptions(5, linewidth=150, suppress=True)
warnings.filterwarnings("ignore")

我们这一节只测评 Ne 原子;其它闭壳层原子也可以用相同的方式测评。受制于 libcint 库对 H 壳层轨道的处理可能存在问题,相较于原文 aug-cc-pωCV5Z,我们去除了其中两个 H 轨道信息。

[2]:
mol = gto.Mole()
mol.atom = """
Ne 0. 0. 0.
"""
with open("./assets/Ne_basis.txt") as f:
    mol.basis = gto.basis.parse(f.read())
mol.verbose = 0
mol.build()
nao = nmo = mol.nao

RHF 电子态密度计算

计算实例与基本变量

我们首先给出 RHF 与 CCSD 的计算实例 mf_rhf;并定义 C_rhf 为 RHF 下的轨道系数 \(C_{\mu p}\),以及 mo_occ 为电子占据数。

[3]:
mf_rhf = scf.RHF(mol).run()
C_rhf, mo_occ = mf_rhf.mo_coeff, mf_rhf.mo_occ

我们以前不太提及变量 mo_occ;但它在现在电子态密度的计算中非常实用。它实际上可以看做是关于 \(p\) 的向量 \(2 \delta_{p \in \mathrm{occ}}\)。我们知道 Ne 原子是 10 电子体系,因此在 RHF 下前 5 根轨道的电子占据数是 2。mo_occ 的维度是 \((p, )\) 维即分子轨道数量维度,但只有占据轨道部分 (前 5 根) 填入 2 个电子占据:

[4]:
mo_occ[:10]
[4]:
array([2., 2., 2., 2., 2., 0., 0., 0., 0., 0.])

一个衍生的概念是分子轨道下的密度矩阵 \(D_{pq} = 2 \delta_{pq} \delta_{p \in \mathrm{occ}}\)。我们以前经常写原子轨道下的密度矩阵为 \(D_{\mu \nu} = C_{\mu i} C_{\nu i}\);但我们也可以写 \(D_{\mu \nu} = C_{\mu p} D_{\mu \nu} C_{\nu q}\)

[5]:
np.allclose(C_rhf @ np.diag(mo_occ) @ C_rhf.T, mf_rhf.make_rdm1())
[5]:
True

mo_occ 变量将在下一小节经常使用到。

径向轨道格点

我们评测电子态密度的依据是径向密度大小。我们选取 50000 个格点,它们可以沿着任意轴向 (我们测评的是非简并闭壳层原子,因此一定具有球对称性,我们不妨选取 \(x\) 轴向),平均地分布在 0 - 10 Angstrom 之间。从 0 到 10 Angstrom 的点我们用 rad_x 记录;但这些点的单位选取 Bohr,因此大约是 0 - 18.90 Bohr:

[6]:
rad_ngrid = 50000
rad_x = np.linspace(0, 10 / lib.param.BOHR, rad_ngrid)
rad_x
[6]:
array([ 0.     ,  0.00038,  0.00076, ..., 18.89651, 18.89688, 18.89726])

将这些距离化为具体的、三维的格点 rad_coord

[7]:
rad_coord = np.array([rad_x, np.zeros(rad_ngrid), np.zeros(rad_ngrid)]).T
rad_coord
[7]:
array([[ 0.     ,  0.     ,  0.     ],
       [ 0.00038,  0.     ,  0.     ],
       [ 0.00076,  0.     ,  0.     ],
       ...,
       [18.89651,  0.     ,  0.     ],
       [18.89688,  0.     ,  0.     ],
       [18.89726,  0.     ,  0.     ]])

我们的目标是获得各个方法下的密度格点,因此我们需要计算轨道导数 rad_ao_0 \(\phi_{\mu}\), rad_ao_1 \(\phi_{t \mu}\), rad_ao_2 \(\phi_{tr \mu}\)。我们可以使用 PySCF 自带的 eval_ao 函数:

[8]:
ni = dft.numint.NumInt()
rad_ao = ni.eval_ao(mol, rad_coord, deriv=2)
rad_ao.shape
[8]:
(10, 50000, 159)
[9]:
rad_ao_0 = rad_ao[0]
rad_ao_1 = 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]],
])

密度格点 (RHO) 绘图

我们先绘制 RHF 的电子态密度格点。首先,我们定义函数 get_rad_rho 函数,它输入原子轨道下的密度矩阵 \(D_{\mu \nu}\),输出密度格点

\[\rho = \phi_\mu \phi_\nu D_{\mu \nu}\]
[10]:
def get_rad_rho(dm):
    return np.einsum("gu, gv, uv -> g", rad_ao_0, rad_ao_0, dm)

代入 RHF 密度,就可以得到格点 rad_rho_rhf \(\rho^\mathrm{HF}\)

[11]:
D_rhf = mf_rhf.make_rdm1()
rad_rho_rhf = get_rad_rho(D_rhf)

\(\rho^\mathrm{HF}\) (或者我们显示地将距离表示出来 \(\rho^\mathrm{HF} (r)\)) 是径向密度,而非径向分布;我们测评的依据是径向分布 dist_rho_rhf \(4 \pi r^2 \rho^\mathrm{HF} (r)\)

但在此之前,我们需要对单位作分析。我们上述计算过程中使用的都是 Bohr 单位;但绘图与测评过程中,则需要转化为 Angstrom 单位。注意到 rad_rho_rhf \(\rho^\mathrm{HF} (r)\) 的单位是 \(\mathsf{Bohr}^{-3}\),因为

\[\int 4 \pi r^2 \rho^\mathrm{HF} (r) \, \mathrm{d} r = n_\mathrm{elec}\]
[12]:
(4 * np.pi * rad_x**2 * rad_rho_rhf).sum() * (rad_x[1] - rad_x[0])
[12]:
10.000000000000112

因此,\(4 \pi r^2 \rho^\mathrm{HF} (r)\) 的量纲为 \([\mathrm{L}]^{-1}\),因此转化为 Angstrom 单位就通过除以 Bohr 换算数就可以得到:

[13]:
dist_rho_rhf = 4 * np.pi * rad_rho_rhf * rad_x**2 / lib.param.BOHR

这样,我们就可以绘制 CCSD 的密度格点图像了:

[14]:
fig, ax = plt.subplots(figsize=(6, 4.5))
ax.plot(rad_x * lib.param.BOHR, dist_rho_rhf, label="HF")
ax.set_xlim([0, 4]); ax.set_ylim([0, 25])
ax.set_title("HF curve for Ne RHO")
ax.set_xlabel("$r$ / Angstrom")
ax.set_ylabel("$g(\mathrm{RHO}_\mathrm{HF})$ / e Angstrom$^{-1}$")
ax.legend(); fig.tight_layout()

这也与 Medvedev 文章 Supporting Materials Fig S1 右上方图片近乎一致 (但我们使用的是 RHF 而非 CCSD 方法)。

密度梯度 (GRD) 绘图

现在我们对 RHF 的密度梯度绘图。密度梯度定义为

\[\rho_t = 2 \phi_{t \mu} \phi_\nu D_{\mu \nu}\]

但用于测评的格点是通过下述方式导出的:

\[|\nabla \rho| = \sqrt{\sum_{t \in \{ x, y, z \}} \rho_t^2}\]
[15]:
def get_rad_grd(dm):
    return np.linalg.norm(2 * np.einsum("tgu, gv, uv -> tg", rad_ao_1, rad_ao_0, dm), axis=0)
[16]:
rad_grd_rhf = get_rad_grd(D_rhf)

类似于前文,我们使用 \(4 \pi r^2 |\nabla \rho|\) 来作格点梯度测评:

单位换算存疑

可以看到下面的代码仅仅除以了一次 Bohr 与 Angstrom 单位的换算数;但 \(4 \pi r^2 |\nabla \rho|\) 的量纲为 \([\mathrm{L}]^{-2}\),并且我们在作单位换算之前统一都使用了原子单位,即长度为 Bohr,因此这里应该是要作两次单位换算。但如果只作一次单位换算,可以与 Medvedev 的 Science 作正确的核对。后面的 Laplacian 量计算也同样如此。

[17]:
dist_grd_rhf = 4 * np.pi * get_rad_grd(D_rhf) * rad_x**2 / lib.param.BOHR

格点梯度的绘制如下:

[18]:
fig, ax = plt.subplots(figsize=(6, 4.5))
ax.plot(rad_x * lib.param.BOHR, dist_grd_rhf, label="HF")
ax.set_xlim([0, 2]); ax.set_ylim([0, 400])
ax.set_title("HF curve for Ne GRD")
ax.set_xlabel("$r$ / Angstrom")
ax.set_ylabel("$g(\mathrm{GRD}_\mathrm{HF})$ / e Angstrom$^{-2}$")
ax.legend(); fig.tight_layout()

Laplacian 量 (LR) 绘制

密度的二阶导数量如下:

\[\rho_{tr} = (2 \phi_{tr \mu} \phi_{\nu} + 2 \phi_{t \mu} \phi_{r \nu}) D_{\mu \nu}\]

但用于测评的格点是通过下述方式给出的:

\[|\nabla^2 \rho| = \rho_{xx} + \rho_{yy} + \rho_{zz}\]
[19]:
def get_rad_lr(dm):
    lr_grid = (
        + 2 * np.einsum("trgu, gv, uv -> trg", rad_ao_2, rad_ao_0, dm)
        + 2 * np.einsum("tgu, rgv, uv -> trg", rad_ao_1, rad_ao_1, dm))
    return lr_grid.trace(axis1=0, axis2=1)
[20]:
rad_lr_rhf = get_rad_lr(D_rhf)
[21]:
dist_lr_rhf = 4 * np.pi * rad_lr_rhf * rad_x**2 / lib.param.BOHR

格点的绘制如下:

[22]:
fig, ax = plt.subplots(figsize=(6, 4.5))
ax.plot(rad_x * lib.param.BOHR, dist_lr_rhf, label="HF")
ax.plot([0, 1], [0, 0], color="black", linewidth=0.5)
ax.set_xlim([0, 1]); ax.set_ylim([-8000, 4000])
ax.set_title("HF curve for Ne LR")
ax.set_xlabel("$r$ / Angstrom")
ax.set_ylabel("$g(\mathrm{LR}_\mathrm{HF})$ / e Angstrom$^{-3}$")
ax.legend(); fig.tight_layout()

泛函测评过程

作为标准的 CCSD 结果

关于 CCSD 是如何计算的,这里我们不作详细展开。我们分别将 CCSD 的密度 (RHO)、梯度 (GRD)、Laplacian (LR) 储存于 dist_rho_ccsd, dist_grd_ccsd, dist_lr_ccsd 中。CCSD 的这些格点将会作为参照格点,用以评判其它格点的优劣。

[23]:
with open("assets/RHO-Ne.txt", "r") as f:
    dist_rho_ccsd = np.array([float(line.split()[2]) for line in f.readlines()]) / lib.param.BOHR
with open("assets/GRD-Ne.txt", "r") as f:
    dist_grd_ccsd = np.array([float(line.split()[2]) for line in f.readlines()]) / lib.param.BOHR
with open("assets/LR-Ne.txt", "r") as f:
    dist_lr_ccsd = np.array([float(line.split()[2]) for line in f.readlines()]) / lib.param.BOHR

各方法密度矩阵的导出

我们先定义下述函数,它将生成 \((99, 590)\) 的格点。我们知道,原始工作中选用的是在 Gaussian 中的 UltraFine 格点,\((99, 590)\) 相对来说比较接近 UltraFine 格点。

[24]:
def mol_to_grids(mol, atom_grid=(99, 590)):
    grids = dft.Grids(mol)
    grids.atom_grid = atom_grid
    grids.becke_scheme = dft.gen_grid.stratmann
    grids.prune = None
    grids.build()
    return grids
grids = mol_to_grids(mol)

下述函数是通过输入分子、泛函名称来进行计算,从而输出密度矩阵:

[25]:
def mol_to_scf(mol, xc):
    scf_eng = dft.RKS(mol)
    scf_eng.grids = mol_to_grids(mol)
    scf_eng.xc = xc
    scf_eng.conv_tol = 1e-10
    return scf_eng.run()

对于自洽场方法 (PBE, B3LYP) 而言,我们就很容易获得其密度矩阵:

[26]:
D_pbe0 = mol_to_scf(mol, "PBE0").make_rdm1()
D_b3lyp = mol_to_scf(mol, "B3LYPg").make_rdm1()

而对于 MP2 而言,我们需要 PySCF 之外的工具了,这里我们用 GradMP2 来构成实例。当然用 DipoleMP2 也可以,因为我们的目的是得到弛豫密度,因为弛豫密度不受被求导量的变化而受到影响。

MP2 弛豫密度目前无法直接通过 PySCF 获得

对于 mp.MP2 来给出 MP2 的计算实例,并通过 make_rdm1 给出的密度,该密度相当于 \(D_{ij}^\mathrm{MP2}\)\(D_{ab}^\mathrm{MP2}\);但它并不包括 \(D_{ai}^\mathrm{MP2}\)\(D_{ia}^\mathrm{MP2}\)。这与 PySCF 给出的 CCSD 密度也不一样。因此,我们还是使用 pyxdh 来计算 MP2 的弛豫密度。

由于内存消耗庞大,我们需要尽量一次性地求出密度矩阵。对于包含 PT2 贡献的泛函 (或 MP2),我们需要通过下述方式给出密度矩阵:

\[D_{\mu \nu}^\mathrm{PT2+} = C_{\mu p} \big( 2 \delta_{pq} \delta_{p \in \mathrm{occ}} + \frac{1}{2} (D_{pq}^\mathrm{PT2+} + D_{pq}^\mathrm{PT2+}) \big) C_{\nu q}\]

这是因为密度矩阵应当要满足对称性,但 \(D_{pq}^\mathrm{PT2+}\) 未必满足这种对称性。

[27]:
def get_D_resp(mf):
    return mf.C @ (np.diag(mo_occ) + 0.5 * (mf.D_r + mf.D_r.T)) @ mf.C.T
[28]:
D_mp2 = get_D_resp(GradMP2({"scf_eng": mf_rhf, "cphf_tol": 1e-8}))

对于 B2PLYP 来讲,我们额外定义一下 PT2 相关能系数即可:

[29]:
D_b2plyp = get_D_resp(GradMP2({"scf_eng": mol_to_scf(mol, "0.53*HF + 0.47*B88, 0.73*LYP"), "cphf_tol": 1e-8, "cc": 0.27}))

但对于 XYG3 型泛函而言,我们还需要定义非自洽部分,对于 XYGJ-OS 与 xDH-PBE0 而言,还需要定义平行自旋 PT2 贡献系数 \(c_\mathrm{SS}\) 为零。

[30]:
def mol_to_nc(mol, xc):
    nc_eng = dft.RKS(mol)
    nc_eng.grids = mol_to_grids(mol)
    nc_eng.xc = xc
    return nc_eng
[31]:
D_xyg3 = get_D_resp(GradXDH({
    "scf_eng": mol_to_scf(mol, "B3LYPg"),
    "nc_eng": mol_to_nc(mol, "0.8033*HF - 0.0140*LDA + 0.2107*B88, 0.6789*LYP"),
    "cc": 0.3211,
    "cphf_tol": 1e-10}))
[32]:
D_xygjos = get_D_resp(GradXDH({
    "scf_eng": mol_to_scf(mol, "B3LYPg"),
    "nc_eng": mol_to_nc(mol, "0.7731*HF + 0.2269*LDA, 0.2309*VWN3 + 0.2754*LYP"),
    "cc": 0.4364, "ss": 0.,
    "cphf_tol": 1e-10}))

电子态密度 (RHO) 绘图

我们先绘制一下 Medvedev 的 Supporting Materials 的 Fig. S1 的右下图片。由于 PySCF 所使用的 libxc 库暂时无法调用 M11 泛函,我们就只对 MP2, PBE0, B3LYP, HF 作绘图。

[33]:
dict1 = {
    "MP2": (D_mp2, "#0000ff"),
    "PBE0": (D_pbe0, "#008000"),
    "B3LYP": (D_b3lyp, "#ff0000"),
    "HF": (D_rhf, "#00ff00"),
}
dict2 = {
    "MP2": (D_mp2, "#508fc5"),
    "B2PLYP": (D_b2plyp, "#e26b12"),
    "XYG3": (D_xyg3, "#a9a6a5"),
    "XYGJ-OS": (D_xygjos, "#f9b500"),
}
[34]:
fig, ax = plt.subplots(figsize=(6, 4.5))
ax.plot([0, 10], [0, 0], c="black", linewidth=0.5)
for func, item in dict1.items():
    ax.plot(rad_x * lib.param.BOHR, 4 * np.pi * rad_x**2 * get_rad_rho(item[0]) / lib.param.BOHR - dist_rho_ccsd, c=item[1], label=func)
ax.set_xlim([0, 4]); ax.set_ylim([-0.1, 0.15])
ax.set_title("Errors for Ne RHO")
ax.set_xlabel("$r$ / Angstrom")
ax.set_ylabel("$g(\mathrm{RHO}_\mathrm{METHOD}) - g(\mathrm{RHO}_\mathrm{CCSD})$ / e Angstrom$^{-1}$")
ax.legend(); fig.tight_layout()

下面我们依样画葫芦,绘制包含 PT2 的泛函误差图。这些图片可以参考 Su 的 Supporting Information Fig. S1。但由于原文中的 xDH-PBE0 使用了 LibXC 所不包含的泛函,因此没有绘制该泛函的格点。

[35]:
fig, ax = plt.subplots(figsize=(6, 4.5))
ax.plot([0, 10], [0, 0], c="black", linewidth=0.5)
for func, item in dict2.items():
    ax.plot(rad_x * lib.param.BOHR, 4 * np.pi * rad_x**2 * get_rad_rho(item[0]) / lib.param.BOHR - dist_rho_ccsd, c=item[1], label=func)
ax.set_xlim([0, 3]); ax.set_ylim([-0.07, 0.07])
ax.set_title("Errors for Ne RHO")
ax.set_xlabel("$r$ / Angstrom")
ax.set_ylabel("$g(\mathrm{RHO}_\mathrm{METHOD}) - g(\mathrm{RHO}_\mathrm{CCSD})$ / e Angstrom$^{-1}$")
ax.legend(); fig.tight_layout()

密度梯度 (GRD) 绘图

[36]:
fig, ax = plt.subplots(figsize=(6, 4.5))
ax.plot([0, 10], [0, 0], c="black", linewidth=0.5)
for func, item in dict1.items():
    ax.plot(rad_x * lib.param.BOHR, 4 * np.pi * rad_x**2 * get_rad_grd(item[0]) / lib.param.BOHR - dist_grd_ccsd, c=item[1], label=func)
ax.set_xlim([0, 2]); ax.set_ylim([-1.5, 1])
ax.set_title("Errors for Ne GRD")
ax.set_xlabel("$r$ / Angstrom")
ax.set_ylabel("$g(\mathrm{GRD}_\mathrm{METHOD}) - g(\mathrm{GRD}_\mathrm{CCSD})$ / e Angstrom$^{-2}$")
ax.legend(); fig.tight_layout()
[37]:
fig, ax = plt.subplots(figsize=(6, 4.5))
ax.plot([0, 10], [0, 0], c="black", linewidth=0.5)
for func, item in dict2.items():
    ax.plot(rad_x * lib.param.BOHR, 4 * np.pi * rad_x**2 * get_rad_grd(item[0]) / lib.param.BOHR - dist_grd_ccsd, c=item[1], label=func)
ax.set_xlim([0, 2]); ax.set_ylim([-1, 0.6])
ax.set_title("Errors for Ne GRD")
ax.set_xlabel("$r$ / Angstrom")
ax.set_ylabel("$g(\mathrm{GRD}_\mathrm{METHOD}) - g(\mathrm{GRD}_\mathrm{CCSD})$ / e Angstrom$^{-2}$")
ax.legend(); fig.tight_layout()

密度 Laplacian 量 (LR) 绘图

[38]:
fig, ax = plt.subplots(figsize=(6, 4.5))
ax.plot([0, 10], [0, 0], c="black", linewidth=0.5)
for func, item in dict1.items():
    ax.plot(rad_x * lib.param.BOHR, 4 * np.pi * rad_x**2 * get_rad_lr(item[0]) / lib.param.BOHR - dist_lr_ccsd, c=item[1], label=func)
ax.set_xlim([0, 1]); ax.set_ylim([-30, 40])
ax.set_title("Errors for Ne LR")
ax.set_xlabel("$r$ / Angstrom")
ax.set_ylabel("$g(\mathrm{LR}_\mathrm{METHOD}) - g(\mathrm{LR}_\mathrm{CCSD})$ / e Angstrom$^{-3}$")
ax.legend(); fig.tight_layout()
[39]:
fig, ax = plt.subplots(figsize=(6, 4.5))
ax.plot([0, 10], [0, 0], c="black", linewidth=0.5)
for func, item in dict2.items():
    ax.plot(rad_x * lib.param.BOHR, 4 * np.pi * rad_x**2 * get_rad_lr(item[0]) / lib.param.BOHR - dist_lr_ccsd, c=item[1], label=func)
ax.set_xlim([0, 1]); ax.set_ylim([-20, 20])
ax.set_title("Errors for Ne LR")
ax.set_xlabel("$r$ / Angstrom")
ax.set_ylabel("$g(\mathrm{LR}_\mathrm{METHOD}) - g(\mathrm{LR}_\mathrm{CCSD})$ / e Angstrom$^{-3}$")
ax.legend(); fig.tight_layout()

XYG3 型泛函电场梯度 CheatSheet

我们曾经在最初的时候,提及 RHF 方法下的电场梯度 (从而得到偶极矩);但由于电场梯度会产生众多零值 Skeleton 导数,因此不适合用于对更为广泛的梯度性质进行讨论。于是,我们后来的文档都使用核坐标梯度来说明问题。现在我们再回到电场梯度;我们尝试求取 XYG3 型泛函的偶极矩。

准备工作

[1]:
%matplotlib notebook

from pyscf import gto, scf, dft, lib
from pyscf.scf import cphf
import numpy as np
from functools import partial
import warnings
from matplotlib import pyplot as plt
from pyxdh.Utilities import DipoleDerivGenerator, NumericDiff, GridHelper, KernelHelper
from pyxdh.DerivOnce import DipoleSCF, DipoleMP2, DipoleXDH

np.einsum = partial(np.einsum, optimize=["greedy", 1024 ** 3 * 2 / 8])
np.allclose = partial(np.allclose, atol=1e-6, rtol=1e-4)
np.set_printoptions(5, linewidth=150, suppress=True)
warnings.filterwarnings("ignore")
[2]:
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()
[2]:
<pyscf.gto.mole.Mole at 0x7fdd44b7f160>
[3]:
def mol_to_grids(mol, atom_grid=(75, 302)):
    grids = dft.Grids(mol)
    grids.atom_grid = atom_grid
    grids.becke_scheme = dft.gen_grid.stratmann
    grids.prune = None
    grids.build()
    return grids
grids = mol_to_grids(mol)
[4]:
def mol_to_scf(mol):
    scf_eng = dft.RKS(mol)
    scf_eng.grids = mol_to_grids(mol)
    scf_eng.xc = "B3LYPg"
    scf_eng.conv_tol = 1e-10
    return scf_eng.run()
[5]:
def mol_to_nc(mol):
    nc_eng = dft.RKS(mol)
    nc_eng.grids = mol_to_grids(mol)
    nc_eng.xc = "0.8033*HF - 0.0140*LDA + 0.2107*B88, 0.6789*LYP"
    nc_eng.conv_tol = 1e-10
    return nc_eng
[6]:
scf_eng = mol_to_scf(mol)
nc_eng = mol_to_nc(mol)
diph = DipoleXDH({"scf_eng": scf_eng, "nc_eng": nc_eng, "cc": 0.3211, "cphf_tol": 1e-12})
[7]:
nmo, nao, natm, nocc, nvir, cx, cc, cx_n = diph.nao, diph.nao, diph.natm, diph.nocc, diph.nvir, diph.cx, diph.cc, diph.nc_deriv.cx
mol_slice = diph.mol_slice
so, sv, sa = diph.so, diph.sv, diph.sa
C, Co, Cv, e, eo, ev, D = diph.C, diph.Co, diph.Cv, diph.e, diph.eo, diph.ev, diph.D
H_0_ao, S_0_ao, eri0_ao, F_0_ao = diph.H_0_ao, diph.S_0_ao, diph.eri0_ao, diph.F_0_ao
H_0_mo, S_0_mo, eri0_mo, F_0_mo = diph.H_0_mo, diph.S_0_mo, diph.eri0_mo, diph.F_0_mo
T_iajb, t_iajb, D_iajb = diph.T_iajb, diph.t_iajb, diph.D_iajb
[8]:
grdh = GridHelper(mol, grids, D)
ao_0, ao_1, ao_2 = grdh.ao_0, grdh.ao_1, grdh.ao_2
rho_0, rho_1, rho_2 = grdh.rho_0, grdh.rho_1, grdh.rho_2
[9]:
kerh = KernelHelper(grdh, "B3LYPg")
kerh_n = KernelHelper(grdh, "0.8033*HF - 0.0140*LDA + 0.2107*B88, 0.6789*LYP")
[10]:
def dipole_generator(component, interval):
    def get_hcore(mol=mol):
        return scf.rhf.get_hcore(mol) - interval * mol.intor("int1e_r")[component]
    mf_scf = mol_to_scf(mol)
    mf_nc = mol_to_nc(mol)
    mf_scf.get_hcore = get_hcore
    mf_nc.get_hcore = get_hcore
    mf_scf.run()
    return DipoleXDH({"scf_eng": mf_scf, "nc_eng": mf_nc, "cc": 0.3211, "cphf_tol": 1e-12})

dipn = DipoleDerivGenerator(dipole_generator)
[11]:
def plot_diff(anal_mat, num_mat):
    fig, ax = plt.subplots(figsize=(2.4, 1.8)); ax.set_xscale("log")
    ax.hist(abs(anal_mat.ravel() - num_mat.ravel()), bins=np.logspace(np.log10(1e-10), np.log10(1e-1), 50), alpha=0.5)
    ax.hist(abs(num_mat.ravel()), bins=np.logspace(np.log10(1e-10), np.log10(1e-1), 50), alpha=0.5)
    return fig.tight_layout()

XYG3 型电场梯度:程序实现 CheatSheet

XYG3 type of double hybrid functional derivative:

\[\begin{split}\begin{align} \partial_\mathbb{A} E_\mathrm{tot} &= h_{\mu \nu}^\mathbb{A} D_{\mu \nu} + \frac{1}{2} D_{\mu \nu} (\mu \nu | \kappa \lambda)^\mathbb{A} D_{\kappa \lambda} - \frac{c_\mathrm{x}^\mathrm{n}}{4} D_{\mu \nu} (\mu \kappa | \nu \lambda)^\mathbb{A} D_{\kappa \lambda} + f_\rho^\mathrm{n} \rho^\mathbb{A} + f_\gamma^\mathrm{n} \gamma^\mathbb{A} - 2 S_{ij}^\mathbb{A} F_{ij}^\mathrm{n} \\ &\quad + D_{pq}^\mathrm{PT2+} B_{pq}^\mathbb{A} + W_{pq}^\mathrm{PT2} [\mathrm{I}] S_{pq}^\mathbb{A} + 2 T_{ij}^{ab} (ia|jb)^\mathbb{A} + \partial_\mathbb{A} E_\mathrm{nuc} \end{align}\end{split}\]

Derivative Unrelated Tensors

For all derivative-unrelated tensors, we refer to the realization from previous xDH nucleus coordinate derivative cheatsheet.

  • nc_F_0_ao \(F_{\mu \nu}^\mathrm{n}\)

  • Ax0_Core \(A_{pq, rs}\)

  • PT2 Tensors

  • W_I \(W_{pq}^\mathrm{PT2} [\mathrm{I}]\)

  • D_r \(D_{pq}^\mathrm{PT2+}\)

  • L \(L_{ai}^\mathrm{PT2+}\) PT2+

[12]:
nc_F_0_ao, nc_F_0_mo = diph.nc_deriv.F_0_ao, diph.nc_deriv.F_0_mo
Ax0_Core = diph.Ax0_Core
T_iajb, t_iajb, D_iajb = diph.T_iajb, diph.t_iajb, diph.D_iajb
W_I, D_r, L = diph.W_I, diph.D_r, diph.L

Zero Derivative Tensors

  • S_1_ao \(S_{\mu \nu}^t\)

  • eri1_ao \((\mu \nu | \kappa \lambda)^t\)

  • \(\rho^t\), \(\rho_r^t\), \(\gamma^t\)

H_1_ao \(h_{\mu \nu}^t\) Hamiltonian Core Skeleton

  • H_1_ao \(h_{\mu \nu}^{t}\); dim: \((t, \mu, \nu)\); symm: \(h_{\mu \nu}^{t} = h_{\nu \mu}^{t}\)

\[h_{\mu \nu}^{t} = - \langle \mu | t | \nu \rangle\]

Note that \(t\) in \(\langle \mu | t | \nu \rangle\) means electron coordinate component, not kinetic operator \(\hat t\).

[13]:
H_1_ao = - mol.intor("int1e_r")
H_1_ao.shape
[13]:
(3, 22, 22)
[14]:
np.allclose(H_1_ao, H_1_ao.swapaxes(-1, -2))  # symm check
[14]:
True
[15]:
np.allclose(H_1_ao, diph.H_1_ao)  # pyxdh approach
[15]:
True
[16]:
nd_H_0_ao = NumericDiff(dipn, lambda diph: diph.H_0_ao).derivative
plot_diff(H_1_ao, nd_H_0_ao)
  • H_1_mo \(h_{pq}^{t}\); dim: \((t, p, q)\); symm: \(h_{pq}^{t} = h_{qp}^{t}\)

\[h_{pq}^{t} = C_{\mu p} h_{\mu \nu}^{t} C_{\nu q}\]
[17]:
H_1_mo = np.einsum("up, tuv, vq -> tpq", C, H_1_ao, C)
H_1_mo.shape
[17]:
(3, 22, 22)
[18]:
np.allclose(H_1_mo, diph.H_1_mo)  # pyxdh approach
[18]:
True

F_1_ao \(F_{\mu \nu}^{t}\) (Self-consistent) Fock Skeleton

  • F_1_ao \(F_{\mu \nu}^{t} = h_{\mu \nu}^{t}\)

  • F_1_mo \(F_{pq}^{t} = h_{pq}^{t}\)

[19]:
F_1_ao, F_1_mo = H_1_ao, H_1_mo
[20]:
np.allclose(F_1_ao, diph.F_1_ao)  # pyxdh approach
[20]:
True
[21]:
np.allclose(F_1_mo, diph.F_1_mo)  # pyxdh approach
[21]:
True

B_1 \(B_{pq}^{t}\) B Matrix (CP-HF Equation RHS)

  • B_1 \(B_{pq}^{t} = F_{pq}^t\)

[22]:
B_1 = F_1_mo
[23]:
np.allclose(B_1, diph.B_1)  # pyxdh approach
[23]:
True

U_1_vo \(U_{ai}^{t}\) U Matrix Virt-Occ Block (Unnecessary for xDH Gradient)

  • U_1_vo \(U_{ai}^{t}\); dim: \((t, p, q)\)

\[- (\varepsilon_a - \varepsilon_i) U_{ai}^{t} - A_{ai, bj} U_{bj}^{t} = B_{ai}^{t}\]
[24]:
U_1_vo = cphf.solve(
    Ax0_Core(sv, so, sv, so),
    e,
    diph.mo_occ,
    B_1[:, sv, so],
    tol=1e-12,
)[0]
U_1_vo.shape
[24]:
(3, 13, 9)
[25]:
np.allclose(
    B_1[:, sv, so] + (ev[:, None] - eo[None, :]) * U_1_vo + Ax0_Core(sv, so, sv, so)(U_1_vo),
    np.zeros((3, nvir, nocc))
)  # check sanity by CP-HF equation
[25]:
True
[26]:
nd_C = NumericDiff(dipn, lambda diph: diph.C).derivative
nd_U_1_vo = np.einsum("mu, tup -> tmp", np.linalg.inv(C), nd_C)[:, sv, so]
plot_diff(U_1_vo, nd_U_1_vo)

E_1_nuc \(\partial_{F_t} E_\mathrm{nuc}\) Nucleus Repulsion Energy Derivative

  • E_1_nuc \(\partial_{F_t} E_\mathrm{nuc}\); dim: \((t, )\)

\[\partial_{F_t} E_\mathrm{nuc} = Z_A A_t\]
[27]:
E_1_nuc = np.einsum("A, At -> t", mol.atom_charges(), mol.atom_coords())
E_1_nuc
[27]:
array([ 1.88973,  1.32281, 24.56644])

E_1_elec \(\partial E_\mathrm{elec}\) XYG3 Electronic Energy Derivative

  • E_1_elec \(\partial_{F_t} E_\mathrm{elec}\); dim: \((t, )\)

\[\begin{align} \partial_{F_t} E_\mathrm{elec} &= h_{\mu \nu}^{t} D_{\mu \nu} + D_{pq}^\mathrm{PT2+} B_{pq}^{t} \end{align}\]
[28]:
E_1_elec = (
    + np.einsum("tuv, uv -> t", H_1_ao, D)
    + np.einsum("pq, tpq -> t", D_r, B_1)
)
E_1_elec
[28]:
array([ -1.0425 ,  -0.70621, -24.90992])

We can use numerical derivative to verify \(\partial_{A_t} E_\mathrm{elec}\); however, numerical derivative of total energy \(E_\mathrm{tot}\) only returns \(\partial_{A_t} E_\mathrm{elec}\) but not \(\partial_{A_t} E_\mathrm{tot}\). Nucleus contribution is simply left-out.

[29]:
nd_E_0 = NumericDiff(dipn, lambda diph: diph.eng).derivative
nd_E_0
[29]:
array([ -1.0425 ,  -0.70621, -24.90992])
[30]:
np.allclose(E_1_elec, nd_E_0)
[30]:
True

Although \(h_{pq}^{t} = B_{pq}^{t}\), it does not mean \(\partial_{F_s} h_{pq}^{t} = \partial_{F_s} B_{pq}^{t}\). So, we do not write \(\partial_{F_t} E_\mathrm{elec}\) as \(h_{pq}^{t} (D_{pq} + D_{pq}^\mathrm{PT2+})\).

E_1 \(\partial E_\mathrm{tot}\) XYG3 Total Energy Derivative

  • E_1 \(\partial_{F_t} E_\mathrm{tot}\); dim: \((t, )\)

\[\partial_{F_t} E_\mathrm{tot} = \partial_{F_t} E_\mathrm{elec} + \partial_{F_t} E_\mathrm{nuc}\]
[31]:
E_1 = E_1_elec + E_1_nuc
E_1
[31]:
array([ 0.84722,  0.6166 , -0.34348])

二阶梯度与性质:序

这一部分中,我们会讨论从自洽场到 XYG3 型泛函二阶梯度的性质的实现,包括核坐标二阶梯度 (Hessian)、极化率 (Polarizability)、偶极矩的核坐标导数 (Dipole Derivative)。

这份文档目前处于未完成的状态,今后也很可能不会再补完。

RHF Hessian

二阶梯度问题导言

我们在上一章,已经对一阶梯度的问题有比较深入的了解。pyxdh 的目标是解决 XYG3 型泛函的二阶梯度的计算;因此,我们不能止步于此。这一章,我们最后会讨论到 XYG3 的二阶梯度计算,完成这份文档的目标。

上一章我们主要使用的是核坐标 \(\mathbb{A} = A_t\) 来讨论梯度计算;在二阶梯度中,我们仍然使用核坐标讨论。因此,我们会引入第二个原子核坐标分量 \(B_s\) 来作为被求导量。但从程序的实现角度上来说,如果我们有 Skeleton 导数 \((\mu \nu | \kappa \lambda)^{A_t B_s}\),那么它的维度将是 \((A, t, B, s, \mu, \nu, \kappa, \lambda)\),即八维张量。这多少有些冗长;而且我们还需要考虑到我们还会求取核坐标与电场的混合梯度,因此我们希望对被求导量的维度作重新规划。

在以后的文段中,我们会沿用 pyxdh 的程序习惯,将 \((\mu \nu | \kappa \lambda)^{A_t B_s}\) 的维度定义为 \((A, t, B, s, \mu, \nu, \kappa, \lambda)\)。其余类推。

我们希望通过几篇文档,能较好地描述二阶梯度问题;但使用核坐标二维梯度,就意味着被求导量是相同的,在理解导数的过程中可能会忽视了被求导量对称性的问题,因此最好的方式应当是用使用混合导数来引导我们的二阶梯度计算。但正因为电场有许多 Skeleton 导数恰好为零,因此混合核坐标与电场梯度中也会存在很多零项,影响讨论。因此,我们最终选择用核坐标二阶梯度来引导我们的讨论。

我们由于在一阶梯度时,已经讨论了许多推导的方式;因此在二阶梯度文档中,公式的推导速度略过许多细节,也不会用很多习题来引导思考。

准备工作

我们会使用 HessSCF 类作为求取 RHF (或 GGA) Hessian 的计算实例。它的初始化需要 GradSCF 的实例。

[1]:
%matplotlib notebook

from pyscf import gto, scf, dft, lib, grad, hessian
import numpy as np
from functools import partial
import warnings
from matplotlib import pyplot as plt
from pyxdh.Utilities import NucCoordDerivGenerator, DipoleDerivGenerator, NumericDiff
from pyxdh.DerivOnce import GradSCF
from pyxdh.DerivTwice import HessSCF

np.einsum = partial(np.einsum, optimize=["greedy", 1024 ** 3 * 2 / 8])
np.allclose = partial(np.allclose, atol=1e-6, rtol=1e-4)
np.set_printoptions(5, linewidth=150, suppress=True)
warnings.filterwarnings("ignore")
[2]:
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()
[2]:
<pyscf.gto.mole.Mole at 0x7f065417fa00>
[3]:
gradh = GradSCF({"scf_eng": scf.RHF(mol), "cphf_tol": 1e-12})
hessh = HessSCF({"deriv_A": gradh, "deriv_B": gradh})
[4]:
nmo, nao, natm, nocc, nvir = gradh.nao, gradh.nao, gradh.natm, gradh.nocc, gradh.nvir
so, sv, sa = gradh.so, gradh.sv, gradh.sa
mol_slice = gradh.mol_slice
C, Co, Cv, e, eo, ev, D = gradh.C, gradh.Co, gradh.Cv, gradh.e, gradh.eo, gradh.ev, gradh.D
H_0_ao, S_0_ao, eri0_ao, F_0_ao = gradh.H_0_ao, gradh.S_0_ao, gradh.eri0_ao, gradh.F_0_ao
H_0_mo, S_0_mo, eri0_mo, F_0_mo = gradh.H_0_mo, gradh.S_0_mo, gradh.eri0_mo, gradh.F_0_mo
H_1_ao, S_1_ao, eri1_ao, F_1_ao = gradh.H_1_ao, gradh.S_1_ao, gradh.eri1_ao, gradh.F_1_ao
H_1_mo, S_1_mo, eri1_mo, F_1_mo = gradh.H_1_mo, gradh.S_1_mo, gradh.eri1_mo, gradh.F_1_mo
Ax0_Core, B_1, U_1, U_1_vo = gradh.Ax0_Core, gradh.B_1, gradh.U_1, gradh.U_1_vo
[5]:
def grad_generator(mol):
    scf_eng = scf.RHF(mol)
    config = {"scf_eng": scf_eng, "cphf_tol": 1e-12}
    return GradSCF(config)

gradn = NucCoordDerivGenerator(mol, grad_generator)
[6]:
def plot_diff(anal_mat, num_mat):
    fig, ax = plt.subplots(figsize=(2.4, 1.8)); ax.set_xscale("log")
    ax.hist(abs(anal_mat.ravel() - num_mat.ravel()), bins=np.logspace(np.log10(1e-10), np.log10(1e-1), 50), alpha=0.5)
    ax.hist(abs(num_mat.ravel()), bins=np.logspace(np.log10(1e-10), np.log10(1e-1), 50), alpha=0.5)
    return fig.tight_layout()

我们不妨看一下通过 hessh 计算得到的 Hessian \(\partial_{A_t} \partial_{B_s} E_\mathrm{tot}\) 是长什么样子的:

[7]:
hessh.E_2
[7]:
array([[ 0.36765, -0.01096, -0.02986, -0.02036,  0.0064 ,  0.03848, -0.4029 ,  0.00214, -0.02579,  0.0556 ,  0.00242,  0.01717],
       [-0.01096,  0.02901,  0.11159,  0.0047 ,  0.08453, -0.11579,  0.00851, -0.03718,  0.0048 , -0.00226, -0.07637, -0.0006 ],
       [-0.02986,  0.11159,  0.47024, -0.00243,  0.00961, -0.33099,  0.02687,  0.0038 , -0.03383,  0.00542, -0.125  , -0.10542],
       [-0.02036,  0.0047 , -0.00243, -0.07793, -0.00283, -0.04145, -0.00102, -0.0056 ,  0.04462,  0.09931,  0.00372, -0.00074],
       [ 0.0064 ,  0.08453,  0.00961, -0.00283,  0.66306, -0.43734,  0.00034,  0.00409, -0.00816, -0.00392, -0.75168,  0.43588],
       [ 0.03848, -0.11579, -0.33099, -0.04145, -0.43734,  0.426  , -0.00318,  0.00431, -0.04919,  0.00616,  0.54882, -0.04582],
       [-0.4029 ,  0.00851,  0.02687, -0.00102,  0.00034, -0.00318,  0.41067, -0.01219, -0.02918, -0.00675,  0.00333,  0.00549],
       [ 0.00214, -0.03718,  0.0038 , -0.0056 ,  0.00409,  0.00431, -0.01219,  0.02907,  0.00724,  0.01565,  0.00402, -0.01535],
       [-0.02579,  0.0048 , -0.03383,  0.04462, -0.00816, -0.04919, -0.02918,  0.00724,  0.0954 ,  0.01035, -0.00389, -0.01238],
       [ 0.0556 , -0.00226,  0.00542,  0.09931, -0.00392,  0.00616, -0.00675,  0.01565,  0.01035, -0.14815, -0.00947, -0.02193],
       [ 0.00242, -0.07637, -0.125  ,  0.00372, -0.75168,  0.54882,  0.00333,  0.00402, -0.00389, -0.00947,  0.82402, -0.41993],
       [ 0.01717, -0.0006 , -0.10542, -0.00074,  0.43588, -0.04582,  0.00549, -0.01535, -0.01238, -0.02193, -0.41993,  0.16362]])

它可以通过下述的数值导数方法获得;其中,E_1\((A, t)\) 维度的分子力 \(\partial_{A_t} E_\mathrm{tot}\),我们对其压平后进行数值导数,能得到与上述代码近乎一致的输出:

[8]:
nd_E_1 = NumericDiff(gradn, lambda gradh: gradh.E_1.ravel()).derivative
nd_E_1.shape
[8]:
(12, 12)
[9]:
np.allclose(hessh.E_2, nd_E_1, atol=5e-6)
[9]:
True

RHF Hessian 概述

我们首先回顾 RHF 的能量表达式:

\[E_\mathrm{tot} = h_{\mu \nu} D_{\mu \nu} + \frac{1}{2} D_{\mu \nu} (\mu \nu | \kappa \lambda) D_{\kappa \lambda} - \frac{1}{4} D_{\mu \nu} (\mu \kappa | \nu \lambda) D_{\kappa \lambda} + E_\mathrm{nuc}\]

其一阶梯度表达式是

\[\begin{align} \frac{\partial E_\mathrm{tot}}{\partial B_s} &= h_{\mu \nu}^{B_s} D_{\mu \nu} + \frac{1}{2} D_{\mu \nu} (\mu \nu | \kappa \lambda)^{B_s} D_{\kappa \lambda} - \frac{1}{4} D_{\mu \nu} (\mu \kappa | \nu \lambda)^{B_s} D_{\kappa \lambda} - 2 F_{ij} S_{ij}^{B_s} + \partial_{B_s} E_\mathrm{nuc} \end{align}\]

我们定义原子核互斥能的二阶梯度为 \(\partial_{A_t} \partial_{B_s} E_\mathrm{nuc}\)。那么,其余的电子能量部分则有

\[\begin{split}\begin{align} \frac{\partial^2 E_\mathrm{elec}}{\partial B_s \partial A_t} &= \partial_{A_t} h_{\mu \nu}^{B_s} \cdot D_{\mu \nu} + 4 h_{mi}^{B_s} U_{mi}^{A_t} \\ &\quad + \frac{1}{2} \partial_{A_t} (\mu \nu | \kappa \lambda)^{B_s} \cdot D_{\mu \nu} D_{\kappa \lambda} + 4 (\mu \nu | \kappa \lambda)^{B_s} D_{\mu \nu} U_{mi}^{A_t} C_{\kappa m} C_{\lambda i} \\ &\quad - \frac{1}{4} \partial_{A_t} (\mu \kappa | \nu \lambda)^{B_s} \cdot D_{\mu \nu} D_{\kappa \lambda} - 2 (\mu \kappa | \nu \lambda)^{B_s} D_{\mu \nu} U_{mi}^{A_t} C_{\kappa m} C_{\lambda i} \\ &\quad - 2 F_{ij} C_{\mu i} C_{\nu j} \cdot \partial_{A_t} S_{\mu \nu}^{B_s} - 4 F_{ij} S_{mj}^{B_s} U_{mi}^{A_t} \\ &\quad - 2 F_{ij}^{A_t} S_{ij}^{B_s} - 2 A_{ij, mk} U_{mk}^{A_t} S_{ij}^{B_s} - 4 F_{mj} U_{mi}^{A_t} S_{ij}^{B_s} \end{align}\end{split}\]

上式是根据导数的定义直接给出的,我们甚至现在就可以验证上述导数的计算过程;譬如我们可以用下面的代码验证

\[\frac{\partial}{\partial A_t} (- 2 F_{ij} S_{ij}^{B_s}) = - 2 F_{ij} C_{\mu i} C_{\nu j} \cdot \partial_{A_t} S_{\mu \nu}^{B_s} - 4 F_{ij} S_{mj}^{B_s} \mathscr{U}_{mi}^{A_t} - 2 F_{ij}^{A_t} S_{ij}^{B_s} - 2 A_{ij, mk} \mathscr{U}_{mk}^{A_t} S_{ij}^{B_s} - 4 F_{mj} \mathscr{U}_{mi}^{A_t} S_{ij}^{B_s}\]

之所以上式中可以直接使用 \(\mathscr{U}_{mi}^{A_t}\) 替代 \(U_{mi}^{A_t}\),是因为利用了对于任何对称的 \(X_{ij}\),有 \(\sum_{ij} X_{ij}^{A_t} U_{ij}^\mathbb{A} = \sum_{ij} X_{ij}^{A_t} U_{ji}^\mathbb{A}\) 的结论。若对 \(\mathscr{U}_{mi}^{A_t}\)\(U_{mi}^{A_t}\) 之间的区别,也许需要回顾一下 RHF U 矩阵 文档的一些段落。我们这里暂时用到了下面文段才会介绍的 \(\partial_{A_t} S_{\mu \nu}^{B_s} = S_{\mu \nu}^{A_t B_s}\) S_2_ao

[10]:
tmp_num = NumericDiff(gradn, lambda gradh: - 2 * np.einsum("ij, Bij -> B", gradh.F_0_mo[so, so], gradh.S_1_mo[:, so, so])).derivative
tmp_anal = (
    - 2 * np.einsum("ij, ui, vj, ABuv -> AB", F_0_mo[so, so], Co, Co, hessh.S_2_ao)
    - 4 * np.einsum("ij, Bmj, Ami -> AB", F_0_mo[so, so], S_1_mo[:, :, so], U_1[:, :, so])
    - 2 * np.einsum("Aij, Bij -> AB", F_1_mo[:, so, so], S_1_mo[:, so, so])
    - 2 * np.einsum("Aij, Bij -> AB", Ax0_Core(so, so, sa, so)(U_1[:, :, so]), S_1_mo[:, so, so])
    - 4 * np.einsum("mj, Ami, Bij -> AB", F_0_mo[:, so], U_1[:, :, so], S_1_mo[:, so, so])
)
plot_diff(tmp_num, tmp_anal)

但显然,能量导数的表达式还可以简化。首先,我们定义下述二阶 Skeleton 导数:

  • H_2_ao \(h_{\mu \nu}^{A_t B_s} = \partial_{A_t} h_{\mu \nu}^{B_s}\)

  • S_2_ao \(S_{\mu \nu}^{A_t B_s} = \partial_{A_t} S_{\mu \nu}^{B_s}\)

  • eri2_ao \((\mu \nu | \kappa \lambda)^{A_t B_s} = \partial_{A_t} (\mu \nu | \kappa \lambda)^{B_s}\)

经过推导,我们可以消除所有与 \(U_{ij}^{A_t}\) 有关的项,得到

\[\begin{split}\begin{align} \frac{\partial^2 E_\mathrm{tot}}{\partial B_s \partial A_t} &= h_{\mu \nu}^{A_t B_s} D_{\mu \nu} + \frac{1}{2} (\mu \nu | \kappa \lambda)^{A_t B_s} D_{\mu \nu} D_{\kappa \lambda} - \frac{1}{4} (\mu \kappa | \nu \lambda)^{A_t B_s} D_{\mu \nu} D_{\kappa \lambda} - 2 S_{ij}^{A_t B_s} F_{ij} \\ &\quad - 2 F_{ij}^{B_s} S_{ij}^{A_t} - 2 F_{ij}^{A_t} S_{ij}^{B_s} \\ &\quad + 2 (\varepsilon_i + \varepsilon_j) S_{ij}^{A_t} S_{ij}^{B_s} + 4 B_{ai}^{B_s} U_{ai}^{A_t} + S_{ij}^{A_t} A_{ij, kl} S_{kl}^{B_s} \\ &\quad + \partial_{A_t} \partial_{B_s} E_\mathrm{nuc} \end{align}\end{split}\]

我们可以用下述的程序来验证;但下述代码中的 H_2_ao \(h_{\mu \nu}^{A_t B_s}\), eri2_ao \((\mu \nu | \kappa \lambda)^{A_t B_s}\), S_2_mo \(S_{pq}^{A_t B_s}\), hess_nuc \(\partial_{A_t} \partial_{B_s} E_\mathrm{nuc}\) 我们需要在下文进行额外的说明。

[11]:
np.allclose(
    + np.einsum("ABuv, uv -> AB", hessh.H_2_ao, D)
    + 0.5 * np.einsum("ABuvkl, uv, kl -> AB", hessh.eri2_ao, D, D)
    - 0.25 * np.einsum("ABukvl, uv, kl -> AB", hessh.eri2_ao, D, D)
    - 2 * np.einsum("ABij, ij -> AB", hessh.S_2_mo[:, :, so, so], F_0_mo[so, so])
    - 2 * np.einsum("Bij, Aij -> AB", F_1_mo[:, so, so], S_1_mo[:, so, so])
    - 2 * np.einsum("Aij, Bij -> AB", F_1_mo[:, so, so], S_1_mo[:, so, so])
    + 2 * np.einsum("ij, Aij, Bij -> AB", eo[:, None] + eo[None, :], S_1_mo[:, so, so], S_1_mo[:, so, so])
    + 4 * np.einsum("Bai, Aai -> AB", B_1[:, sv, so], U_1_vo)
    + np.einsum("Bij, Aij -> AB", Ax0_Core(so, so, so, so)(S_1_mo[:, so, so]), S_1_mo[:, so, so])
    + hessian.RHF(gradh.scf_eng).hess_nuc().swapaxes(1, 2).reshape((12, 12)),
    hessh.E_2
)
[11]:
True

二阶 Skeleton 导数

H_2_ao \(h_{\mu \nu}^{A_t B_s}\) Hamiltonian Core 二阶导数

我们知道 Hamiltonian Core 分为两部分:动能与原子核势能部分。动能部分的一阶导数为

\[\begin{align} h_{\mu \nu}^{B_s} &\leftarrow \frac{\partial}{\partial B_s} \langle \mu | \hat t | \nu \rangle = - \langle \partial_s \mu_B | \hat t | \nu \rangle + \mathrm{swap} (\mu, \nu) \end{align}\]

由于 \(\partial_{A_t} \hat t = 0\),因此推导导数的过程会相当方便。利用链式法则,得到

\[\frac{\partial^2}{\partial A_t \partial B_s} \langle \mu | \hat t | \nu \rangle = \langle \partial_t \partial_s \mu_{AB} | \hat t | \nu \rangle + \langle \partial_t \mu_A | \hat t | \partial_s \nu_B \rangle + \mathrm{swap} (\mu, \nu)\]

在 PySCF 中,下述积分是可以生成的:

  • int1e_ipipkin \(\langle \partial_t \partial_s \mu | \hat t | \nu \rangle\)

  • int1e_ipipkin \(\langle \partial_t \mu | \hat t | \partial_s \nu \rangle\)

[12]:
int1e_ipipkin = mol.intor("int1e_ipipkin").reshape(3, 3, nao, nao)
int1e_ipipkin.shape
[12]:
(3, 3, 22, 22)
[13]:
int1e_ipkinip = mol.intor("int1e_ipkinip").reshape(3, 3, nao, nao)
int1e_ipkinip.shape
[13]:
(3, 3, 22, 22)

我们的做法是,先生成一个 \((A, t, B, s, \mu, \nu)\) 维度的张量,随后将其转换为 \((A_t, B_s, \mu, \nu)\) 维度。

[14]:
H_2_ao_contrib1 = np.zeros((natm, 3, natm, 3, nao, nao))
for A in range(natm):
    sA = mol_slice(A)
    H_2_ao_contrib1[A, :, A, :, sA, :] += int1e_ipipkin[:, :, sA, :]
    for B in range(natm):
        sB = mol_slice(B)
        H_2_ao_contrib1[A, :, B, :, sA, sB] += int1e_ipkinip[:, :, sA, sB]
H_2_ao_contrib1 += H_2_ao_contrib1.swapaxes(-1, -2)
H_2_ao_contrib1.shape = (natm * 3, natm * 3, nao, nao)
[15]:
def get_H_1_ao_contrib1(gradh):
    mol = gradh.mol
    H_1_ao_contrib1 = np.zeros((natm, 3, nao, nao))
    for A in range(natm):
        sA = mol_slice(A)
        H_1_ao_contrib1[A, :, sA, :] -= mol.intor("int1e_ipkin")[:, sA, :]
    H_1_ao_contrib1 += H_1_ao_contrib1.swapaxes(-1, -2)
    return H_1_ao_contrib1.reshape(natm * 3, nao, nao)

nd_H_1_ao_contrib1 = NumericDiff(gradn, get_H_1_ao_contrib1).derivative
[16]:
plot_diff(H_2_ao_contrib1, nd_H_1_ao_contrib1)

第二部分则是原子核的势能的导数。我们回顾到

\[h_{\mu \nu}^{B_s} \leftarrow - \langle \mu | \hat v_\mathrm{nuc} | \partial_s \nu_B \rangle - \langle \mu | \frac{Z_B}{| \boldsymbol{r} - \boldsymbol{B} |} | \partial_s \nu \rangle + \mathrm{swap} (\mu, \nu)\]

我们可以利用链式法则,并且利用到分部积分的结论,我们能推知

\[\begin{split}\begin{align} \frac{\partial^2}{\partial A_t \partial B_s} \langle \mu | \hat v_\mathrm{nuc} | \nu \rangle &= \langle \partial_t \partial_s \mu_{AB} | \hat v_\mathrm{nuc} | \nu \rangle + \langle \partial_t \mu_A | \hat v_\mathrm{nuc} | \partial_s \nu_B \rangle + \langle \partial_t \partial_s \mu_A | \frac{Z_B}{| \boldsymbol{r} - \boldsymbol{B} |} | \nu \rangle + \langle \partial_t \mu_A | \frac{Z_B}{| \boldsymbol{r} - \boldsymbol{B} |} | \partial_s \nu \rangle \\ &\quad + \langle \mu | \frac{Z_A}{| \boldsymbol{r} - \boldsymbol{A} |} | \partial_t \partial_s \nu_B \rangle + \langle \partial_t \mu | \frac{Z_A}{| \boldsymbol{r} - \boldsymbol{A} |} | \partial_s \nu_B \rangle - \langle \partial_t \partial_s \mu | \frac{Z_A}{| \boldsymbol{r} - \boldsymbol{A} |} | \nu \rangle \delta_{AB} - \langle \partial_s \mu | \frac{Z_A}{| \boldsymbol{r} - \boldsymbol{A} |} | \partial_t \nu \rangle \delta_{AB} \end{align}\end{split}\]

上式的第一行是对左右矢直接求导得到,第二行是对算符求导后分部积分得到。

[17]:
int1e_ipipnuc = mol.intor("int1e_ipipnuc").reshape(3, 3, nao, nao)
int1e_ipnucip = mol.intor("int1e_ipnucip").reshape(3, 3, nao, nao)
[18]:
H_2_ao_contrib2 = np.zeros((natm, 3, natm, 3, nao, nao))
for A in range(natm):
    sA = mol_slice(A)
    H_2_ao_contrib2[A, :, A, :, sA, :] += int1e_ipipnuc[:, :, sA, :]  # Term 1
    with mol.with_rinv_as_nucleus(A):
        H_2_ao_contrib2[A, :, A] += - mol.atom_charge(A) * mol.intor("int1e_ipiprinv").reshape(3, 3, nao, nao)  # Term 7
        H_2_ao_contrib2[A, :, A] += - mol.atom_charge(A) * mol.intor("int1e_iprinvip").reshape(3, 3, nao, nao)  # Term 8
    for B in range(natm):
        sB = mol_slice(B)
        H_2_ao_contrib2[A, :, B, :, sA, sB] += int1e_ipnucip[:, :, sA, sB]  # Term 2
        with mol.with_rinv_as_nucleus(B):
            H_2_ao_contrib2[A, :, B, :, sA, :] += mol.atom_charge(B) * mol.intor("int1e_ipiprinv").reshape(3, 3, nao, nao)[:, :, sA, :]  # Term 3
            H_2_ao_contrib2[A, :, B, :, sA, :] += mol.atom_charge(B) * mol.intor("int1e_iprinvip").reshape(3, 3, nao, nao)[:, :, sA, :]  # Term 4
        with mol.with_rinv_as_nucleus(A):
            H_2_ao_contrib2[A, :, B, :, :, sB] += mol.atom_charge(A) * mol.intor("int1e_ipiprinv").reshape(3, 3, nao, nao).swapaxes(-1, -2)[:, :, :, sB]  # Term 5
            H_2_ao_contrib2[A, :, B, :, :, sB] += mol.atom_charge(A) * mol.intor("int1e_iprinvip").reshape(3, 3, nao, nao)[:, :, :, sB]  # Term 6
H_2_ao_contrib2 += H_2_ao_contrib2.swapaxes(-1, -2)
H_2_ao_contrib2.shape = (natm * 3, natm * 3, nao, nao)
[19]:
def get_H_1_ao_contrib2(gradh):
    mol = gradh.mol
    H_1_ao_contrib2 = np.zeros((natm, 3, nao, nao))
    for A in range(natm):
        sA = mol_slice(A)
        H_1_ao_contrib2[A, :, sA, :] -= mol.intor("int1e_ipnuc")[:, sA, :]
        with mol.with_rinv_as_nucleus(A):
            H_1_ao_contrib2[A] -= mol.atom_charge(A) * mol.intor("int1e_iprinv")
    H_1_ao_contrib2 += H_1_ao_contrib2.swapaxes(-1, -2)
    return H_1_ao_contrib2.reshape(natm * 3, nao, nao)

nd_H_1_ao_contrib2 = NumericDiff(gradn, get_H_1_ao_contrib2).derivative
[20]:
plot_diff(H_2_ao_contrib2, nd_H_1_ao_contrib2)

将动能与势能贡献相加和,就得到总的 Hamiltonian Core 的总二阶梯度 H_2_ao \(h_{\mu \nu}^{A_t B_s}\) 了:

[21]:
H_2_ao = H_2_ao_contrib1 + H_2_ao_contrib2
nd_H_1_ao = NumericDiff(gradn, lambda gradh: gradh.H_1_ao).derivative
plot_diff(H_2_ao, nd_H_1_ao)

在 pyxdh 中,它对应的是 H_2_ao property:

[22]:
np.allclose(H_2_ao, hessh.H_2_ao)
[22]:
True

在 PySCF 中,亦有 hessian.rhf.hcore_generator 可以生成 \(h_{\mu \nu}^{A_t B_s}\)

[23]:
np.allclose(
    H_2_ao,
    np.array([[hessian.RHF(gradh.scf_eng).hcore_generator()(A, B) for B in range(natm)] for A in range(natm)]).swapaxes(1, 2).reshape(natm * 3, natm * 3, nao, nao)
)
[23]:
True

S_2_ao \(S_{\mu \nu}^{A_t B_s}\) 重叠积分二阶导数

我们回顾到

\[S_{\mu \nu}^{A_t} = - \langle \partial_t \mu_A | \partial_s \nu \rangle + \mathrm{swap} (\mu, \nu)\]

那么,通过链式法则可以轻松地推知

\[S_{\mu \nu}^{A_t B_s} = \frac{\partial}{\partial B_s} S_{\mu \nu}^{A_t} = \langle \partial_t \mu_A | \partial_s \nu_B \rangle + \langle \partial_t \partial_s \mu_{AB} | \nu \rangle + \mathrm{swap} (\mu, \nu)\]

在此之前,我们定义

  • int1e_ipovlpip \(\langle \partial_t \mu | \partial_s \nu \rangle\)

  • int1e_ipovlpip \(\langle \partial_t \partial_s \mu | \nu \rangle\)

[24]:
int1e_ipovlpip = mol.intor("int1e_ipovlpip").reshape(3, 3, nao, nao)
int1e_ipovlpip.shape
[24]:
(3, 3, 22, 22)
[25]:
int1e_ipipovlp = mol.intor("int1e_ipipovlp").reshape(3, 3, nao, nao)
int1e_ipipovlp.shape
[25]:
(3, 3, 22, 22)
[26]:
S_2_ao = np.zeros((natm, 3, natm, 3, nao, nao))
for A in range(natm):
    sA = mol_slice(A)
    S_2_ao[A, :, A, :, sA, :] += int1e_ipipovlp[:, :, sA, :]
    for B in range(natm):
        sB = mol_slice(B)
        S_2_ao[A, :, B, :, sA, sB] += int1e_ipovlpip[:, :, sA, sB]
S_2_ao += S_2_ao.swapaxes(-1, -2)
S_2_ao.shape = (natm * 3, natm * 3, nao, nao)

我们可以用数值导数验证其正确性:

[27]:
nd_S_1_ao = NumericDiff(gradn, lambda gradh: gradh.S_1_ao).derivative
plot_diff(S_2_ao, nd_S_1_ao)

在 pyxdh 中,\(S_{\mu \nu}^{A_t B_s}\) 对应了 S_2_ao property:

[28]:
np.allclose(S_2_ao, hessh.S_2_ao)
[28]:
True

eri2_ao \((\mu \nu | \kappa \lambda)^{A_t B_s}\) ERI 积分二阶导数

我们回顾到

\[(\mu \nu | \kappa \lambda)^{A_t} = - (\partial_t \mu_A \nu | \kappa \lambda) + \mathrm{swap} (\mu, \nu) + \mathrm{swap} (\mu \nu, \kappa \lambda)\]

我们知道 \(\partial_{B_s} (1/r) = 0\),即双电子积分的算符是不受原子核坐标变化而干扰的,那么很容易地可以通过链式法则,获得

\[(\mu \nu | \kappa \lambda)^{A_t} = \frac{1}{2} (\partial_t \partial_s \mu_{AB} \nu | \kappa \lambda) + \frac{1}{2} (\partial_t \mu_A \partial_s \nu_B | \kappa \lambda) + (\partial_t \mu_A \nu | \partial_s \kappa_B \lambda) + \mathrm{swap} (\mu, \nu) + \mathrm{swap} (\kappa, \lambda) + \mathrm{swap} (\mu \nu, \kappa \lambda)\]

上式借用了下述结论:

\[(\partial_t \mu_A \nu | \partial_s \kappa_B \lambda) + (\partial_t \mu_A \nu | \kappa \partial_s \lambda_B) = (\partial_t \mu_A \nu | \partial_s \kappa_B \lambda) + \mathrm{swap} (\kappa, \lambda)\]

我们可以额外定义下述变量:

  • int2e_ipip1 \((\partial_t \partial_s \mu \nu | \kappa \lambda)\)

  • int2e_ipvip1 \((\partial_t \mu \partial_s \nu | \kappa \lambda)\)

  • int2e_ip1ip2 \((\partial_t \mu \nu | \partial_s \kappa \lambda)\)

[29]:
int2e_ipip1 = mol.intor("int2e_ipip1").reshape(3, 3, nao, nao, nao, nao)
int2e_ipvip1 = mol.intor("int2e_ipvip1").reshape(3, 3, nao, nao, nao, nao)
int2e_ip1ip2 = mol.intor("int2e_ip1ip2").reshape(3, 3, nao, nao, nao, nao)
[30]:
eri2_ao = np.zeros((natm, 3, natm, 3, nao, nao, nao, nao))
for A in range(natm):
    sA = mol_slice(A)
    eri2_ao[A, :, A, :, sA, :, :, :] += 0.5 * int2e_ipip1[:, :, sA, :, :, :]
    for B in range(natm):
        sB = mol_slice(B)
        eri2_ao[A, :, B, :, sA, sB, :, :] += 0.5 * int2e_ipvip1[:, :, sA, sB, :, :]
        eri2_ao[A, :, B, :, sA, :, sB, :] += int2e_ip1ip2[:, :, sA, :, sB, :]
eri2_ao += eri2_ao.swapaxes(-3, -4)
eri2_ao += eri2_ao.swapaxes(-1, -2)
eri2_ao += eri2_ao.swapaxes(-1, -3).swapaxes(-2, -4)
eri2_ao.shape = (natm * 3, natm * 3, nao, nao, nao, nao)

我们可以用数值导数验证其正确性:

[31]:
nd_eri1_ao = NumericDiff(gradn, lambda gradh: gradh.eri1_ao).derivative
plot_diff(eri2_ao, nd_eri1_ao)

E_2_nuc \(\partial_{A_t} \partial_{B_s} E_\mathrm{nuc}\) 核互斥能二阶导数

我们回顾到

\[\begin{split}\begin{align} \partial_{A_t} E_\mathrm{nuc} &= - Z_{AM} r_{AM}^{-3} V_{AMt} \\ Z_{MN} &= Z_M Z_N \\ V_{MNt} &= M_t - N_t \\ r_{MN}^{-1} &= \left\{ \begin{matrix} | \boldsymbol{M} - \boldsymbol{N} |^{-1}, & \quad M \neq N \\ 0, & \quad M = N \end{matrix} \right. \end{align}\end{split}\]

我们在推导核互斥能一阶梯度时,上述的三个变量都是在已经导出了梯度表达式之后,处于简化的目的而定义的变量。我们下面补充两个性质:

\[\begin{split}\begin{align} \partial_{A_t} V_{MNr} &= \delta_{AM} \delta_{tr} - \delta_{AN} \delta_{tr} \\ \partial_{A_t} r_{MN}^{-1} &= - r_{MN}^{-3} V_{MNt} (\delta_{MA} - \delta_{NA}) \end{align}\end{split}\]

我们补充定义变量:

  • nuc_Z \(Z_{MN}\)

  • nuc_V \(V_{MNt}\)

  • nuc_rinv \(r_{MN}^{-1}\)

[32]:
nuc_Z = np.einsum("A, B -> AB", mol.atom_charges(), mol.atom_charges())
nuc_V = lib.direct_sum("Mt - Nt -> MNt", mol.atom_coords(), mol.atom_coords())
nuc_rinv = 1 / (np.linalg.norm(nuc_V, axis=2) + np.diag([np.inf] * natm))
  • delta_AB \(\delta_{AB}\)

  • delta_ts \(\delta_{ts}\)

[33]:
delta_AB = np.eye(natm)
delta_ts = np.eye(3)

我们应当能推知

\[\partial_{A_t} \partial_{B_s} E_\mathrm{nuc} = - Z_{AM} r_{AM}^{-3} \delta_{AB} \delta_{ts} + Z_{AB} r_{AB}^{-3} \delta_{ts} + 3 Z_{AM} r_{AM}^{-5} V_{AMt} V_{AMs} \delta_{AB} - 3 Z_{AB} r_{AB}^{-5} V_{ABt} V_{ABs}\]
[34]:
E_2_nuc = (
    - np.einsum("AM, AM, AB, ts -> AtBs", nuc_Z, nuc_rinv**3, delta_AB, delta_ts)
    + np.einsum("AB, AB, ts -> AtBs", nuc_Z, nuc_rinv**3, delta_ts)
    + 3 * np.einsum("AM, AM, AMt, AMs, AB -> AtBs", nuc_Z, nuc_rinv**5, nuc_V, nuc_V, delta_AB)
    - 3 * np.einsum("AB, AB, ABt, ABs -> AtBs", nuc_Z, nuc_rinv**5, nuc_V, nuc_V)
)
E_2_nuc.shape = (natm * 3, natm * 3)

我们可以与数值导数进行验证:

[35]:
nd_E_1_nuc = NumericDiff(gradn, lambda gradh: gradh.scf_grad.grad_nuc()).derivative
plot_diff(E_2_nuc, nd_E_1_nuc)

在 PySCF 中可以导出解析二阶梯度,我们用下述代码验证之:

[36]:
np.allclose(
    E_2_nuc,
    hessian.RHF(gradh.scf_eng).hess_nuc().swapaxes(1, 2).reshape((12, 12))
)
[36]:
True

RHF 核坐标二阶 U 矩阵

这一节我们会讨论二阶 U 矩阵的计算。我们以前定义了一阶 U 矩阵 (Yamaguchi, p398, eq G.1)

\[\frac{\partial}{\partial \mathbb{A}} C_{\mu p} = C_{\mu m} U_{mp}^\mathbb{A}\]

而二阶 U 矩阵则定义为 (Yamaguchi, p398, eq G.2)

\[\frac{\partial^2}{\partial \mathbb{A} \partial \mathbb{B}} C_{\mu p} = C_{\mu m} U_{mp}^\mathbb{AB}\]

尽管二阶 U 矩阵的定义直截了当,但我们以后其实是不使用二阶 U 矩阵的。尽管如此,讨论二阶 U 矩阵的过程仍然是有意义的。

准备工作

[1]:
%matplotlib notebook

from pyscf import gto, scf, dft, lib, grad, hessian
from pyscf.scf import cphf
import numpy as np
from functools import partial
import warnings
from matplotlib import pyplot as plt
from pyxdh.Utilities import NucCoordDerivGenerator, DipoleDerivGenerator, NumericDiff
from pyxdh.DerivOnce import GradSCF
from pyxdh.DerivTwice import HessSCF

np.einsum = partial(np.einsum, optimize=["greedy", 1024 ** 3 * 2 / 8])
np.allclose = partial(np.allclose, atol=1e-6, rtol=1e-4)
np.set_printoptions(5, linewidth=150, suppress=True)
warnings.filterwarnings("ignore")
[2]:
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()
[2]:
<pyscf.gto.mole.Mole at 0x7f94800ef1c0>
[3]:
gradh = GradSCF({"scf_eng": scf.RHF(mol), "cphf_tol": 1e-12})
hessh = HessSCF({"deriv_A": gradh, "deriv_B": gradh})
[4]:
nmo, nao, natm, nocc, nvir = gradh.nao, gradh.nao, gradh.natm, gradh.nocc, gradh.nvir
so, sv, sa = gradh.so, gradh.sv, gradh.sa
mol_slice = gradh.mol_slice
C, Co, Cv, e, eo, ev, D = gradh.C, gradh.Co, gradh.Cv, gradh.e, gradh.eo, gradh.ev, gradh.D
H_0_ao, S_0_ao, eri0_ao, F_0_ao = gradh.H_0_ao, gradh.S_0_ao, gradh.eri0_ao, gradh.F_0_ao
H_0_mo, S_0_mo, eri0_mo, F_0_mo = gradh.H_0_mo, gradh.S_0_mo, gradh.eri0_mo, gradh.F_0_mo
H_1_ao, S_1_ao, eri1_ao, F_1_ao = gradh.H_1_ao, gradh.S_1_ao, gradh.eri1_ao, gradh.F_1_ao
H_1_mo, S_1_mo, eri1_mo, F_1_mo = gradh.H_1_mo, gradh.S_1_mo, gradh.eri1_mo, gradh.F_1_mo
Ax0_Core, B_1, U_1, U_1_vo = gradh.Ax0_Core, gradh.B_1, gradh.U_1, gradh.U_1_vo
H_2_ao, S_2_ao, eri2_ao = hessh.H_2_ao, hessh.S_2_ao, hessh.eri2_ao
H_2_mo, S_2_mo, eri2_mo = hessh.H_2_mo, hessh.S_2_mo, hessh.eri2_mo
[5]:
def grad_generator(mol):
    scf_eng = scf.RHF(mol)
    config = {"scf_eng": scf_eng, "cphf_tol": 1e-12}
    return GradSCF(config)

gradn = NucCoordDerivGenerator(mol, grad_generator)
[6]:
def plot_diff(anal_mat, num_mat):
    fig, ax = plt.subplots(figsize=(2.4, 1.8)); ax.set_xscale("log")
    ax.hist(abs(anal_mat.ravel() - num_mat.ravel()), bins=np.logspace(np.log10(1e-10), np.log10(1e-1), 50), alpha=0.5)
    ax.hist(abs(num_mat.ravel()), bins=np.logspace(np.log10(1e-10), np.log10(1e-1), 50), alpha=0.5)
    return fig.tight_layout()

这一节中,我们所提及的 U 矩阵通常是未经“轨道旋转”的 (而非经过旋转的 \(\mathscr{U}\)),它用 U_1_nr 表示。同时,使用了未经旋转的 \(U_{pq}^\mathbb{A}\) 的计算实例 gradh_nr, hessh_nr

[7]:
gradh_nr = GradSCF({"scf_eng": scf.RHF(mol), "cphf_tol": 1e-12, "rotation": False})
hessh_nr = HessSCF({"deriv_A": gradh_nr, "deriv_B": gradh_nr, "rotation": False})
U_1_nr = gradh_nr.U_1
[8]:
def grad_nr_generator(mol):
    scf_eng = scf.RHF(mol)
    config = {"scf_eng": scf_eng, "cphf_tol": 1e-12, "rotation": False}
    return GradSCF(config)

gradn_nr = NucCoordDerivGenerator(mol, grad_nr_generator)

但二阶 U 矩阵在以后我们就简单地用 U_2 表示了。在 pyxdh 中,并不存在专门用于计算二阶 U 矩阵的函数。

数值二阶 U 矩阵

由于二阶数值梯度的误差较大,我们应当将上面的二阶梯度求取方式尽量化为一阶数值导数。根据对 \(C_{\mu p}\) 一阶导数的结果,我们容易知道

\[\frac{\partial^2}{\partial \mathbb{A} \partial \mathbb{B}} C_{\mu p} = C_{\mu m} U_{mp}^\mathbb{AB} = C_{\mu m} \frac{\partial U_{mp}^\mathbb{A}}{\partial \mathbb{B}} + C_{\mu m} U_{mr}^\mathbb{B} U_{rp}^\mathbb{A}\]

假使 \(C_{\mu m}\) 矩阵可逆,同时我们更换一下角标,就得到 (Yamaguchi, p399, eq H.1)

\[U_{pq}^\mathbb{AB} = \frac{\partial U_{pq}^\mathbb{A}}{\partial \mathbb{B}} + U_{pm}^\mathbb{B} U_{mq}^\mathbb{A}\]

我们用 n_U_2 表示通过数值方法给出的 \(U_{pq}^{A_t B_s}\)

[9]:
nd_U_1 = NumericDiff(gradn_nr, lambda gradh_nr: gradh_nr.U_1).derivative.swapaxes(0, 1)
n_U_2 = nd_U_1 + np.einsum("Bpm, Amq -> ABpq", U_1_nr, U_1_nr)
n_U_2.shape
[9]:
(12, 12, 22, 22)

解析二阶 U 矩阵

Fock 矩阵二阶全导数

回顾到一阶 U 矩阵的计算,它的前提是未经过“轨道旋转”的 (Canonical) RHF 的 Fock 矩阵的非对角元为零。二阶 U 矩阵的推导也一样是这个前提。

我们先需要回顾一下 Fock 一阶梯度计算:

\[\frac{\partial F_{pq}}{\partial \mathbb{A}} = \big[ F_{pm} U_{mq}^\mathbb{A} + \mathrm{swap} (p, q) \big] + \frac{\partial F_{\mu \nu}}{\partial \mathbb{A}} C_{\mu p} C_{\nu q}\]

上式有意地没有写成我们可以用于实际计算的表达式

\[\frac{\partial F_{pq}}{\partial \mathbb{A}} = F_{pm} U_{mq}^\mathbb{A} + F_{qm} U_{mp}^\mathbb{A} + F_{pq}^\mathbb{A} + A_{pq, mk} U_{mk}^\mathbb{A}\]

我们再作二阶导数,得到

\[\begin{split}\begin{align} \frac{\partial^2 F_{pq}}{\partial \mathbb{A} \partial \mathbb{B}} &= \big[ F_{pr} U_{rm}^\mathbb{B} U_{mq}^\mathbb{A} + F_{rm} U_{rp}^\mathbb{B} U_{mq}^\mathbb{A} + \mathrm{swap} (p, q) \big] \\ &\quad + \big[ \frac{\partial F_{\mu \nu}}{\partial \mathbb{A}} C_{\mu m} U_{mp}^\mathbb{B} C_{\nu q} + \mathrm{swap} (p, q) + \mathrm{swap} (\mathbb{A}, \mathbb{B}) \big] \\ &\quad + \frac{\partial^2 F_{\mu \nu}}{\partial \mathbb{A} \partial \mathbb{B}} C_{\mu p} C_{\nu q} + \big[ F_{pm} \frac{\partial U_{mq}^\mathbb{A}}{\partial \mathbb{B}} + \mathrm{swap} (p, q) \big] \\ &= \big[ F_{pm} U_{mq}^{\mathbb{A} \mathbb{B}} + F_{rm} U_{rp}^\mathbb{B} U_{mq}^\mathbb{A} + \mathrm{swap} (p, q) \big] \\ &\quad + \big[ F_{pm}^\mathbb{A} U_{mq}^\mathbb{B} + A_{pm, rk} U_{rk}^\mathbb{A} U_{mq}^\mathbb{B} + \mathrm{swap} (p, q) + \mathrm{swap} (\mathbb{A}, \mathbb{B}) \big] \\ &\quad + \frac{\partial^2 F_{\mu \nu}}{\partial \mathbb{A} \partial \mathbb{B}} C_{\mu p} C_{\nu q} \\ \end{align}\end{split}\]

上式中,\(\partial_\mathbb{A} \partial_\mathbb{B} F_{\mu \nu}\) 还需要再进行进一步推导。

\[\frac{\partial^2 F_{\mu \nu}}{\partial \mathbb{A} \partial \mathbb{B}} C_{\mu p} C_{\nu q} = \frac{\partial \big( F_{\mu \nu}^\mathbb{A} + A_{\mu \nu, \kappa \lambda} C_{\kappa m} U_{mk}^\mathbb{A} C_{\lambda k} \big)}{\partial \mathbb{B}} C_{\mu p} C_{\nu q}\]

若我们定义 Fock Skeleton 导数 F_2_ao \(F_{\mu \nu}^\mathbb{AB}\)

\[\frac{\partial F_{\mu \nu}^\mathbb{A}}{\partial \mathbb{B}} = F_{\mu \nu}^\mathbb{AB} + \frac{\partial A_{\mu \nu, \kappa \lambda}}{\partial \mathbb{A}} C_{\kappa m} U_{mk}^\mathbb{B} C_{\lambda k}\]

以及 A 张量全导数

\[A_{\mu \nu, \kappa \lambda}^\mathbb{A} = \frac{\partial A_{\mu \nu, \kappa \lambda}}{\partial \mathbb{A}}\]

那么,

\[\frac{\partial^2 F_{\mu \nu}}{\partial \mathbb{A} \partial \mathbb{B}} C_{\mu p} C_{\nu q} = F_{pq}^\mathbb{AB} + A_{pq, mk}^\mathbb{A} U_{mk}^\mathbb{B} + A_{pq, mk}^\mathbb{B} U_{mk}^\mathbb{A} + A_{pq, rk} U_{rm}^\mathbb{B} U_{mk}^\mathbb{A} + A_{pq, mr} U_{rk}^\mathbb{B} U_{mk}^\mathbb{A} + A_{pq, mk} U_{mk}^\mathbb{AB} - A_{pq, mk} U_{mr}^\mathbb{B} U_{rk}^\mathbb{A}\]

综上,我们可以整理得到

\[\begin{split}\begin{align} \frac{\partial^2 F_{pq}}{\partial \mathbb{A} \partial \mathbb{B}} &= F_{pq}^\mathbb{AB} + A_{pq, mk} U_{mk}^\mathbb{AB} + \varepsilon_p U_{pq}^\mathbb{AB} + \varepsilon_q U_{qp}^\mathbb{AB} \\ &\quad + \big[ F_{pm}^\mathbb{A} U_{mq}^\mathbb{B} + \mathrm{swap} (p, q) + \mathrm{swap} (\mathbb{A}, \mathbb{B}) \big] \\ &\quad + \varepsilon_m \big[ U_{mp}^\mathbb{A} U_{mq}^\mathbb{B} + \mathrm{swap} (p, q) \big] \\ &\quad + A_{pq, mr} U_{mk}^\mathbb{A} U_{rk}^\mathbb{B} \\ &\quad + \big[ A_{pm, rk} U_{rk}^\mathbb{A} U_{mq}^\mathbb{B} + \mathrm{swap} (p, q) + \mathrm{swap} (\mathbb{A}, \mathbb{B}) \big] \\ &\quad + \big[ A_{pq, mk}^\mathbb{A} U_{mk}^\mathbb{B} + \mathrm{swap} (\mathbb{A}, \mathbb{B}) \big] \end{align}\end{split}\]

重叠矩阵二阶全导数

在继续推导之前,我们引入二阶 U 矩阵的一个性质。我们考察重叠矩阵的全导数。在此之前,我们先回顾一下重叠矩阵一阶导数:

\[\begin{align} \frac{\partial S_{pq}}{\partial \mathbb{A}} = S_{pq}^\mathbb{A} + U_{pq}^\mathbb{A} + U_{qp}^\mathbb{A} = 0 \end{align}\]

相应地,我们可以给出

\[\begin{split}\begin{align} 0 = \frac{\partial^2 S_{pq}}{\partial \mathbb{A} \partial \mathbb{B}} &= S_{pq}^\mathbb{AB} + S_{pm}^\mathbb{A} U_{mq}^\mathbb{B} + S_{mq}^\mathbb{A} U_{mp}^\mathbb{B} \\ &\quad + U_{pq}^\mathbb{AB} + U_{qp}^\mathbb{AB} - U_{pm}^\mathbb{B} U_{mq}^\mathbb{A} - U_{qm}^\mathbb{B} U_{mp}^\mathbb{A} \end{align}\end{split}\]

我们会将上式中除了二阶 U 矩阵之外的项写为 Xi_2 \(\xi_{pq}^\mathbb{AB}\):(Yamaguchi, p405, eq L.4)

\[\begin{split}\begin{align} \xi_{pq}^\mathbb{AB} &= S_{pq}^\mathbb{AB} + S_{pm}^\mathbb{A} U_{mq}^\mathbb{B} + S_{mq}^\mathbb{A} U_{mp}^\mathbb{B} - U_{pm}^\mathbb{B} U_{mq}^\mathbb{A} - U_{qm}^\mathbb{B} U_{mp}^\mathbb{A} \\ &= \frac{1}{2} S_{pq}^\mathbb{AB} + U_{pm}^\mathbb{A} U_{qm}^\mathbb{B} - S_{pm}^\mathbb{A} S_{qm}^\mathbb{B} + \mathrm{swap} (\mathbb{A}, \mathbb{B}) \end{align}\end{split}\]

对于二阶核坐标梯度而言,代码可以写如:

[10]:
Xi_2 = (
    + 0.5 * S_2_mo
    + np.einsum("Apm, Bqm -> ABpq", U_1_nr, U_1_nr)
    - np.einsum("Apm, Bqm -> ABpq", S_1_mo, S_1_mo)
)
Xi_2 += Xi_2.swapaxes(0, 1)

但需要注意到,现在我们讨论的是二阶核坐标梯度,因此被求导量 \(\mathbb{A}, \mathbb{B}\) 是相同的,所以可以使用 swapaxes 求取。但若是像核坐标与电场混合二阶导数,就必须要显式地展开 \(\mathrm{swap} (\mathbb{A}, \mathbb{B})\)

该量在 pyxdh 中也存在对应的 property:

[11]:
np.allclose(Xi_2, hessh_nr.Xi_2)
[11]:
True

任务 (1)

用程序证明,

\[\xi_{pq}^\mathbb{AB} \neq S_{pq}^\mathbb{AB} + \mathscr{U}_{pm}^\mathbb{A} \mathscr{U}_{qm}^\mathbb{B} - S_{pm}^\mathbb{A} S_{qm}^\mathbb{B} + \mathrm{swap} (\mathbb{A}, \mathbb{B})\]

这个结论几乎表明,尽管轨道旋转后的 \(\mathscr{U}_{ai}^\mathbb{A}\) 可以与未旋转的 \(U_{ai}^\mathbb{A}\) 设为相同,但在相同的轨道旋转构造下,\(\mathscr{U}_{ai}^\mathbb{AB} \not\equiv U_{ai}^\mathbb{AB}\)

因此 (Yamaguchi, p405, eq L.3)

\[\xi_{pq}^\mathbb{AB} + U_{pq}^\mathbb{AB} + U_{qp}^\mathbb{AB} = 0\]

一阶 A 张量、Fock 二阶 Skeleton 导数与二阶 B 矩阵

我们利用上述的 \(U_{pq}^\mathbb{AB}\)\(U_{qp}^\mathbb{AB}\) 之间的关系,重述 \(\partial_\mathbb{A} \partial_\mathbb{B} F_{pq}\) 为:(Yamaguchi, p433, eq W.2)

\[\begin{split}\begin{align} \frac{\partial^2 F_{pq}}{\partial \mathbb{A} \partial \mathbb{B}} &= (\varepsilon_p - \varepsilon_q) U_{pq}^\mathbb{AB} + A_{pq, bj} U_{bj}^\mathbb{AB} \\ &\quad + F_{pq}^\mathbb{AB} - \frac{1}{2} A_{pq, kl} \xi_{kl}^\mathbb{AB} - \xi_{pq}^\mathbb{AB} \varepsilon_q \\ &\quad + \big[ F_{pm}^\mathbb{A} U_{mq}^\mathbb{B} + \mathrm{swap} (p, q) + \mathrm{swap} (\mathbb{A}, \mathbb{B}) \big] \\ &\quad + \varepsilon_m \big[ U_{mp}^\mathbb{A} U_{mq}^\mathbb{B} + \mathrm{swap} (p, q) \big] \\ &\quad + A_{pq, mr} U_{mk}^\mathbb{A} U_{rk}^\mathbb{B} \\ &\quad + \big[ A_{pm, rk} U_{rk}^\mathbb{A} U_{mq}^\mathbb{B} + \mathrm{swap} (p, q) + \mathrm{swap} (\mathbb{A}, \mathbb{B}) \big] \\ &\quad + \big[ A_{pq, mk}^\mathbb{A} U_{mk}^\mathbb{B} + \mathrm{swap} (\mathbb{A}, \mathbb{B}) \big] \end{align}\end{split}\]

上式的第二行以后的所有项,我们定义为 B_2 \(B_{pq}^\mathbb{AB}\)。在此之前,我们还是现将 \(\mathrm{swap} (\mathbb{A}, \mathbb{B})\) 展开,避免以后的混合偏导数情况时重写代码:(Yamaguchi, p437, eq X.5)

\[\begin{split}\begin{align} B_{pq}^\mathbb{AB} &= F_{pq}^\mathbb{AB} - \frac{1}{2} A_{pq, kl} \xi_{kl}^\mathbb{AB} - \xi_{pq}^\mathbb{AB} \varepsilon_q \\ &\quad + F_{mp}^\mathbb{A} U_{mq}^\mathbb{B} + F_{mp}^\mathbb{B} U_{mq}^\mathbb{A} + F_{mq}^\mathbb{A} U_{mp}^\mathbb{B} + F_{mq}^\mathbb{B} U_{mp}^\mathbb{A} \\ &\quad + ( U_{mp}^\mathbb{A} U_{mq}^\mathbb{B} + U_{mp}^\mathbb{B} U_{mq}^\mathbb{A} ) \varepsilon_m \\ &\quad + A_{pq, mr} U_{mk}^\mathbb{A} U_{rk}^\mathbb{B} \\ &\quad + A_{pm, rk} (U_{rk}^\mathbb{A} U_{mq}^\mathbb{B} + U_{rk}^\mathbb{B} U_{mq}^\mathbb{A}) \\ &\quad + A_{qm, rk} (U_{rk}^\mathbb{A} U_{mp}^\mathbb{B} + U_{rk}^\mathbb{B} U_{mp}^\mathbb{A}) \\ &\quad + A_{pq, mk}^\mathbb{A} U_{mk}^\mathbb{B} + A_{pq, mk}^\mathbb{B} U_{mk}^\mathbb{A} \end{align}\end{split}\]

在给出二阶 B 矩阵之前,我们需要给出 A_1_mo \(A_{pq, rs}^\mathbb{A}\) 的表达式:(Yamaguchi, p407, eq M.5)

\[A_{pq, rs}^\mathbb{A} = C_{\mu p} C_{\nu q} \frac{\partial A_{\mu \nu, \kappa \lambda}}{\partial \mathbb{A}} C_{\kappa r} C_{\lambda s} = 4 (pq | rs)^\mathbb{AB} - (pr | qs)^\mathbb{AB} - (ps | qr)^\mathbb{AB}\]

下述的代码并不是严格按上式来编写的,利用到了 ERI 积分的对称性。但需要指出,\(A_{pq, rs}^\mathbb{A}\) 从推导的过程上,并不是 \(A_{pq, rs}\) 的 Skeleton 导数。这点很关键,我们将会在 GGA 的 A 张量导数时作进一步说明。

[12]:
A_1_mo = 4 * eri1_mo - eri1_mo.swapaxes(-1, -3) - eri1_mo.swapaxes(-2, -3)

在 pyxdh 中,具体执行 A 张量缩并的程序是 Ax1_Core,譬如对于 \(A_{pq, rs}^\mathbb{A} X_{rs}\) 而言,

[13]:
X = np.random.randn(nmo, nmo)
np.allclose(
    np.einsum("Apqrs, rs -> Apq", A_1_mo, X),
    gradh.Ax1_Core(sa, sa, sa, sa)(X)
)
[13]:
True

除此之外,我们还需要给出 Fock 矩阵二阶 Skeleton 导数。需要留意的是,F_2_ao \(F_{\mu \nu}^\mathbb{AB}\) 是由下式定义而来:

\[\frac{\partial F_{\mu \nu}^\mathbb{A}}{\partial \mathbb{B}} = F_{\mu \nu}^\mathbb{AB} + \frac{\partial A_{\mu \nu, \kappa \lambda}}{\partial \mathbb{A}} C_{\kappa m} U_{mk}^\mathbb{B} C_{\lambda k}\]

其在 RHF 下,表达式则为 (Yamaguchi, p408, M.7)

\[F_{\mu \nu}^\mathbb{AB} = h_{\mu \nu}^\mathbb{AB} + (\mu \nu | \kappa \lambda)^\mathbb{AB} D_{\kappa \lambda} - \frac{1}{2} (\mu \kappa | \nu \lambda)^\mathbb{AB} D_{\kappa \lambda}\]
[14]:
F_2_ao = H_2_ao + ((eri2_ao - 0.5 * eri2_ao.swapaxes(-2, -3)) * D).sum(axis=(-1, -2))
F_2_mo = C.T @ F_2_ao @ C

我们仍然需要注意到,Fock 矩阵的 Skeleton 导数是由上式给出的。在 GGA 推导中,我们需要格外注意。

有这些准备后,我们就能给出二阶 B 矩阵 B_2 了:

上式的第二行以后的所有项,我们定义为 B_2 \(B_{pq}^\mathbb{AB}\)。在此之前,我们还是现将 \(\mathrm{swap} (\mathbb{A}, \mathbb{B})\) 展开,避免以后的混合偏导数情况时重写代码:(Yamaguchi, p437, eq X.5)

\[\begin{split}\begin{align} B_{pq}^\mathbb{AB} &= F_{pq}^\mathbb{AB} - \frac{1}{2} A_{pq, kl} \xi_{kl}^\mathbb{AB} - \xi_{pq}^\mathbb{AB} \varepsilon_q \\ &\quad + F_{mp}^\mathbb{A} U_{mq}^\mathbb{B} + F_{mp}^\mathbb{B} U_{mq}^\mathbb{A} + F_{mq}^\mathbb{A} U_{mp}^\mathbb{B} + F_{mq}^\mathbb{B} U_{mp}^\mathbb{A} \\ &\quad + ( U_{mp}^\mathbb{A} U_{mq}^\mathbb{B} + U_{mp}^\mathbb{B} U_{mq}^\mathbb{A} ) \varepsilon_m \\ &\quad + A_{pq, mr} U_{mk}^\mathbb{A} U_{rk}^\mathbb{B} \\ &\quad + A_{pm, rk} (U_{rk}^\mathbb{A} U_{mq}^\mathbb{B} + U_{rk}^\mathbb{B} U_{mq}^\mathbb{A}) \\ &\quad + A_{qm, rk} (U_{rk}^\mathbb{A} U_{mp}^\mathbb{B} + U_{rk}^\mathbb{B} U_{mp}^\mathbb{A}) \\ &\quad + A_{pq, mk}^\mathbb{A} U_{mk}^\mathbb{B} + A_{pq, mk}^\mathbb{B} U_{mk}^\mathbb{A} \end{align}\end{split}\]
[15]:
B_2 = (
    # Line 1
    + F_2_mo
    - 0.5 * Ax0_Core(sa, sa, so, so)(Xi_2[:, :, so, so])
    - Xi_2 * e
    # Line 2
    + np.einsum("Amp, Bmq -> ABpq", F_1_mo, U_1_nr)
    + np.einsum("Bmp, Amq -> ABpq", F_1_mo, U_1_nr)
    + np.einsum("Amq, Bmp -> ABpq", F_1_mo, U_1_nr)
    + np.einsum("Bmq, Amp -> ABpq", F_1_mo, U_1_nr)
    # Line 3
    + np.einsum("Amp, Bmq, m -> ABpq", U_1_nr, U_1_nr, e)
    + np.einsum("Bmp, Amq, m -> ABpq", U_1_nr, U_1_nr, e)
    # Line 4
    + Ax0_Core(sa, sa, sa, sa)(np.einsum("Amk, Brk -> ABmr", U_1_nr[:, :, so], U_1_nr[:, :, so]))
    # Line 5
    + np.einsum("Apm, Bmq -> ABpq", Ax0_Core(sa, sa, sa, so)(U_1_nr[:, :, so]), U_1_nr)
    + np.einsum("Bpm, Amq -> ABpq", Ax0_Core(sa, sa, sa, so)(U_1_nr[:, :, so]), U_1_nr)
    # Line 6
    + np.einsum("Aqm, Bmp -> ABpq", Ax0_Core(sa, sa, sa, so)(U_1_nr[:, :, so]), U_1_nr)
    + np.einsum("Bqm, Amp -> ABpq", Ax0_Core(sa, sa, sa, so)(U_1_nr[:, :, so]), U_1_nr)
    # Line 7
    + np.einsum("Apqmk, Bmk -> ABpq", A_1_mo[:, :, :, :, so], U_1_nr[:, :, so])
    + np.einsum("Bpqmk, Amk -> ABpq", A_1_mo[:, :, :, :, so], U_1_nr[:, :, so])
)
[16]:
np.allclose(B_2, hessh_nr.B_2)
[16]:
True

CP-HF 方程解二阶 U 矩阵

根据 \(\partial_\mathbb{A} \partial_\mathbb{B} F_{ai}\) 的条件,我们能得到二阶 CP-HF 方程:

\[- (\varepsilon_i - \varepsilon_a) U_{ai}^\mathbb{AB} - A_{ai, bj} U_{bj}^\mathbb{AB} = B_{ai}^\mathbb{AB}\]

该式的求解过程与 CP-HF 方程求解一阶 U 矩阵、或者通过 Z-Vector 方法求 PT2+ 密度矩阵的方法完全相同。U_2_nr_vo 就可以用下述代码获得:

[17]:
U_2_vo_nr = cphf.solve(
    Ax0_Core(sv, so, sv, so),
    e,
    gradh.scf_eng.mo_occ,
    B_2[:, :, sv, so].reshape(-1, nvir, nocc),
    max_cycle=100,
    tol=1e-12,
)[0].reshape(natm * 3, natm * 3, nvir, nocc)

就像一阶 U 矩阵的计算方式一样,剩余的部分是占据-占据与非占-非占部分。除了对角元之外,其余的项都可以用下述表达式给出:

\[U_{pq}^\mathbb{AB} = - (B_{pq}^\mathbb{AB} + A_{pq, bj} U_{bj}^\mathbb{AB}) / (\varepsilon_p - \varepsilon_q), \quad p \neq q\]
[18]:
U_2_nr = - (B_2 + Ax0_Core(sa, sa, sv, so)(U_2_vo_nr)) / (e[:, None] - e[None, :])

而对角元是通过 \(\xi_{pq}^\mathbb{AB} + U_{pq}^\mathbb{AB} + U_{qp}^\mathbb{AB} = 0\) 给出:

\[U_{pp}^\mathbb{AB} = - \frac{1}{2} \xi_{pq}^\mathbb{AB}\]
[19]:
for p in range(nmo):
    U_2_nr[:, :, p, p] = - Xi_2[:, :, p, p] / 2

在 pyxdh 中,在给定 rotation 选项为 False 时,可以获得相同的 \(U_{pq}^\mathbb{AB}\)

[20]:
np.allclose(U_2_nr, hessh_nr.U_2)
[20]:
True

我们也可以用数值方式给出的二阶 U 矩阵来验证我们的结果:

[21]:
plot_diff(U_2_nr, n_U_2)

参考任务解答

任务 (1)

[22]:
np.allclose(Xi_2, hessh.Xi_2)
[22]:
False

“不安全”的 MP2 Hessian

这一节,我们会介绍“不安全”的 MP2 Hessian 的求解过程。这非常接近 pyxdh 所实际采用的计算流程;但我们将所有涉及到 \(\mathscr{U}_{pq}^{A_t}\) 之处,全部替换为了 \(U_{pq}^{A_t}\)。尽管我们会提及二阶 U 矩阵 \(U_{pq}^{A_t B_s}\),但在实际计算中会依靠逆向 Z-Vector 方法,尽量避免使用它。

准备工作

程序变量名变更

出于程序简便的考量,这一节中特别地,所有 U_1 代表的不是 \(\mathscr{U}_{pq}^\mathbb{A}\),而是原先用 U_1_nr 所指代的未经轨道旋转的 \(U_{pq}^\mathbb{A}\)

类似地,gradh_nr 会被 gradhhessh_nr 会被 hessh 替代。

我们这里同时引入 A 张量一阶导数量的缩并函数 Ax1_Core;它从使用上与 Ax0_Core 相同,但关于维度上,\(A_{pq, rs}^\mathbb{A} X_{rs}^\mathbb{B}\) 在对 \(r, s\) 角标缩并后给出的结果是 \((\mathbb{A}, \mathbb{B}, p, q)\) 维度的。

[1]:
%matplotlib notebook

from pyscf import gto, scf, dft, lib, grad, hessian
from pyscf.scf import cphf
import numpy as np
from functools import partial
import warnings
from matplotlib import pyplot as plt
from pyxdh.Utilities import NucCoordDerivGenerator, DipoleDerivGenerator, NumericDiff
from pyxdh.DerivOnce import GradMP2
from pyxdh.DerivTwice import HessMP2, HessSCF

np.einsum = partial(np.einsum, optimize=["greedy", 1024 ** 3 * 2 / 8])
np.allclose = partial(np.allclose, atol=1e-6, rtol=1e-4)
np.set_printoptions(5, linewidth=150, suppress=True)
warnings.filterwarnings("ignore")
[2]:
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()
[2]:
<pyscf.gto.mole.Mole at 0x7fa8d03be340>
[3]:
gradh = GradMP2({"scf_eng": scf.RHF(mol), "cphf_tol": 1e-12, "rotation": False})
hessh = HessMP2({"deriv_A": gradh, "deriv_B": gradh, "rotation": False})
[4]:
nmo, nao, natm, nocc, nvir = gradh.nao, gradh.nao, gradh.natm, gradh.nocc, gradh.nvir
so, sv, sa = gradh.so, gradh.sv, gradh.sa
mol_slice = gradh.mol_slice
C, Co, Cv, e, eo, ev, D = gradh.C, gradh.Co, gradh.Cv, gradh.e, gradh.eo, gradh.ev, gradh.D
H_0_ao, S_0_ao, eri0_ao, F_0_ao = gradh.H_0_ao, gradh.S_0_ao, gradh.eri0_ao, gradh.F_0_ao
H_0_mo, S_0_mo, eri0_mo, F_0_mo = gradh.H_0_mo, gradh.S_0_mo, gradh.eri0_mo, gradh.F_0_mo
H_1_ao, S_1_ao, eri1_ao, F_1_ao = gradh.H_1_ao, gradh.S_1_ao, gradh.eri1_ao, gradh.F_1_ao
H_1_mo, S_1_mo, eri1_mo, F_1_mo = gradh.H_1_mo, gradh.S_1_mo, gradh.eri1_mo, gradh.F_1_mo
Ax0_Core, B_1, U_1, U_1_vo = gradh.Ax0_Core, gradh.B_1, gradh.U_1, gradh.U_1_vo
H_2_ao, S_2_ao, eri2_ao, F_2_ao = hessh.H_2_ao, hessh.S_2_ao, hessh.eri2_ao, hessh.F_2_ao
H_2_mo, S_2_mo, eri2_mo, F_2_mo = hessh.H_2_mo, hessh.S_2_mo, hessh.eri2_mo, hessh.F_2_mo
Ax1_Core, B_2, U_2 = gradh.Ax1_Core, hessh.B_2, hessh.U_2
[5]:
T_iajb, t_iajb, D_iajb = gradh.T_iajb, gradh.t_iajb, gradh.D_iajb
D_r, W_I = gradh.D_r, gradh.W_I
[6]:
def grad_generator(mol):
    scf_eng = scf.RHF(mol)
    config = {"scf_eng": scf_eng, "cphf_tol": 1e-12, "rotation": False}
    return GradMP2(config)

gradn = NucCoordDerivGenerator(mol, grad_generator)
[7]:
def plot_diff(anal_mat, num_mat):
    fig, ax = plt.subplots(figsize=(2.4, 1.8)); ax.set_xscale("log")
    ax.hist(abs(anal_mat.ravel() - num_mat.ravel()), bins=np.logspace(np.log10(1e-10), np.log10(1e-1), 50), alpha=0.5)
    ax.hist(abs(num_mat.ravel()), bins=np.logspace(np.log10(1e-10), np.log10(1e-1), 50), alpha=0.5)
    return fig.tight_layout()

程序二阶导数正确性的验证

首先,MP2 的二阶导数可以通过 E_2 property 获得:

[8]:
hessh.E_2.shape
[8]:
(12, 12)

Gaussian 程序可以计算 MP2 的二阶导数。其 输入卡fchk 文件 可以前往 Github 上下载。我们这里拿现成的程序验证之:

[9]:
from pyxdh.Utilities import FormchkInterface
[10]:
fchk_MP2 = FormchkInterface("../../../pyxdh/Validation/gaussian/H2O2-MP2-freq.fchk")  # if use Py_xDH repository, this link should be valid
np.allclose(hessh.E_2, fchk_MP2.hessian())
[10]:
True

其 RHF 部分的 Hessian 可以由下面导出:

[11]:
E_2_RHF_contrib = HessSCF._get_E_2(hessh)
E_2_RHF_contrib.shape
[11]:
(12, 12)
[12]:
fchk_RHF = FormchkInterface("../../../pyxdh/Validation/gaussian/H2O2-HF-freq.fchk")  # if use Py_xDH repository, this link should be valid
np.allclose(E_2_RHF_contrib, fchk_RHF.hessian())
[12]:
True

而 MP2 部分的 Hessian 则由下式导出:

[13]:
E_2_MP2_contrib = hessh._get_E_2_MP2_Contrib()
E_2_MP2_contrib.shape
[13]:
(12, 12)

两部分的加和即是总 Hessian:

[14]:
np.allclose(E_2_RHF_contrib + E_2_MP2_contrib, hessh.E_2)
[14]:
True

我们文档的目标,将是生成 E_2_MP2_contrib \(\partial_\mathbb{A} \partial_\mathbb{B} E_\mathrm{MP2, c}\)

MP2 二阶梯度概述

回顾到

\[\partial_\mathbb{A} E_\mathrm{MP2, c} = D_{pq}^\mathrm{MP2} B_{pq}^\mathbb{A} + W_{pq}^\mathrm{MP2} [\mathrm{I}] S_{pq}^\mathbb{A} + 2 T_{ij}^{ab} (ia | jb)^\mathbb{A}\]

我们需要做的,仅仅是将链式法则用于上式中。

\[\begin{split}\begin{align} \partial_\mathbb{A} \partial_\mathbb{B} E_\mathrm{MP2, c} &= \partial_\mathbb{B} D_{pq}^\mathrm{MP2} \cdot B_{pq}^\mathbb{A} + D_{pq}^\mathrm{MP2} \cdot \partial_\mathbb{B} B_{pq}^\mathbb{A} \\ &\quad + \partial_\mathbb{B} W_{pq}^\mathrm{MP2} [\mathrm{I}] \cdot S_{pq}^\mathbb{A} + W_{pq}^\mathrm{MP2} [\mathrm{I}] \cdot \partial_\mathbb{B} S_{pq}^\mathbb{A} \\ &\quad + 2 \partial_\mathbb{B} T_{ij}^{ab} \cdot (ia | jb)^\mathbb{A} + 2 T_{ij}^{ab} \cdot \partial_\mathbb{B} (ia | jb)^\mathbb{A} \end{align}\end{split}\]

我们简单讨论一下上面的表达式。在给定未经轨道旋转的 U 矩阵的情况下,首先,\(\partial_\mathbb{B} S_{pq}^\mathbb{A}\)\(\partial_\mathbb{B} (ia | jb)^\mathbb{A}\) 是容易求取的。\(\partial_\mathbb{B} B_{pq}^\mathbb{A}\) 需要使用到 \(A_{pq, kl}^\mathbb{A}\),这我们在上一节也讨论过了。因此,右侧的三项就不是很困难的问题。

对于左边的三项而言,首先,\(W_{pq}^\mathrm{MP2} [\mathrm{I}]\)\(T_{ij}^{ab}\)\(D_{ij}^\mathrm{MP2}\), \(D_{ab}^\mathrm{MP2}\) 都需要 \(t_{ij}^{ab}\),因此需要计算 \(t_{ij}^{ab}\) 的全导数。除此之外,\(D_{ai}^\mathrm{MP2}\) 的推导是通过 CP-HF 方程给出的,因此它的求导也需要技巧。

pdB_B_A \(\partial_\mathbb{B} B_{pq}^\mathbb{A}\)pdB_S_A_mo \(\partial_\mathbb{B} S_{pq}^\mathbb{A}\)

回顾 \(B_{pq}^\mathbb{A}\) 的定义:

\[B_{pq}^\mathbb{A} = F_{pq}^\mathbb{A} - S_{pq}^\mathbb{A} \varepsilon_q - \frac{1}{2} A_{pq, kl} S_{kl}^\mathbb{A}\]

我们之后会重述该 B 矩阵为

\[B_{pq}^\mathbb{A} = F_{pq}^\mathbb{A} - S_{pm}^\mathbb{A} F_{qm} - \frac{1}{2} A_{pq, kl} S_{kl}^\mathbb{A}\]

我们求取上述导数的方式也很暴力:

\[\begin{split}\begin{align} \partial_\mathbb{B} B_{pq}^\mathbb{A} &= \partial_\mathbb{B} F_{pq}^\mathbb{A} - \partial_\mathbb{B} S_{pm}^\mathbb{A} \cdot F_{qm} - S_{pm}^\mathbb{A} \cdot \partial_\mathbb{B} F_{qm} - \frac{1}{2} A_{pq, kl} \cdot \partial_\mathbb{B} S_{kl}^\mathbb{A} - \frac{1}{2} A_{pq, kl}^\mathbb{B} S_{kl}^\mathbb{A} \\ &\quad - A_{pq, mk} U_{ml}^\mathbb{B} S_{kl}^\mathbb{A} - \frac{1}{2} A_{pm, kl} S_{kl}^\mathbb{A} U_{mq}^\mathbb{B} - \frac{1}{2} A_{mq, kl} S_{kl}^\mathbb{A} U_{mp}^\mathbb{B} \end{align}\end{split}\]

我们给出一些中间变量:

  • pdB_F_A_mo \(\partial_\mathbb{B} F_{pq}^\mathbb{A}\)

\[\partial_\mathbb{B} F_{pq}^\mathbb{A} = F_{pq}^\mathbb{AB} + A_{pq, mk}^\mathbb{A} U_{mk}^\mathbb{B} + F_{pm}^\mathbb{A} U_{mq}^\mathbb{B} + F_{mq}^\mathbb{A} U_{mp}^\mathbb{B}\]
[15]:
pdB_F_A_mo = (
    + F_2_mo
    + Ax1_Core(sa, sa, sa, so)(U_1[:, :, so])
    + np.einsum("Apm, Bmq -> ABpq", F_1_mo, U_1)
    + np.einsum("Amq, Bmp -> ABpq", F_1_mo, U_1)
)
pdB_F_A_mo.shape
[15]:
(12, 12, 22, 22)
[16]:
nd_F_1_mo = NumericDiff(gradn, lambda gradh: gradh.F_1_mo).derivative.swapaxes(0, 1)
plot_diff(pdB_F_A_mo, nd_F_1_mo)
[17]:
np.allclose(pdB_F_A_mo, hessh.pdB_F_A_mo)  # pyxdh approach
[17]:
True
  • pdB_S_A_mo \(\partial_\mathbb{B} S_{pq}^\mathbb{A}\)

\[\partial_\mathbb{B} S_{pq}^\mathbb{A} = S_{pq}^\mathbb{AB} + S_{pm}^\mathbb{A} U_{mq}^\mathbb{B} + S_{mq}^\mathbb{A} U_{mp}^\mathbb{B}\]
[18]:
pdB_S_A_mo = (
    + S_2_mo
    + np.einsum("Apm, Bmq -> ABpq", S_1_mo, U_1)
    + np.einsum("Amq, Bmp -> ABpq", S_1_mo, U_1)
)
pdB_S_A_mo.shape
[18]:
(12, 12, 22, 22)
[19]:
nd_S_1_mo = NumericDiff(gradn, lambda gradh: gradh.S_1_mo).derivative.swapaxes(0, 1)
plot_diff(pdB_S_A_mo, nd_S_1_mo)
[20]:
np.allclose(pdB_S_A_mo, hessh.pdB_S_A_mo)  # pyxdh approach
[20]:
True
  • pdA_F_0_mo \(\partial_\mathbb{A} F_{pq}\)

\[\partial_\mathbb{A} F_{pq} = F_{pq}^\mathbb{A} + A_{pq, mk} U_{mk}^\mathbb{A} + F_{pm} U_{mq}^\mathbb{A} + F_{mq} U_{mp}^\mathbb{A}\]
[21]:
pdA_F_0_mo = (
    + F_1_mo
    + Ax0_Core(sa, sa, sa, so)(U_1[:, :, so])
    + np.einsum("pm, Amq -> Apq", F_0_mo, U_1)
    + np.einsum("mq, Amp -> Apq", F_0_mo, U_1)
)
pdA_F_0_mo.shape
[21]:
(12, 22, 22)
[22]:
nd_F_0_mo = NumericDiff(gradn, lambda gradh: gradh.F_0_mo).derivative
plot_diff(pdA_F_0_mo.diagonal(axis1=-2, axis2=-1), nd_F_0_mo.diagonal(axis1=-2, axis2=-1))
  • pdB_B_A \(\partial_\mathbb{B} B_{pq}^\mathbb{A}\)

\[\begin{split}\begin{align} \partial_\mathbb{B} B_{pq}^\mathbb{A} &= \partial_\mathbb{B} F_{pq}^\mathbb{A} - \partial_\mathbb{B} S_{pm}^\mathbb{A} \cdot F_{qm} - S_{pm}^\mathbb{A} \cdot \partial_\mathbb{B} F_{qm} - \frac{1}{2} A_{pq, kl} \cdot \partial_\mathbb{B} S_{kl}^\mathbb{A} - \frac{1}{2} A_{pq, kl}^\mathbb{B} S_{kl}^\mathbb{A} \\ &\quad - A_{pq, mk} U_{ml}^\mathbb{B} S_{kl}^\mathbb{A} - \frac{1}{2} A_{pm, kl} S_{kl}^\mathbb{A} U_{mq}^\mathbb{B} - \frac{1}{2} A_{mq, kl} S_{kl}^\mathbb{A} U_{mp}^\mathbb{B} \end{align}\end{split}\]
[23]:
pdB_B_A = (
    + pdB_F_A_mo
    - np.einsum("ABpm, qm -> ABpq", pdB_S_A_mo, F_0_mo)
    - np.einsum("Apm, Bqm -> ABpq", S_1_mo, pdA_F_0_mo)
    - 0.5 * Ax0_Core(sa, sa, so, so)(pdB_S_A_mo[:, :, so, so])
    - 0.5 * Ax1_Core(sa, sa, so, so)(S_1_mo[:, so, so]).swapaxes(0, 1)
    - Ax0_Core(sa, sa, sa, so)(np.einsum("Bml, Akl -> ABmk", U_1[:, :, so], S_1_mo[:, so, so]))
    - 0.5 * np.einsum("Apm, Bmq -> ABpq", Ax0_Core(sa, sa, so, so)(S_1_mo[:, so, so]), U_1)
    - 0.5 * np.einsum("Amq, Bmp -> ABpq", Ax0_Core(sa, sa, so, so)(S_1_mo[:, so, so]), U_1)
)
pdB_B_A.shape
[23]:
(12, 12, 22, 22)
[24]:
nd_B_1 = NumericDiff(gradn, lambda gradh: gradh.B_1).derivative.swapaxes(0, 1)
plot_diff(pdB_B_A, nd_B_1)
[25]:
np.allclose(pdB_B_A, hessh.pdB_B_A)  # pyxdh approach
[25]:
True

pdB_pdpA_eri0_iajb \(\partial_\mathbb{B} (ia | jb)^\mathbb{A}\)

  • pdB_pdpA_eri0_iajb \(\partial_\mathbb{B} (ia | jb)^\mathbb{A}\)

\[\partial_\mathbb{B} (ia | jb)^\mathbb{A} = (ia | jb)^\mathbb{AB} + (ma | jb)^\mathbb{A} U_{mi}^\mathbb{B} + (im | jb)^\mathbb{A} U_{ma}^\mathbb{B} + (ia | mb)^\mathbb{A} U_{mj}^\mathbb{B} + (ia | jm)^\mathbb{A} U_{mb}^\mathbb{B}\]
[26]:
pdB_pdpA_eri0_iajb = (
    + np.einsum("ABuvkl, ui, va, kj, lb -> ABiajb", eri2_ao, Co, Cv, Co, Cv)
    + np.einsum("Amajb, Bmi -> ABiajb", eri1_mo[:, :, sv, so, sv], U_1[:, :, so])
    + np.einsum("Aimjb, Bma -> ABiajb", eri1_mo[:, so, :, so, sv], U_1[:, :, sv])
    + np.einsum("Aiamb, Bmj -> ABiajb", eri1_mo[:, so, sv, :, sv], U_1[:, :, so])
    + np.einsum("Aiajm, Bmb -> ABiajb", eri1_mo[:, so, sv, so, :], U_1[:, :, sv])
)
[27]:
nd_eri1_mo = NumericDiff(gradn, lambda gradh: gradh.eri1_mo[:, so, sv, so, sv]).derivative.swapaxes(0, 1)
plot_diff(pdB_pdpA_eri0_iajb, nd_eri1_mo)

pdA_t_iajb \(\partial_\mathbb{A} t_{ij}^{ab}\)

该导数的求取方式相对来说特别一些。首先,我们根据链式法则,容易给出如下表达式:

\[\partial_\mathbb{A} t_{ij}^{ab} = \big( - \partial_\mathbb{A} D_{ij}^{ab} \cdot t_{ij}^{ab} + \partial_\mathbb{A} (ia|jb) \big) / D_{ij}^{ab}\]

其中,\(\partial_\mathbb{A} (ia|jb)\) 是相对容易求的。但对于 \(\partial_\mathbb{A} D_{ij}^{ab} \cdot t_{ij}^{ab}\),我们会重新写为

\[\partial_\mathbb{A} D_{ij}^{ab} \cdot t_{ij}^{ab} = \partial_\mathbb{A} F_{ki} \cdot t_{kj}^{ab} + \partial_\mathbb{A} F_{kj} \cdot t_{ik}^{ab} - \partial_\mathbb{A} F_{ca} \cdot t_{ij}^{cb} - \partial_\mathbb{A} F_{cb} \cdot t_{ij}^{ac}\]
  • pdA_eri0_mo \(\partial_\mathbb{A} (pq|rs)\)

\[\partial_\mathbb{A} (pq|rs) = (pq|rs)^\mathbb{A} + (mq|rs) U_{mp}^\mathbb{A} + (pm|rs) U_{mq}^\mathbb{A} + (pq|ms) U_{mr}^\mathbb{A} + (pq|rm) U_{ms}^\mathbb{A}\]
[28]:
pdA_eri0_mo = (
    + eri1_mo
    + np.einsum("mqrs, Amp -> Apqrs", eri0_mo, U_1)
    + np.einsum("pmrs, Amq -> Apqrs", eri0_mo, U_1)
    + np.einsum("pqms, Amr -> Apqrs", eri0_mo, U_1)
    + np.einsum("pqrm, Ams -> Apqrs", eri0_mo, U_1)
)
[29]:
nd_eri0_mo = NumericDiff(gradn, lambda gradh: gradh.eri0_mo).derivative
plot_diff(pdA_eri0_mo, nd_eri0_mo)
[30]:
np.allclose(pdA_eri0_mo, gradh.pdA_eri0_mo)  # pyxdh approach
[30]:
True
  • pdA_t_iajb \(\partial_\mathbb{A} t_{ij}^{ab}\)

\[\partial_\mathbb{A} t_{ij}^{ab} = \big( \partial_\mathbb{A} (ia|jb) - \partial_\mathbb{A} F_{ki} \cdot t_{kj}^{ab} - \partial_\mathbb{A} F_{kj} \cdot t_{ik}^{ab} + \partial_\mathbb{A} F_{ca} \cdot t_{ij}^{cb} + \partial_\mathbb{A} F_{cb} \cdot t_{ij}^{ac} \big) / D_{ij}^{ab}\]
[31]:
pdA_t_iajb = (
    + pdA_eri0_mo[:, so, sv, so, sv]
    - np.einsum("Aki, kajb -> Aiajb", pdA_F_0_mo[:, so, so], t_iajb)
    - np.einsum("Akj, iakb -> Aiajb", pdA_F_0_mo[:, so, so], t_iajb)
    + np.einsum("Aca, icjb -> Aiajb", pdA_F_0_mo[:, sv, sv], t_iajb)
    + np.einsum("Acb, iajc -> Aiajb", pdA_F_0_mo[:, sv, sv], t_iajb)
)
pdA_t_iajb /= D_iajb
[32]:
nd_t_iajb = NumericDiff(gradn, lambda gradh: gradh.t_iajb).derivative
plot_diff(pdA_t_iajb, nd_t_iajb)
[33]:
np.allclose(pdA_t_iajb, gradh.pdA_t_iajb)  # pyxdh approach
[33]:
True

包含 \(\partial_\mathbb{A} t_{ij}^{ab}\) 的各类导数

pdA_T_iajb \(\partial_\mathbb{A} T_{ij}^{ab}\)

回顾到 (在 RHF 参考态下)

\[T_{ij}^{ab} = 2 t_{ij}^{ab} - t_{ij}^{ba}\]

因此

\[\partial_\mathbb{A} T_{ij}^{ab} = 2 \partial_\mathbb{A} t_{ij}^{ab} - \partial_\mathbb{A} t_{ij}^{ba}\]
[34]:
pdA_T_iajb = 2 * pdA_t_iajb - pdA_t_iajb.swapaxes(-1, -3)
pdA_T_iajb.shape
[34]:
(12, 9, 13, 9, 13)
[35]:
nd_T_iajb = NumericDiff(gradn, lambda gradh: gradh.T_iajb).derivative
plot_diff(pdA_T_iajb, nd_T_iajb)
[36]:
np.allclose(pdA_T_iajb, gradh.pdA_T_iajb)  # pyxdh approach
[36]:
True

pdB_D_r_oovv \(\partial_\mathbb{B} D_{pq}^\mathrm{MP2}\) 的占据-占据与非占-非占块

回顾弛豫密度的占据-占据与非占-非占块的计算:

\[\begin{split}\begin{aligned} D_{ij}^\text{PT2} &= - 2 T_{ik}^{ab} t_{jk}^{ab} \\ D_{ab}^\text{PT2} &= 2 T_{ij}^{ac} t_{ij}^{bc} \end{aligned}\end{split}\]

这也相当容易用链式法则给出其计算方式:

\[\begin{split}\begin{aligned} \partial_\mathbb{B} D_{ij}^\text{PT2} &= - 2 \partial_\mathbb{B} T_{ik}^{ab} \cdot t_{jk}^{ab} - 2 T_{ik}^{ab} \cdot \partial_\mathbb{B} t_{jk}^{ab} \\ \partial_\mathbb{B} D_{ab}^\text{PT2} &= 2 \partial_\mathbb{B} T_{ij}^{ac} \cdot t_{ij}^{bc} + 2 T_{ij}^{ac} \cdot \partial_\mathbb{B} t_{ij}^{bc} \end{aligned}\end{split}\]
[37]:
pdB_D_r_oovv = np.zeros((natm * 3, nmo, nmo))
pdB_D_r_oovv[:, so, so] -= 2 * np.einsum("iakb, Ajakb -> Aij", T_iajb, pdA_t_iajb)
pdB_D_r_oovv[:, sv, sv] += 2 * np.einsum("iajc, Aibjc -> Aab", T_iajb, pdA_t_iajb)
pdB_D_r_oovv[:, so, so] -= 2 * np.einsum("Aiakb, jakb -> Aij", pdA_T_iajb, t_iajb)
pdB_D_r_oovv[:, sv, sv] += 2 * np.einsum("Aiajc, ibjc -> Aab", pdA_T_iajb, t_iajb)
[38]:
nd_D_r = NumericDiff(gradn, lambda gradh: gradh.D_r).derivative
[39]:
plot_diff(pdB_D_r_oovv[:, so, so], nd_D_r[:, so, so])
[40]:
plot_diff(pdB_D_r_oovv[:, sv, sv], nd_D_r[:, sv, sv])

pdB_W_I \(\partial_\mathbb{B} W_{pq}^\mathrm{MP2}\)

回顾到

\[\begin{split}\begin{align} W_{ij}^\mathrm{PT2} [\mathrm{I}] &= - 2 T_{ik}^{ab} (ja|kb) \\ W_{ab}^\mathrm{PT2} [\mathrm{I}] &= - 2 T_{ij}^{ac} (ib|jc) \\ W_{ai}^\mathrm{PT2} [\mathrm{I}] &= - 4 T_{jk}^{ab} (ij|bk) \\ W_{ia}^\mathrm{PT2} [\mathrm{I}] &= 0 \end{align}\end{split}\]

我们有

\[\begin{split}\begin{align} \partial_\mathbb{B} W_{ij}^\mathrm{PT2} [\mathrm{I}] &= - 2 \partial_\mathbb{B} T_{ik}^{ab} \cdot (ja|kb) - 2 T_{ik}^{ab} \cdot \partial_\mathbb{B} (ja|kb) \\ \partial_\mathbb{B} W_{ab}^\mathrm{PT2} [\mathrm{I}] &= - 2 \partial_\mathbb{B} T_{ij}^{ac} \cdot (ib|jc) - 2 T_{ij}^{ac} \cdot \partial_\mathbb{B} (ib|jc) \\ \partial_\mathbb{B} W_{ai}^\mathrm{PT2} [\mathrm{I}] &= - 4 \partial_\mathbb{B} T_{jk}^{ab} \cdot (ij|bk) - 4 T_{jk}^{ab} \cdot \partial_\mathbb{B} (ij|bk) \\ \partial_\mathbb{B} W_{ia}^\mathrm{PT2} [\mathrm{I}] &= 0 \end{align}\end{split}\]
[41]:
pdB_W_I = np.zeros((natm * 3, nmo, nmo))
pdB_W_I[:, so, so] -= 2 * np.einsum("Aiakb, jakb -> Aij", pdA_T_iajb, eri0_mo[so, sv, so, sv])
pdB_W_I[:, sv, sv] -= 2 * np.einsum("Aiajc, ibjc -> Aab", pdA_T_iajb, eri0_mo[so, sv, so, sv])
pdB_W_I[:, sv, so] -= 4 * np.einsum("Ajakb, ijbk -> Aai", pdA_T_iajb, eri0_mo[so, so, sv, so])
pdB_W_I[:, so, so] -= 2 * np.einsum("iakb, Ajakb -> Aij", T_iajb, pdA_eri0_mo[:, so, sv, so, sv])
pdB_W_I[:, sv, sv] -= 2 * np.einsum("iajc, Aibjc -> Aab", T_iajb, pdA_eri0_mo[:, so, sv, so, sv])
pdB_W_I[:, sv, so] -= 4 * np.einsum("jakb, Aijbk -> Aai", T_iajb, pdA_eri0_mo[:, so, so, sv, so])
[42]:
nd_W_I = NumericDiff(gradn, lambda gradh: gradh.W_I).derivative
plot_diff(pdB_W_I, nd_W_I)
[43]:
np.allclose(pdB_W_I, hessh.pdB_W_I)  # pyxdh approach
[43]:
True

\(\partial_\mathbb{B} D_{ai}^\mathrm{MP2}\) 的处理

pdB_D_r \(\partial_\mathbb{B} D_{ai}^\mathrm{MP2}\)

首先,我们回顾到

\[- (\varepsilon_a - \varepsilon_i) D_{ai}^\mathrm{MP2} - A_{ai, bj} D_{bj}^\mathrm{MP2} = L_{ai}^\mathrm{MP2}\]

我们也会将上式重新写作

\[F_{ki} D_{ak}^\mathrm{MP2} - F_{ca} D_{ci}^\mathrm{MP2} - A_{ai, bj} D_{bj}^\mathrm{MP2} = L_{ai}^\mathrm{MP2}\]

我们对上述等式两边求导,得到

\[\begin{split}\begin{align} \partial_\mathbb{B} L_{ai}^\mathrm{MP2} &= - (\varepsilon_a - \varepsilon_i) \cdot \partial_\mathbb{B} D_{ai}^\mathrm{MP2} - A_{ai, bj} \cdot \partial_\mathbb{B} D_{bj}^\mathrm{MP2} \\ &\quad + \partial_\mathbb{B} F_{ki} \cdot D_{ak}^\mathrm{MP2} - \partial_\mathbb{B} F_{ca} \cdot D_{ci}^\mathrm{MP2} - \partial_\mathbb{B} A_{ai, bj} \cdot D_{bj}^\mathrm{PT2} \end{align}\end{split}\]

我们重新整理上式,就可以得到一个新的 CP-HF 方程。同时留意到

\[\begin{split}\begin{align} L_{ai} &= A_{ai, kl} D_{kl}^\mathrm{MP2} + A_{ai, bc} D_{bc}^\mathrm{MP2} - 4 T_{jk}^{ab} (ij|bk) + 4 T_{ij}^{bc} (ab|jc) \\ &= A_{ai, pq} D_{pq}^\text{MP2, oo-vv} - 4 T_{jk}^{ab} (ij|bk) + 4 T_{ij}^{bc} (ab|jc) \end{align}\end{split}\]

我们能得到

\[\begin{align} \partial_\mathbb{B} L_{ai} &= \partial_\mathbb{B} A_{ai, pq} \cdot D_{pq}^\text{MP2, oo-vv} + A_{ai, pq} \cdot \partial_\mathbb{B} D_{pq}^\text{MP2, oo-vv} - 4 \partial_\mathbb{B} T_{jk}^{ab} \cdot (ij|bk) - 4 T_{jk}^{ab} \cdot \partial_\mathbb{B} (ij|bk) + 4 \partial_\mathbb{B} T_{ij}^{bc} \cdot (ab|jc) + 4 T_{ij}^{bc} \cdot \partial_\mathbb{B} (ab|jc) \end{align}\]

我们定义 RHS_B \(\mathtt{RHS}^\mathbb{B}\)

\[\begin{split}\begin{align} \mathtt{RHS}^\mathbb{B} &= A_{ai, pq}^\mathbb{B} \cdot D_{pq}^\text{MP2} + A_{mi, pq} \cdot D_{pq}^\text{MP2} U_{ma}^\mathbb{B} + A_{am, pq} \cdot D_{pq}^\text{MP2} U_{mi}^\mathbb{B} + A_{ai, mq} \cdot D_{pq}^\text{MP2} U_{mp}^\mathbb{B} + A_{ai, pm} \cdot D_{pq}^\text{MP2} U_{mq}^\mathbb{B} \\ &\quad + A_{ai, pq} \cdot \partial_\mathbb{B} D_{pq}^\text{MP2, oo-vv} \\ &\quad - 4 \partial_\mathbb{B} T_{jk}^{ab} \cdot (ij|bk) - 4 T_{jk}^{ab} \cdot \partial_\mathbb{B} (ij|bk) + 4 \partial_\mathbb{B} T_{ij}^{bc} \cdot (ab|jc) + 4 T_{ij}^{bc} \cdot \partial_\mathbb{B} (ab|jc) \\ &\quad - \partial_\mathbb{B} F_{ki} \cdot D_{ak}^\mathrm{MP2} + \partial_\mathbb{B} F_{ca} \cdot D_{ci}^\mathrm{MP2} \end{align}\end{split}\]

上式的第一行是通过 \(\partial_\mathbb{B} A_{ai, pq} \cdot D_{pq}^\mathrm{MP2}\) 导出的。

[44]:
RHS_B = (
    # Line 1
    + Ax1_Core(sv, so, sa, sa)(D_r)
    + np.einsum("mi, Bma -> Bai", Ax0_Core(sa, so, sa, sa)(D_r), U_1[:, :, sv])
    + np.einsum("am, Bmi -> Bai", Ax0_Core(sv, sa, sa, sa)(D_r), U_1[:, :, so])
    + Ax0_Core(sv, so, sa, sa)(np.einsum("pq, Bmp -> Bmq", D_r, U_1))
    + Ax0_Core(sv, so, sa, sa)(np.einsum("pq, Bmq -> Bpm", D_r, U_1))
    # Line 2
    + Ax0_Core(sv, so, sa, sa)(pdB_D_r_oovv)
    # Line 3
    - 4 * np.einsum("Bjakb, ijbk -> Bai", pdA_T_iajb, eri0_mo[so, so, sv, so])
    - 4 * np.einsum("jakb, Bijbk -> Bai", T_iajb, pdA_eri0_mo[:, so, so, sv, so])
    + 4 * np.einsum("Bibjc, abjc -> Bai", pdA_T_iajb, eri0_mo[sv, sv, so, sv])
    + 4 * np.einsum("ibjc, Babjc -> Bai", T_iajb, pdA_eri0_mo[:, sv, sv, so, sv])
    # Line 4
    - np.einsum("Bki, ak -> Bai", pdA_F_0_mo[:, so, so], D_r[sv, so])
    + np.einsum("Bca, ci -> Bai", pdA_F_0_mo[:, sv, sv], D_r[sv, so])
)
RHS_B.shape
[44]:
(12, 13, 9)
[45]:
np.allclose(RHS_B, hessh.RHS_B)  # pyxdh approach
[45]:
True

随后解 CP-HF 方程得到 pdB_D_r_vo \(\partial_\mathbb{B} D_{ai}^\mathrm{MP2}\)

\[- (\varepsilon_a - \varepsilon_i) \cdot \partial_\mathbb{B} D_{ai}^\mathrm{MP2} - A_{ai, bj} \cdot \partial_\mathbb{B} D_{bj}^\mathrm{MP2} = \mathtt{RHS}^\mathbb{B}\]
[46]:
pdB_D_r_vo = cphf.solve(
    Ax0_Core(sv, so, sv, so),
    e,
    gradh.scf_eng.mo_occ,
    RHS_B,
    max_cycle=100,
    tol=1e-12,
)[0]
pdB_D_r_vo.shape
[46]:
(12, 13, 9)
[47]:
plot_diff(pdB_D_r_vo, nd_D_r[:, sv, so])

至此,我们已经将所有的弛豫密度矩阵导数求完了。pdB_D_r \(\partial_\mathbb{B} D_{pq}^\mathrm{PT2}\) 表示为

[48]:
pdB_D_r = pdB_D_r_oovv.copy()
pdB_D_r[:, sv, so] += pdB_D_r_vo
pdB_D_r.shape
[48]:
(12, 22, 22)
[49]:
plot_diff(pdB_D_r, nd_D_r)

逆向 Z-Vector 方法

这是下述文章所提及的方法:

  • Cammi

    这篇文档讨论 MP2 的二阶梯度实现。

    Cammi, R.; Mennucci, B.; Pomelli, C.; Cappelli, C.; Corni, S.; Frediani, L.; Trucks, G. W. & Frisch, M. J.

    Second-order Møller–Plesset second derivatives for the polarizable continuum model: theoretical bases and application to solvent effects in electrophilic bromination of ethylene

    Theor. Chem. Acc. 2004, 111, 66-77

    doi: 10.1007/s00214-003-0521-8

我们注意到,我们未必真的要求取 \(\partial_\mathbb{B} D_{ai}^\mathrm{MP2}\)。我们的计算目标是 \(\partial_\mathbb{B} D_{pq}^\mathrm{MP2} \cdot B_{pq}^\mathbb{A}\)\(p, q\) 的角标求和,如果不严格地用矩阵的语言描述,就是 \(\mathrm{tr} \big( (\partial_\mathbb{B} \mathbf{D}^\mathrm{MP2})^\dagger \mathbf{B}^\mathbb{A} \big)\)

但我们知道,如果我们将 CP-HF 方程写为 \(\mathbf{A}' \partial_\mathbb{B} \mathbf{D}^\mathrm{MP2} = \mathtt{RHS}^\mathbb{B}\),那么 \(\partial_\mathbb{B} \mathbf{D}^\mathrm{MP2} = (\mathbf{A}')^{-1} \mathtt{RHS}^\mathbb{B}\)。因此,

\[\mathrm{tr} \big( (\partial_\mathbb{B} \mathbf{D}^\mathrm{MP2})^\dagger \mathbf{B}^\mathbb{A} \big) = \mathrm{tr} \big( (\mathtt{RHS}^\mathbb{B})^\dagger (\mathbf{A}')^{-1} \mathbf{B}^\mathbb{A} \big) = \mathrm{tr} \big( (\mathtt{RHS}^\mathbb{B})^\dagger \mathbf{U}^\mathbb{A} \big)\]

我们指出,上述过程的推导与 Z-Vector 方法的推导是相同的,但只是将原先与 Z 矩阵想等价的 \(\partial_\mathbb{B} \mathbf{D}^\mathrm{MP2}\) 转化为 \(\mathbf{U}^\mathbb{A}\) 的计算。而 Z-Vector 方法本来是要避免 U 矩阵的计算的,因此称为逆向 Z-Vector 方法。

MP2 相关能二阶核坐标梯度:非安全方法

\[\begin{split}\begin{align} \partial_\mathbb{A} \partial_\mathbb{B} E_\mathrm{MP2, c} &= \partial_\mathbb{B} D_{pq}^\mathrm{MP2, oo-vv} B_{pq}^\mathbb{A} + \mathtt{RHS}_{ai}^\mathbb{B} U_{ai}^\mathbb{A} + D_{pq}^\mathrm{MP2} \cdot \partial_\mathbb{B} B_{pq}^\mathbb{A} \\ &\quad + \partial_\mathbb{B} W_{pq}^\mathrm{MP2} [\mathrm{I}] \cdot S_{pq}^\mathbb{A} + W_{pq}^\mathrm{MP2} [\mathrm{I}] \cdot \partial_\mathbb{B} S_{pq}^\mathbb{A} \\ &\quad + 2 \partial_\mathbb{B} T_{ij}^{ab} \cdot (ia | jb)^\mathbb{A} + 2 T_{ij}^{ab} \cdot \partial_\mathbb{B} (ia | jb)^\mathbb{A} \end{align}\end{split}\]
[50]:
E_2_MP2_contrib = (
    + np.einsum("Bpq, Apq -> AB", pdB_D_r_oovv, B_1)
    + np.einsum("Bai, Aai -> AB", RHS_B, U_1_vo)
    + np.einsum("pq, ABpq -> AB", D_r, pdB_B_A)
    + np.einsum("Bpq, Apq -> AB", pdB_W_I, S_1_mo)
    + np.einsum("pq, ABpq -> AB", W_I, pdB_S_A_mo)
    + 2 * np.einsum("Biajb, Aiajb -> AB", pdA_T_iajb, eri1_mo[:, so, sv, so, sv])
    + 2 * np.einsum("iajb, ABiajb -> AB", T_iajb, pdB_pdpA_eri0_iajb)
)
[51]:
np.allclose(E_2_MP2_contrib, hessh._get_E_2_MP2_Contrib())
[51]:
True

“安全的”MP2 Hessian 与“轨道旋转”效应的消除

我们在上一节中,使用了 \(U_{pq}^\mathbb{A}\) 来表达 MP2 的 Hessian。但我们都知道,\(U_{ij}^\mathbb{A}\)\(U_{ab}^\mathbb{A}\) 由于存在奇点,因此应当用“轨道旋转”过后的 \(\mathscr{U}_{ij}^\mathbb{A}\)\(\mathscr{U}_{ab}^\mathbb{A}\) 替代;这个过程中,很容易出现问题,也难以用直接对一阶导数作数值导数的方法,来验证二阶导数采用“轨道旋转”过后的 U 矩阵的正确性。这一节,我们就省视这种更变的合理性,并深入地理解 MP2 的 Hessian 推导。

这一节并不作公式的推演。尽管这一节的内容有它的意义,但读者不一定有必要完整地了解整个过程。读者只需要知道,我们在这篇文档中,验证了“轨道旋转”在二阶梯度求取中的可行性,以及在实际应用过程中,注意到普通 \(U_{pq}^\mathbb{A}\)\(\mathscr{U}_{pq}^\mathbb{A}\) 的区别就可以了。

未完成文档

这份文档由于需要太过于长的公式推导过程,因此关于弛豫密度的“轨道旋转”问题就没有继续进行下去。

关于“轨道旋转”问题,可能的另一种讨论思路是使用非正则 RHF 参考态的 Λ-CCSD 方程退化到 MP2 的情形。

准备工作

[1]:
%matplotlib notebook

from pyscf import gto, scf, dft, lib, grad, hessian
from pyscf.scf import cphf
import numpy as np
from functools import partial
import warnings
from matplotlib import pyplot as plt
from pyxdh.Utilities import NucCoordDerivGenerator, DipoleDerivGenerator, NumericDiff
from pyxdh.DerivOnce import GradMP2
from pyxdh.DerivTwice import HessMP2, HessSCF

np.einsum = partial(np.einsum, optimize=["greedy", 1024 ** 3 * 2 / 8])
np.allclose = partial(np.allclose, atol=1e-6, rtol=1e-4)
np.set_printoptions(5, linewidth=150, suppress=True)
warnings.filterwarnings("ignore")
[2]:
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()
[2]:
<pyscf.gto.mole.Mole at 0x7fb82077e520>

我们将轨道“未经旋转”的计算实例用变量 gradh_nrhessh_nr 表示;而“经过旋转”的计算实例用 gradhhessh 表示。

[3]:
gradh_nr = GradMP2({"scf_eng": scf.RHF(mol), "cphf_tol": 1e-12, "rotation": False})
hessh_nr = HessMP2({"deriv_A": gradh_nr, "deriv_B": gradh_nr, "rotation": False})
[4]:
gradh = GradMP2({"scf_eng": scf.RHF(mol), "cphf_tol": 1e-12})
hessh = HessMP2({"deriv_A": gradh, "deriv_B": gradh})

一些与“轨道旋转”无关的变量定义如下:

[5]:
nmo, nao, natm, nocc, nvir = gradh.nao, gradh.nao, gradh.natm, gradh.nocc, gradh.nvir
so, sv, sa = gradh.so, gradh.sv, gradh.sa
mol_slice = gradh.mol_slice
C, Co, Cv, e, eo, ev, D = gradh.C, gradh.Co, gradh.Cv, gradh.e, gradh.eo, gradh.ev, gradh.D
H_0_ao, S_0_ao, eri0_ao, F_0_ao = gradh.H_0_ao, gradh.S_0_ao, gradh.eri0_ao, gradh.F_0_ao
H_0_mo, S_0_mo, eri0_mo, F_0_mo = gradh.H_0_mo, gradh.S_0_mo, gradh.eri0_mo, gradh.F_0_mo
H_1_ao, S_1_ao, eri1_ao, F_1_ao = gradh.H_1_ao, gradh.S_1_ao, gradh.eri1_ao, gradh.F_1_ao
H_1_mo, S_1_mo, eri1_mo, F_1_mo = gradh.H_1_mo, gradh.S_1_mo, gradh.eri1_mo, gradh.F_1_mo
Ax0_Core, B_1, U_1_vo = gradh.Ax0_Core, gradh.B_1, gradh.U_1_vo
H_2_ao, S_2_ao, eri2_ao, F_2_ao = hessh.H_2_ao, hessh.S_2_ao, hessh.eri2_ao, hessh.F_2_ao
H_2_mo, S_2_mo, eri2_mo, F_2_mo = hessh.H_2_mo, hessh.S_2_mo, hessh.eri2_mo, hessh.F_2_mo
Ax1_Core = gradh.Ax1_Core
[6]:
D_iajb, t_iajb, T_iajb, D_r, W_I, L =  gradh.D_iajb, gradh.t_iajb, gradh.T_iajb, gradh.D_r, gradh.W_I, gradh.L

而一阶 U 矩阵则如以前的文档一样,分为“未经旋转”的 U_1 \(U_{pq}^\mathbb{A}\) 与“经过旋转”的 U_1_nr \(\mathscr{U}_{pq}^\mathbb{A}\)

[7]:
U_1, U_1_nr = gradh.U_1, gradh_nr.U_1

最后,我们能发现,使用“经过旋转”与“未经旋转”的 U 矩阵下,MP2 Hessian 矩阵的结果是完全一致的:

[8]:
np.allclose(hessh.E_2, hessh_nr.E_2)
[8]:
True

MP2 Hessian 相关能贡献回顾

上一篇文档,我们提到,

\[\begin{split}\begin{align} \partial_\mathbb{A} \partial_\mathbb{B} E_\mathrm{MP2, c} &= \partial_\mathbb{B} D_{pq}^\mathrm{MP2, oo-vv} B_{pq}^\mathbb{A} + \mathtt{RHS}_{ai}^\mathbb{B} U_{ai}^\mathbb{A} + D_{pq}^\mathrm{MP2} \cdot \partial_\mathbb{B} B_{pq}^\mathbb{A} \\ &\quad + \partial_\mathbb{B} W_{pq}^\mathrm{MP2} [\mathrm{I}] \cdot S_{pq}^\mathbb{A} + W_{pq}^\mathrm{MP2} [\mathrm{I}] \cdot \partial_\mathbb{B} S_{pq}^\mathbb{A} \\ &\quad + 2 \partial_\mathbb{B} T_{ij}^{ab} \cdot (ia | jb)^\mathbb{A} + 2 T_{ij}^{ab} \cdot \partial_\mathbb{B} (ia | jb)^\mathbb{A} \end{align}\end{split}\]
[9]:
E_2_MP2_Contrib = (
    # D_r * B
    + np.einsum("Bpq, Apq -> AB", hessh.pdB_D_r_oovv, B_1)
    + np.einsum("Bai, Aai -> AB", hessh.RHS_B, U_1[:, sv, so])
    + np.einsum("pq, ABpq -> AB", D_r, hessh.pdB_B_A)
    # W_I * S
    + np.einsum("Bpq, Apq -> AB", hessh.pdB_W_I, S_1_mo)
    + np.einsum("pq, ABpq -> AB", W_I, hessh.pdB_S_A_mo)
    # T * g
    + 2 * np.einsum("Biajb, Aiajb -> AB", gradh.pdA_T_iajb, eri1_mo[:, so, sv, so, sv])
    + 2 * np.einsum("iajb, ABiajb -> AB", T_iajb, hessh.pdB_pdpA_eri0_iajb)
)
np.allclose(E_2_MP2_Contrib, hessh._get_E_2_MP2_Contrib())
[9]:
True

我们会发现,上述的计算使用到的是“旋转”后的 \(\mathscr{U}_{pq}^\mathbb{A}\) 矩阵,但仍然能达到与“未旋转”的 \(U_{pq}^\mathbb{A}\) 一样的效果。我们说,下述程序可以验证之:

[10]:
np.allclose(hessh._get_E_2_MP2_Contrib(), hessh_nr._get_E_2_MP2_Contrib())
[10]:
True

除此之外,每一个大分项贡献都是等价的。譬如说弛豫密度部分的贡献而言,

\[\partial_\mathbb{A} \partial_\mathbb{B} E_\mathrm{MP2, c} \leftarrow \partial_\mathbb{B} D_{pq}^\mathrm{MP2, oo-vv} B_{pq}^\mathbb{A} + \mathtt{RHS}_{ai}^\mathbb{B} U_{ai}^\mathbb{A} + D_{pq}^\mathrm{MP2} \cdot \partial_\mathbb{B} B_{pq}^\mathbb{A}\]
[11]:
np.allclose(
    # D_r * B, rotation
    + np.einsum("Bpq, Apq -> AB", hessh.pdB_D_r_oovv, B_1)
    + np.einsum("Bai, Aai -> AB", hessh.RHS_B, U_1[:, sv, so])
    + np.einsum("pq, ABpq -> AB", D_r, hessh.pdB_B_A),
    # D_r * B, no rotation
    + np.einsum("Bpq, Apq -> AB", hessh_nr.pdB_D_r_oovv, B_1)
    + np.einsum("Bai, Aai -> AB", hessh_nr.RHS_B, U_1_nr[:, sv, so])
    + np.einsum("pq, ABpq -> AB", D_r, hessh_nr.pdB_B_A)
)
[11]:
True

上述的结论对于其它的两项 (\(W_{pq}^\mathrm{MP2} [\mathrm{I}]\) 贡献项与双电子密度贡献项)。但具体落到小分项 \(\partial_\mathbb{B} D_{pq}^\mathrm{MP2, oo-vv} B_{pq}^\mathbb{A}\) 而言,则就有差异了:

[12]:
np.allclose(
    # D_r * B, rotation
    + np.einsum("Bpq, Apq -> AB", hessh.pdB_D_r_oovv, B_1),
    # D_r * B, no rotation
    + np.einsum("Bpq, Apq -> AB", hessh_nr.pdB_D_r_oovv, B_1)
)
[12]:
False

因此,若要验证“旋转”后的 U 矩阵确实能给出正确的二阶梯度贡献大小,仍然需要作不少细致的分析。我们后文就分别对三项贡献作推敲。

双电子密度项“旋转不变”性质

我们先从最容易讨论的一项开始。

\[\begin{split}\begin{align} \partial_\mathbb{A} \partial_\mathbb{B} E_\mathrm{MP2, c} &\leftarrow 2 \partial_\mathbb{B} T_{ij}^{ab} \cdot (ia | jb)^\mathbb{A} + 2 T_{ij}^{ab} \cdot \partial_\mathbb{B} (ia | jb)^\mathbb{A} \\ &= \partial_\mathbb{B} t_{ij}^{ab} \cdot \big( 2 (ia | jb)^\mathbb{A} - (ib | ja)^\mathbb{A} \big) + 2 T_{ij}^{ab} \cdot \partial_\mathbb{B} (ia | jb)^\mathbb{A} \end{align}\end{split}\]

这里开始的文档可能会一改以前文档的风格。以前的文档都是从公式出发编写程序,并回头验证公式的正确性;但在这里,我们会通过正确的程序,反推出公式与我们所需要的性质。

我们首先拆分 \(\partial_\mathbb{B} t_{ij}^{ab}\)\(\partial_\mathbb{B} (ia | jb)^\mathbb{A}\),其中 \(\partial_\mathbb{B} t_{ij}^{ab}\) 仅仅展开到 \(\partial_\mathbb{B} F_{pq}\) 的程度,因为再进行下一级展开,会使得代码太过冗杂。

[13]:
np.allclose(
    # + 2 * np.einsum("Biajb, Aiajb -> AB", gradh.pdA_t_iajb, 2 * gradh.eri1_mo[:, so, sv, so, sv] - gradh.eri1_mo[:, so, sv, so, sv].swapaxes(-1, -3))
    + 2 * np.einsum("Biajb, iajb, Aiajb -> AB", eri1_mo[:, so, sv, so, sv], 1 / D_iajb, 2 * gradh.eri1_mo[:, so, sv, so, sv] - gradh.eri1_mo[:, so, sv, so, sv].swapaxes(-1, -3))
    + 2 * np.einsum("Bmi, majb, iajb, Aiajb -> AB", U_1[:, sa, so], eri0_mo[sa, sv, so, sv], 1 / D_iajb, 2 * gradh.eri1_mo[:, so, sv, so, sv] - gradh.eri1_mo[:, so, sv, so, sv].swapaxes(-1, -3))
    + 2 * np.einsum("Bmj, iamb, iajb, Aiajb -> AB", U_1[:, sa, so], eri0_mo[so, sv, sa, sv], 1 / D_iajb, 2 * gradh.eri1_mo[:, so, sv, so, sv] - gradh.eri1_mo[:, so, sv, so, sv].swapaxes(-1, -3))
    + 2 * np.einsum("Bma, imjb, iajb, Aiajb -> AB", U_1[:, sa, sv], eri0_mo[so, sa, so, sv], 1 / D_iajb, 2 * gradh.eri1_mo[:, so, sv, so, sv] - gradh.eri1_mo[:, so, sv, so, sv].swapaxes(-1, -3))
    + 2 * np.einsum("Bmb, iajm, iajb, Aiajb -> AB", U_1[:, sa, sv], eri0_mo[so, sv, so, sa], 1 / D_iajb, 2 * gradh.eri1_mo[:, so, sv, so, sv] - gradh.eri1_mo[:, so, sv, so, sv].swapaxes(-1, -3))
    - 2 * np.einsum("Bki, kajb, iajb, Aiajb -> AB", gradh.pdA_F_0_mo[:, so, so], t_iajb, 1 / D_iajb, 2 * gradh.eri1_mo[:, so, sv, so, sv] - gradh.eri1_mo[:, so, sv, so, sv].swapaxes(-1, -3))
    - 2 * np.einsum("Bkj, iakb, iajb, Aiajb -> AB", gradh.pdA_F_0_mo[:, so, so], t_iajb, 1 / D_iajb, 2 * gradh.eri1_mo[:, so, sv, so, sv] - gradh.eri1_mo[:, so, sv, so, sv].swapaxes(-1, -3))
    + 2 * np.einsum("Bca, icjb, iajb, Aiajb -> AB", gradh.pdA_F_0_mo[:, sv, sv], t_iajb, 1 / D_iajb, 2 * gradh.eri1_mo[:, so, sv, so, sv] - gradh.eri1_mo[:, so, sv, so, sv].swapaxes(-1, -3))
    + 2 * np.einsum("Bcb, iajc, iajb, Aiajb -> AB", gradh.pdA_F_0_mo[:, sv, sv], t_iajb, 1 / D_iajb, 2 * gradh.eri1_mo[:, so, sv, so, sv] - gradh.eri1_mo[:, so, sv, so, sv].swapaxes(-1, -3))
    # + 2 * np.einsum("iajb, ABiajb -> AB", hessh.T_iajb, hessh.pdB_pdpA_eri0_iajb)
    + 2 * np.einsum("iajb, ABiajb -> AB", T_iajb, eri2_mo[:, :, so, sv, so, sv])
    + 2 * np.einsum("iajb, Bmi, Amajb -> AB", T_iajb, U_1[:, sa, so], eri1_mo[:, sa, sv, so, sv])
    + 2 * np.einsum("iajb, Bmj, Aiamb -> AB", T_iajb, U_1[:, sa, so], eri1_mo[:, so, sv, sa, sv])
    + 2 * np.einsum("iajb, Bma, Aimjb -> AB", T_iajb, U_1[:, sa, sv], eri1_mo[:, so, sa, so, sv])
    + 2 * np.einsum("iajb, Bmb, Aiajm -> AB", T_iajb, U_1[:, sa, sv], eri1_mo[:, so, sv, so, sa]),
    # True value
    + 2 * np.einsum("Biajb, Aiajb -> AB", gradh_nr.pdA_T_iajb, eri1_mo[:, so, sv, so, sv])
    + 2 * np.einsum("iajb, ABiajb -> AB", T_iajb, hessh_nr.pdB_pdpA_eri0_iajb)
)
[13]:
True

我们将上式中包含 \(\mathscr{U}_{mi}^\mathbb{B}\) 的项,与根据“旋转”后的 U 矩阵所作的导数 \(\partial_\mathbb{B} F_{ki}\) 的项提出,我们验证一下,这些项在使用“旋转”与“未旋转”的 U 矩阵下,结果是否相同。

[14]:
np.allclose(
    # No rotation code counterpart
    + 2 * np.einsum("Bmi, majb, iajb, Aiajb -> AB", U_1_nr[:, sa, so], eri0_mo[sa, sv, so, sv], 1 / D_iajb, 2 * gradh.eri1_mo[:, so, sv, so, sv] - gradh.eri1_mo[:, so, sv, so, sv].swapaxes(-1, -3))
    - 2 * np.einsum("Bki, kajb, iajb, Aiajb -> AB", gradh_nr.pdA_F_0_mo[:, so, so], t_iajb, 1 / D_iajb, 2 * gradh.eri1_mo[:, so, sv, so, sv] - gradh.eri1_mo[:, so, sv, so, sv].swapaxes(-1, -3))
    + 2 * np.einsum("iajb, Bmi, Amajb -> AB", T_iajb, U_1_nr[:, sa, so], eri1_mo[:, sa, sv, so, sv]),
    # True value: last code block line 4, 8, 14
    + 2 * np.einsum("Bmi, majb, iajb, Aiajb -> AB", U_1[:, sa, so], eri0_mo[sa, sv, so, sv], 1 / D_iajb, 2 * gradh.eri1_mo[:, so, sv, so, sv] - gradh.eri1_mo[:, so, sv, so, sv].swapaxes(-1, -3))
    - 2 * np.einsum("Bki, kajb, iajb, Aiajb -> AB", gradh.pdA_F_0_mo[:, so, so], t_iajb, 1 / D_iajb, 2 * gradh.eri1_mo[:, so, sv, so, sv] - gradh.eri1_mo[:, so, sv, so, sv].swapaxes(-1, -3))
    + 2 * np.einsum("iajb, Bmi, Amajb -> AB", T_iajb, U_1[:, sa, so], eri1_mo[:, sa, sv, so, sv])
)
[14]:
True

这就意味着我们可以将所有包含 \(i\) 角标的 U 矩阵导数是否正确的问题单独提出。关于其它的角标 (即 \(a, j, b\)),也可以作同样的处理;这里就不再展开。剩下的项都只包含 Skeleton 导数,因此不可能因为 U 矩阵旋转与否而产生变化。因此,我们只要讨论清楚上面代码为何给出 True 的结果,就等于完成了关于 U 矩阵旋转性质的推敲了。

我们再对 \(\partial_\mathbb{B} F_{ki}\) 作展开。

[15]:
np.allclose(
    + 2 * np.einsum("Bmi, majb, iajb, Aiajb -> AB", U_1[:, sa, so], eri0_mo[sa, sv, so, sv], 1 / D_iajb, 2 * gradh.eri1_mo[:, so, sv, so, sv] - gradh.eri1_mo[:, so, sv, so, sv].swapaxes(-1, -3))
    - 2 * np.einsum("Bki, kajb, iajb, Aiajb -> AB", F_1_mo[:, so, so], t_iajb, 1 / D_iajb, 2 * gradh.eri1_mo[:, so, sv, so, sv] - gradh.eri1_mo[:, so, sv, so, sv].swapaxes(-1, -3))
    - 2 * np.einsum("Bmk, mi, kajb, iajb, Aiajb -> AB", U_1[:, sa, so], F_0_mo[sa, so], t_iajb, 1 / D_iajb, 2 * gradh.eri1_mo[:, so, sv, so, sv] - gradh.eri1_mo[:, so, sv, so, sv].swapaxes(-1, -3))
    - 2 * np.einsum("Bmi, mk, kajb, iajb, Aiajb -> AB", U_1[:, sa, so], F_0_mo[sa, so], t_iajb, 1 / D_iajb, 2 * gradh.eri1_mo[:, so, sv, so, sv] - gradh.eri1_mo[:, so, sv, so, sv].swapaxes(-1, -3))
    - 2 * np.einsum("Bki, kajb, iajb, Aiajb -> AB", Ax0_Core(so, so, sa, so)(U_1[:, sa, so]), t_iajb, 1 / D_iajb, 2 * gradh.eri1_mo[:, so, sv, so, sv] - gradh.eri1_mo[:, so, sv, so, sv].swapaxes(-1, -3))
    + 2 * np.einsum("iajb, Bmi, Amajb -> AB", T_iajb, U_1[:, sa, so], eri1_mo[:, sa, sv, so, sv]),
    # True value: last code block line 4, 8, 14
    + 2 * np.einsum("Bmi, majb, iajb, Aiajb -> AB", U_1[:, sa, so], eri0_mo[sa, sv, so, sv], 1 / D_iajb, 2 * gradh.eri1_mo[:, so, sv, so, sv] - gradh.eri1_mo[:, so, sv, so, sv].swapaxes(-1, -3))
    - 2 * np.einsum("Bki, kajb, iajb, Aiajb -> AB", gradh.pdA_F_0_mo[:, so, so], t_iajb, 1 / D_iajb, 2 * gradh.eri1_mo[:, so, sv, so, sv] - gradh.eri1_mo[:, so, sv, so, sv].swapaxes(-1, -3))
    + 2 * np.einsum("iajb, Bmi, Amajb -> AB", T_iajb, U_1[:, sa, so], eri1_mo[:, sa, sv, so, sv])
)
[15]:
True

我们这里指出,\(A_{ki, ml} U_{ml}^\mathbb{B} = A_{ki, ml} \mathscr{U}_{ml}^\mathbb{B}\),若等式两边对 \(m, l\) 角标求和。

[16]:
np.allclose(
    Ax0_Core(so, so, sa, so)(U_1[:, sa, so]),
    Ax0_Core(so, so, sa, so)(U_1_nr[:, sa, so])
)
[16]:
True

于是,我们可以将代码拆分为使用了 \(\mathscr{U}_{ki}^\mathbb{B}\),与 \(\mathscr{U}_{ci}^\mathbb{B}\)\(U_{ml}^\mathbb{B}\) 的两类:

[17]:
np.allclose(
    # unsafe if use not-rotated U
    + 2 * np.einsum("Bki, kajb, iajb, Aiajb -> AB", U_1[:, so, so], eri0_mo[so, sv, so, sv], 1 / D_iajb, 2 * gradh.eri1_mo[:, so, sv, so, sv] - gradh.eri1_mo[:, so, sv, so, sv].swapaxes(-1, -3))
    - 2 * np.einsum("Bik, i, kajb, iajb, Aiajb -> AB", U_1[:, so, so], eo, t_iajb, 1 / D_iajb, 2 * gradh.eri1_mo[:, so, sv, so, sv] - gradh.eri1_mo[:, so, sv, so, sv].swapaxes(-1, -3))
    - 2 * np.einsum("Bki, k, kajb, iajb, Aiajb -> AB", U_1[:, so, so], eo, t_iajb, 1 / D_iajb, 2 * gradh.eri1_mo[:, so, sv, so, sv] - gradh.eri1_mo[:, so, sv, so, sv].swapaxes(-1, -3))
    + 2 * np.einsum("Bki, iajb, Akajb -> AB", U_1[:, so, so], T_iajb, eri1_mo[:, so, sv, so, sv])
    # safe if use not-rotated U
    - 2 * np.einsum("Bki, kajb, iajb, Aiajb -> AB", F_1_mo[:, so, so], t_iajb, 1 / D_iajb, 2 * gradh.eri1_mo[:, so, sv, so, sv] - gradh.eri1_mo[:, so, sv, so, sv].swapaxes(-1, -3))
    + 2 * np.einsum("Bci, cajb, iajb, Aiajb -> AB", U_1[:, sv, so], eri0_mo[sv, sv, so, sv], 1 / D_iajb, 2 * gradh.eri1_mo[:, so, sv, so, sv] - gradh.eri1_mo[:, so, sv, so, sv].swapaxes(-1, -3))
    - 2 * np.einsum("Bki, kajb, iajb, Aiajb -> AB", Ax0_Core(so, so, sa, so)(U_1[:, sa, so]), t_iajb, 1 / D_iajb, 2 * gradh.eri1_mo[:, so, sv, so, sv] - gradh.eri1_mo[:, so, sv, so, sv].swapaxes(-1, -3))
    + 2 * np.einsum("iajb, Bci, Acajb -> AB", T_iajb, U_1[:, sv, so], eri1_mo[:, sv, sv, so, sv])
    ,
    # True value: last code block line 4, 8, 14
    + 2 * np.einsum("Bmi, majb, iajb, Aiajb -> AB", U_1[:, sa, so], eri0_mo[sa, sv, so, sv], 1 / D_iajb, 2 * gradh.eri1_mo[:, so, sv, so, sv] - gradh.eri1_mo[:, so, sv, so, sv].swapaxes(-1, -3))
    - 2 * np.einsum("Bki, kajb, iajb, Aiajb -> AB", gradh.pdA_F_0_mo[:, so, so], t_iajb, 1 / D_iajb, 2 * gradh.eri1_mo[:, so, sv, so, sv] - gradh.eri1_mo[:, so, sv, so, sv].swapaxes(-1, -3))
    + 2 * np.einsum("iajb, Bmi, Amajb -> AB", T_iajb, U_1[:, sa, so], eri1_mo[:, sa, sv, so, sv])
)
[17]:
True

我们注意到上述代码中利用到了 \(\mathscr{U}_{ki}^\mathbb{B}\)\(\mathscr{U}_{ik}^\mathbb{B}\)。我们不妨将 \(\mathscr{U}_{ik}^\mathbb{B}\)\(- S_{ki}^\mathbb{A} - \mathscr{U}_{ki}^\mathbb{A}\) 替代。同时我们注意到方才的代码里第 6 行使用的是 \((ka|jb)^\mathbb{A}\),我们将该行的 \(k, i\) 角标互换,更换为 \((ia|jb)^\mathbb{A}\)

[18]:
np.allclose(
    # unsafe if use not-rotated U
    + 2 * np.einsum("Bki, kajb, iajb, Aiajb -> AB", U_1[:, so, so], eri0_mo[so, sv, so, sv], 1 / D_iajb, 2 * gradh.eri1_mo[:, so, sv, so, sv] - gradh.eri1_mo[:, so, sv, so, sv].swapaxes(-1, -3))
    + 2 * np.einsum("Bki, i, kajb, iajb, Aiajb -> AB", U_1[:, so, so], eo, t_iajb, 1 / D_iajb, 2 * gradh.eri1_mo[:, so, sv, so, sv] - gradh.eri1_mo[:, so, sv, so, sv].swapaxes(-1, -3))
    - 2 * np.einsum("Bki, k, kajb, iajb, Aiajb -> AB", U_1[:, so, so], eo, t_iajb, 1 / D_iajb, 2 * gradh.eri1_mo[:, so, sv, so, sv] - gradh.eri1_mo[:, so, sv, so, sv].swapaxes(-1, -3))
    - 2 * np.einsum("Bki, kajb, Aiajb -> AB", U_1[:, so, so], T_iajb, eri1_mo[:, so, sv, so, sv])
    # safe if use not-rotated U
    - 2 * np.einsum("Bki, kajb, iajb, Aiajb -> AB", F_1_mo[:, so, so], t_iajb, 1 / D_iajb, 2 * gradh.eri1_mo[:, so, sv, so, sv] - gradh.eri1_mo[:, so, sv, so, sv].swapaxes(-1, -3))
    + 2 * np.einsum("Bci, cajb, iajb, Aiajb -> AB", U_1[:, sv, so], eri0_mo[sv, sv, so, sv], 1 / D_iajb, 2 * gradh.eri1_mo[:, so, sv, so, sv] - gradh.eri1_mo[:, so, sv, so, sv].swapaxes(-1, -3))
    - 2 * np.einsum("Bki, kajb, iajb, Aiajb -> AB", Ax0_Core(so, so, sa, so)(U_1[:, sa, so]), t_iajb, 1 / D_iajb, 2 * gradh.eri1_mo[:, so, sv, so, sv] - gradh.eri1_mo[:, so, sv, so, sv].swapaxes(-1, -3))
    + 2 * np.einsum("iajb, Bci, Acajb -> AB", T_iajb, U_1[:, sv, so], eri1_mo[:, sv, sv, so, sv])
    + 2 * np.einsum("Bki, i, kajb, iajb, Aiajb -> AB", S_1_mo[:, so, so], eo, t_iajb, 1 / D_iajb, 2 * gradh.eri1_mo[:, so, sv, so, sv] - gradh.eri1_mo[:, so, sv, so, sv].swapaxes(-1, -3))
    - 2 * np.einsum("Bik, kajb, Aiajb -> AB", S_1_mo[:, so, so], T_iajb, eri1_mo[:, so, sv, so, sv])
    ,
    # True value: last code block line 4, 8, 14
    + 2 * np.einsum("Bmi, majb, iajb, Aiajb -> AB", U_1[:, sa, so], eri0_mo[sa, sv, so, sv], 1 / D_iajb, 2 * gradh.eri1_mo[:, so, sv, so, sv] - gradh.eri1_mo[:, so, sv, so, sv].swapaxes(-1, -3))
    - 2 * np.einsum("Bki, kajb, iajb, Aiajb -> AB", gradh.pdA_F_0_mo[:, so, so], t_iajb, 1 / D_iajb, 2 * gradh.eri1_mo[:, so, sv, so, sv] - gradh.eri1_mo[:, so, sv, so, sv].swapaxes(-1, -3))
    + 2 * np.einsum("iajb, Bmi, Amajb -> AB", T_iajb, U_1[:, sa, so], eri1_mo[:, sa, sv, so, sv])
)
[18]:
True

随后,我们发现,与 \(\mathscr{U}_{ki}^\mathbb{B}\) 有关的项的总和恰好为零:

[19]:
np.abs(
    + 2 * np.einsum("Bki, kajb, iajb, Aiajb -> AB", U_1[:, so, so], eri0_mo[so, sv, so, sv], 1 / D_iajb, 2 * gradh.eri1_mo[:, so, sv, so, sv] - gradh.eri1_mo[:, so, sv, so, sv].swapaxes(-1, -3))
    + 2 * np.einsum("Bki, i, kajb, iajb, Aiajb -> AB", U_1[:, so, so], eo, t_iajb, 1 / D_iajb, 2 * gradh.eri1_mo[:, so, sv, so, sv] - gradh.eri1_mo[:, so, sv, so, sv].swapaxes(-1, -3))
    - 2 * np.einsum("Bki, k, kajb, iajb, Aiajb -> AB", U_1[:, so, so], eo, t_iajb, 1 / D_iajb, 2 * gradh.eri1_mo[:, so, sv, so, sv] - gradh.eri1_mo[:, so, sv, so, sv].swapaxes(-1, -3))
    - 2 * np.einsum("Bki, kajb, Aiajb -> AB", U_1[:, so, so], T_iajb, eri1_mo[:, so, sv, so, sv])
).sum()
[19]:
6.570593390529218e-17

我们把所有与 \(\mathscr{U}_{ki}^\mathbb{B}\) 排除,看看剩下的项,也恰好为零:

[20]:
np.abs(
    + 2 * np.einsum("kajb, iajb, Aiajb -> Aki", eri0_mo[so, sv, so, sv], 1 / D_iajb, 2 * gradh.eri1_mo[:, so, sv, so, sv] - gradh.eri1_mo[:, so, sv, so, sv].swapaxes(-1, -3))
    + 2 * np.einsum("i, kajb, iajb, Aiajb -> Aki", eo, t_iajb, 1 / D_iajb, 2 * gradh.eri1_mo[:, so, sv, so, sv] - gradh.eri1_mo[:, so, sv, so, sv].swapaxes(-1, -3))
    - 2 * np.einsum("k, kajb, iajb, Aiajb -> Aki", eo, t_iajb, 1 / D_iajb, 2 * gradh.eri1_mo[:, so, sv, so, sv] - gradh.eri1_mo[:, so, sv, so, sv].swapaxes(-1, -3))
    - 2 * np.einsum("kajb, Aiajb -> Aki", T_iajb, eri1_mo[:, so, sv, so, sv])
).sum()
[20]:
1.7243299317277436e-15

我们对上式要作一定程度的更变:

[21]:
np.abs(
    + 2 * np.einsum("kajb, kajb, iajb, Aiajb -> Aki", T_iajb, D_iajb, 1 / D_iajb, gradh.eri1_mo[:, so, sv, so, sv])
    + 2 * np.einsum("i, kajb, iajb, Aiajb -> Aki", eo, T_iajb, 1 / D_iajb, gradh.eri1_mo[:, so, sv, so, sv])
    - 2 * np.einsum("k, kajb, iajb, Aiajb -> Aki", eo, T_iajb, 1 / D_iajb, gradh.eri1_mo[:, so, sv, so, sv])
    - 2 * np.einsum("kajb, Aiajb -> Aki", T_iajb, eri1_mo[:, so, sv, so, sv])
).sum()
[21]:
1.4084491772561986e-15
[22]:
np.abs(
    + 2 * np.einsum("kajb, kajb, iajb, Aiajb -> Aki", T_iajb, D_iajb, 1 / D_iajb, gradh.eri1_mo[:, so, sv, so, sv])
    + 2 * np.einsum("i, kajb, iajb, Aiajb -> Aki", eo, T_iajb, 1 / D_iajb, gradh.eri1_mo[:, so, sv, so, sv])
    - 2 * np.einsum("k, kajb, iajb, Aiajb -> Aki", eo, T_iajb, 1 / D_iajb, gradh.eri1_mo[:, so, sv, so, sv])
    - 2 * np.einsum("kajb, Aiajb -> Aki", T_iajb, eri1_mo[:, so, sv, so, sv])
).sum()
[22]:
1.4084491772561986e-15

我们发现上式可以写为 (对 \(j, a, b\) 角标求和)

\[\left[ \frac{T_{kj}^{ab} D_{kj}^{ab} + (\varepsilon_i - \varepsilon_k) T_{kj}^{ab}}{D_{ij}^{ab}} - T_{kj}^{ab} \right] (ia|jb)^\mathbb{A} = \frac{T_{kj}^{ab}}{D_{ij}^{ab}} (D_{kj}^{ab} - D_{ij}^{ab} + \varepsilon_i - \varepsilon_k) (ia|jb)^\mathbb{A}\]

我们注意到 \(D_{kj}^{ab} - D_{ij}^{ab} + \varepsilon_i - \varepsilon_k = 0\)。因此,上式确实为零。

至此,我们就完成了对双电子密度项 U 矩阵“旋转不变”的推敲。

\(W_{pq}^\mathrm{MP2} [\mathrm{I}]\) 贡献项“旋转不变”性质

我们随后以下面的一个占据-占据块的例子,讨论下述贡献项的“旋转不变”性质:

\[\partial_\mathbb{A} \partial_\mathbb{B} E_\mathrm{MP2, c} \leftarrow \partial_\mathbb{B} W_{ij}^\mathrm{MP2} [\mathrm{I}] \cdot S_{ij}^\mathbb{A} + W_{ij}^\mathrm{MP2} [\mathrm{I}] \cdot \partial_\mathbb{B} S_{ij}^\mathbb{A}\]

我们首先对上式的导数作初步展开。第一项展开到 \(\partial_\mathbb{B} T_{ij}^{ab}\) 级别,第二项完全展开:

[23]:
np.allclose(
    - 2 * np.einsum("Biakb, jakb, Aij -> AB", gradh.pdA_T_iajb, eri0_mo[so, sv, so, sv], S_1_mo[:, so, so])
    - 2 * np.einsum("iakb, Bjakb, Aij -> AB", T_iajb, gradh.pdA_eri0_mo[:, so, sv, so, sv], S_1_mo[:, so, so])
    + np.einsum("ij, ABij -> AB", W_I[so, so], S_2_mo[:, :, so, so])
    + np.einsum("Bmi, ij, Amj -> AB", U_1[:, sa, so], W_I[so, so], S_1_mo[:, sa, so])
    + np.einsum("Bmj, ij, Ami -> AB", U_1[:, sa, so], W_I[so, so], S_1_mo[:, sa, so])
    ,
    # True value
    + np.einsum("Bij, Aij -> AB", hessh.pdB_W_I[:, so, so], S_1_mo[:, so, so])
    + np.einsum("ij, ABij -> AB", W_I[so, so], hessh.pdB_S_A_mo[:, :, so, so])
)
[23]:
True

我们进一步对第一项展开,直到 \(\partial_\mathbb{B} F_{pq}\) 级别:

[24]:
np.allclose(
    - 2 * np.einsum("Biakb, iakb, jakb, Aij -> AB", eri1_mo[:, so, sv, so, sv], 1 / D_iajb, 2 * eri0_mo[so, sv, so, sv] - eri0_mo[so, sv, so, sv].swapaxes(-1, -3), S_1_mo[:, so, so])
    - 2 * np.einsum("Bmi, makb, iakb, jakb, Aij -> AB", U_1[:, sa, so], eri0_mo[sa, sv, so, sv], 1 / D_iajb, T_iajb * D_iajb, S_1_mo[:, so, so])
    - 2 * np.einsum("Bmk, iamb, iakb, jakb, Aij -> AB", U_1[:, sa, so], eri0_mo[so, sv, sa, sv], 1 / D_iajb, T_iajb * D_iajb, S_1_mo[:, so, so])
    - 2 * np.einsum("Bma, imkb, iakb, jakb, Aij -> AB", U_1[:, sa, sv], eri0_mo[so, sa, so, sv], 1 / D_iajb, T_iajb * D_iajb, S_1_mo[:, so, so])
    - 2 * np.einsum("Bmb, iakm, iakb, jakb, Aij -> AB", U_1[:, sa, sv], eri0_mo[so, sv, so, sa], 1 / D_iajb, T_iajb * D_iajb, S_1_mo[:, so, so])
    + 2 * np.einsum("Bli, lakb, iakb, jakb, Aij -> AB", gradh.pdA_F_0_mo[:, so, so], t_iajb, 1 / D_iajb, T_iajb * D_iajb, S_1_mo[:, so, so])
    + 2 * np.einsum("Blk, ialb, iakb, jakb, Aij -> AB", gradh.pdA_F_0_mo[:, so, so], t_iajb, 1 / D_iajb, T_iajb * D_iajb, S_1_mo[:, so, so])
    - 2 * np.einsum("Bca, ickb, iakb, jakb, Aij -> AB", gradh.pdA_F_0_mo[:, sv, sv], t_iajb, 1 / D_iajb, T_iajb * D_iajb, S_1_mo[:, so, so])
    - 2 * np.einsum("Bcb, iakc, iakb, jakb, Aij -> AB", gradh.pdA_F_0_mo[:, sv, sv], t_iajb, 1 / D_iajb, T_iajb * D_iajb, S_1_mo[:, so, so])
    - 2 * np.einsum("iakb, Bjakb, Aij -> AB", T_iajb, eri1_mo[:, so, sv, so, sv], S_1_mo[:, so, so])
    - 2 * np.einsum("Bmj, iakb, makb, Aij -> AB", U_1[:, sa, so], T_iajb, eri0_mo[sa, sv, so, sv], S_1_mo[:, so, so])
    - 2 * np.einsum("Bmk, iakb, jamb, Aij -> AB", U_1[:, sa, so], T_iajb, eri0_mo[so, sv, sa, sv], S_1_mo[:, so, so])
    - 2 * np.einsum("Bma, iakb, jmkb, Aij -> AB", U_1[:, sa, sv], T_iajb, eri0_mo[so, sa, so, sv], S_1_mo[:, so, so])
    - 2 * np.einsum("Bmb, iakb, jakm, Aij -> AB", U_1[:, sa, sv], T_iajb, eri0_mo[so, sv, so, sa], S_1_mo[:, so, so])
    + np.einsum("ij, ABij -> AB", W_I[so, so], S_2_mo[:, :, so, so])
    + np.einsum("Bmi, ij, Amj -> AB", U_1[:, sa, so], W_I[so, so], S_1_mo[:, sa, so])
    + np.einsum("Bmj, ij, Ami -> AB", U_1[:, sa, so], W_I[so, so], S_1_mo[:, sa, so])
    ,
    # True value
    + np.einsum("Bij, Aij -> AB", hessh_nr.pdB_W_I[:, so, so], S_1_mo[:, so, so])
    + np.einsum("ij, ABij -> AB", W_I[so, so], hessh_nr.pdB_S_A_mo[:, :, so, so])
)
[24]:
True

涉及角标 \(a, b\) 的项

我们单列出涉及角标 \(a, b\) 的项,并且将这些项在使用“旋转”的 U 矩阵与未旋转的 U 矩阵作对比:

[25]:
np.allclose(
    # not rotated
    - 2 * np.einsum("Bma, imkb, iakb, jakb, Aij -> AB", U_1[:, sa, sv], eri0_mo[so, sa, so, sv], 1 / D_iajb, T_iajb * D_iajb, S_1_mo[:, so, so])
    - 2 * np.einsum("Bmb, iakm, iakb, jakb, Aij -> AB", U_1[:, sa, sv], eri0_mo[so, sv, so, sa], 1 / D_iajb, T_iajb * D_iajb, S_1_mo[:, so, so])
    - 2 * np.einsum("Bca, ickb, iakb, jakb, Aij -> AB", gradh.pdA_F_0_mo[:, sv, sv], t_iajb, 1 / D_iajb, T_iajb * D_iajb, S_1_mo[:, so, so])
    - 2 * np.einsum("Bcb, iakc, iakb, jakb, Aij -> AB", gradh.pdA_F_0_mo[:, sv, sv], t_iajb, 1 / D_iajb, T_iajb * D_iajb, S_1_mo[:, so, so])
    - 2 * np.einsum("Bma, iakb, jmkb, Aij -> AB", U_1[:, sa, sv], T_iajb, eri0_mo[so, sa, so, sv], S_1_mo[:, so, so])
    - 2 * np.einsum("Bmb, iakb, jakm, Aij -> AB", U_1[:, sa, sv], T_iajb, eri0_mo[so, sv, so, sa], S_1_mo[:, so, so])
    ,
    # rotated
    - 2 * np.einsum("Bma, imkb, iakb, jakb, Aij -> AB", U_1_nr[:, sa, sv], eri0_mo[so, sa, so, sv], 1 / D_iajb, T_iajb * D_iajb, S_1_mo[:, so, so])
    - 2 * np.einsum("Bmb, iakm, iakb, jakb, Aij -> AB", U_1_nr[:, sa, sv], eri0_mo[so, sv, so, sa], 1 / D_iajb, T_iajb * D_iajb, S_1_mo[:, so, so])
    - 2 * np.einsum("Bca, ickb, iakb, jakb, Aij -> AB", gradh_nr.pdA_F_0_mo[:, sv, sv], t_iajb, 1 / D_iajb, T_iajb * D_iajb, S_1_mo[:, so, so])
    - 2 * np.einsum("Bcb, iakc, iakb, jakb, Aij -> AB", gradh_nr.pdA_F_0_mo[:, sv, sv], t_iajb, 1 / D_iajb, T_iajb * D_iajb, S_1_mo[:, so, so])
    - 2 * np.einsum("Bma, iakb, jmkb, Aij -> AB", U_1_nr[:, sa, sv], T_iajb, eri0_mo[so, sa, so, sv], S_1_mo[:, so, so])
    - 2 * np.einsum("Bmb, iakb, jakm, Aij -> AB", U_1_nr[:, sa, sv], T_iajb, eri0_mo[so, sv, so, sa], S_1_mo[:, so, so])
)
[25]:
True

随后我们将上式的 \(\partial_\mathbb{B} F_{pq}\) 项作展开,并且排除所有非占-占据与占据-非占部分的 U 矩阵贡献项 (这些 U 矩阵贡献不可能因为“旋转”而改变)。我们还同时更改了上式中第 7, 8 行的下角标,并将类如 \(t_{ik}^{cb} T_{jk}^{ab}\) 统一换成 \(T_{ik}^{cb} t_{jk}^{ab}\)

[26]:
np.allclose(
    # not rotated
    - 2 * np.einsum("Bca, ickb, iakb, jakb, Aij -> AB", U_1[:, sv, sv], T_iajb * D_iajb, 1 / D_iajb, eri0_mo[so, sv, so, sv], S_1_mo[:, so, so])
    - 2 * np.einsum("Bcb, iakc, iakb, jakb, Aij -> AB", U_1[:, sv, sv], T_iajb * D_iajb, 1 / D_iajb, eri0_mo[so, sv, so, sv], S_1_mo[:, so, so])
    - 2 * np.einsum("Bca, ickb, iakb, jakb, Aij -> AB", F_1_mo[:, sv, sv], T_iajb, 1 / D_iajb, eri0_mo[so, sv, so, sv], S_1_mo[:, so, so])
    - 2 * np.einsum("Bca, c, ickb, iakb, jakb, Aij -> AB", U_1[:, sv, sv], ev, T_iajb, 1 / D_iajb, eri0_mo[so, sv, so, sv], S_1_mo[:, so, so])
    - 2 * np.einsum("Bac, a, ickb, iakb, jakb, Aij -> AB", U_1[:, sv, sv], ev, T_iajb, 1 / D_iajb, eri0_mo[so, sv, so, sv], S_1_mo[:, so, so])
    - 2 * np.einsum("Bca, ickb, iakb, jakb, Aij -> AB", Ax0_Core(sv, sv, sa, so)(U_1[:, sa, so]), T_iajb, 1 / D_iajb, eri0_mo[so, sv, so, sv], S_1_mo[:, so, so])
    - 2 * np.einsum("Bcb, iakc, iakb, jakb, Aij -> AB", F_1_mo[:, sv, sv], T_iajb, 1 / D_iajb, eri0_mo[so, sv, so, sv], S_1_mo[:, so, so])
    - 2 * np.einsum("Bcb, c, iakc, iakb, jakb, Aij -> AB", U_1[:, sv, sv], ev, T_iajb, 1 / D_iajb, eri0_mo[so, sv, so, sv], S_1_mo[:, so, so])
    - 2 * np.einsum("Bbc, b, iakc, iakb, jakb, Aij -> AB", U_1[:, sv, sv], ev, T_iajb, 1 / D_iajb, eri0_mo[so, sv, so, sv], S_1_mo[:, so, so])
    - 2 * np.einsum("Bcb, iakc, iakb, jakb, Aij -> AB", Ax0_Core(sv, sv, sa, so)(U_1[:, sa, so]), T_iajb, 1 / D_iajb, eri0_mo[so, sv, so, sv], S_1_mo[:, so, so])
    - 2 * np.einsum("Bac, ickb, jakb, Aij -> AB", U_1[:, sv, sv], T_iajb, eri0_mo[so, sv, so, sv], S_1_mo[:, so, so])
    - 2 * np.einsum("Bbc, iakc, jakb, Aij -> AB", U_1[:, sv, sv], T_iajb, eri0_mo[so, sv, so, sv], S_1_mo[:, so, so])
    ,
    # rotated
    - 2 * np.einsum("Bca, ickb, iakb, jakb, Aij -> AB", U_1_nr[:, sv, sv], eri0_mo[so, sv, so, sv], 1 / D_iajb, T_iajb * D_iajb, S_1_mo[:, so, so])
    - 2 * np.einsum("Bcb, iakc, iakb, jakb, Aij -> AB", U_1_nr[:, sv, sv], eri0_mo[so, sv, so, sv], 1 / D_iajb, T_iajb * D_iajb, S_1_mo[:, so, so])
    - 2 * np.einsum("Bca, ickb, iakb, jakb, Aij -> AB", gradh_nr.pdA_F_0_mo[:, sv, sv], t_iajb, 1 / D_iajb, T_iajb * D_iajb, S_1_mo[:, so, so])
    - 2 * np.einsum("Bcb, iakc, iakb, jakb, Aij -> AB", gradh_nr.pdA_F_0_mo[:, sv, sv], t_iajb, 1 / D_iajb, T_iajb * D_iajb, S_1_mo[:, so, so])
    - 2 * np.einsum("Bca, iakb, jckb, Aij -> AB", U_1_nr[:, sv, sv], T_iajb, eri0_mo[so, sv, so, sv], S_1_mo[:, so, so])
    - 2 * np.einsum("Bcb, iakb, jakc, Aij -> AB", U_1_nr[:, sv, sv], T_iajb, eri0_mo[so, sv, so, sv], S_1_mo[:, so, so])
)
[26]:
True

我们将上式中与轨道“旋转”无关的项与有关的项分列,并且使 U 矩阵的 \(c\) 角标始终放在前面:

[27]:
np.allclose(
    # not rotated
    - 2 * np.einsum("Bca, ickb, iakb, jakb, Aij -> AB", U_1[:, sv, sv], T_iajb * D_iajb, 1 / D_iajb, eri0_mo[so, sv, so, sv], S_1_mo[:, so, so])
    - 2 * np.einsum("Bcb, iakc, iakb, jakb, Aij -> AB", U_1[:, sv, sv], T_iajb * D_iajb, 1 / D_iajb, eri0_mo[so, sv, so, sv], S_1_mo[:, so, so])
    - 2 * np.einsum("Bca, c, ickb, iakb, jakb, Aij -> AB", U_1[:, sv, sv], ev, T_iajb, 1 / D_iajb, eri0_mo[so, sv, so, sv], S_1_mo[:, so, so])
    + 2 * np.einsum("Bca, a, ickb, iakb, jakb, Aij -> AB", U_1[:, sv, sv], ev, T_iajb, 1 / D_iajb, eri0_mo[so, sv, so, sv], S_1_mo[:, so, so])
    - 2 * np.einsum("Bcb, c, iakc, iakb, jakb, Aij -> AB", U_1[:, sv, sv], ev, T_iajb, 1 / D_iajb, eri0_mo[so, sv, so, sv], S_1_mo[:, so, so])
    + 2 * np.einsum("Bcb, b, iakc, iakb, jakb, Aij -> AB", U_1[:, sv, sv], ev, T_iajb, 1 / D_iajb, eri0_mo[so, sv, so, sv], S_1_mo[:, so, so])
    + 2 * np.einsum("Bca, ickb, jakb, Aij -> AB", U_1[:, sv, sv], T_iajb, eri0_mo[so, sv, so, sv], S_1_mo[:, so, so])
    + 2 * np.einsum("Bcb, iakc, jakb, Aij -> AB", U_1[:, sv, sv], T_iajb, eri0_mo[so, sv, so, sv], S_1_mo[:, so, so])
    # rotation unrelated
    - 2 * np.einsum("Bca, ickb, iakb, jakb, Aij -> AB", F_1_mo[:, sv, sv], T_iajb, 1 / D_iajb, eri0_mo[so, sv, so, sv], S_1_mo[:, so, so])
    - 2 * np.einsum("Bcb, iakc, iakb, jakb, Aij -> AB", F_1_mo[:, sv, sv], T_iajb, 1 / D_iajb, eri0_mo[so, sv, so, sv], S_1_mo[:, so, so])
    - 2 * np.einsum("Bca, ickb, iakb, jakb, Aij -> AB", Ax0_Core(sv, sv, sa, so)(U_1[:, sa, so]), T_iajb, 1 / D_iajb, eri0_mo[so, sv, so, sv], S_1_mo[:, so, so])
    - 2 * np.einsum("Bcb, iakc, iakb, jakb, Aij -> AB", Ax0_Core(sv, sv, sa, so)(U_1[:, sa, so]), T_iajb, 1 / D_iajb, eri0_mo[so, sv, so, sv], S_1_mo[:, so, so])
    + 2 * np.einsum("Bca, a, ickb, iakb, jakb, Aij -> AB", S_1_mo[:, sv, sv], ev, T_iajb, 1 / D_iajb, eri0_mo[so, sv, so, sv], S_1_mo[:, so, so])
    + 2 * np.einsum("Bcb, b, iakc, iakb, jakb, Aij -> AB", S_1_mo[:, sv, sv], ev, T_iajb, 1 / D_iajb, eri0_mo[so, sv, so, sv], S_1_mo[:, so, so])
    + 2 * np.einsum("Bca, ickb, jakb, Aij -> AB", S_1_mo[:, sv, sv], T_iajb, eri0_mo[so, sv, so, sv], S_1_mo[:, so, so])
    + 2 * np.einsum("Bcb, iakc, jakb, Aij -> AB", S_1_mo[:, sv, sv], T_iajb, eri0_mo[so, sv, so, sv], S_1_mo[:, so, so])
    ,
    # rotated
    - 2 * np.einsum("Bca, ickb, iakb, jakb, Aij -> AB", U_1_nr[:, sv, sv], eri0_mo[so, sv, so, sv], 1 / D_iajb, T_iajb * D_iajb, S_1_mo[:, so, so])
    - 2 * np.einsum("Bcb, iakc, iakb, jakb, Aij -> AB", U_1_nr[:, sv, sv], eri0_mo[so, sv, so, sv], 1 / D_iajb, T_iajb * D_iajb, S_1_mo[:, so, so])
    - 2 * np.einsum("Bca, ickb, iakb, jakb, Aij -> AB", gradh_nr.pdA_F_0_mo[:, sv, sv], t_iajb, 1 / D_iajb, T_iajb * D_iajb, S_1_mo[:, so, so])
    - 2 * np.einsum("Bcb, iakc, iakb, jakb, Aij -> AB", gradh_nr.pdA_F_0_mo[:, sv, sv], t_iajb, 1 / D_iajb, T_iajb * D_iajb, S_1_mo[:, so, so])
    - 2 * np.einsum("Bca, iakb, jckb, Aij -> AB", U_1_nr[:, sv, sv], T_iajb, eri0_mo[so, sv, so, sv], S_1_mo[:, so, so])
    - 2 * np.einsum("Bcb, iakb, jakc, Aij -> AB", U_1_nr[:, sv, sv], T_iajb, eri0_mo[so, sv, so, sv], S_1_mo[:, so, so])
)
[27]:
True

我们发现,与轨道旋转有关的所有项的总和,为零:

[28]:
np.abs(
    - 2 * np.einsum("Bca, ickb, iakb, jakb, Aij -> AB", U_1[:, sv, sv], T_iajb * D_iajb, 1 / D_iajb, eri0_mo[so, sv, so, sv], S_1_mo[:, so, so])
    - 2 * np.einsum("Bcb, iakc, iakb, jakb, Aij -> AB", U_1[:, sv, sv], T_iajb * D_iajb, 1 / D_iajb, eri0_mo[so, sv, so, sv], S_1_mo[:, so, so])
    - 2 * np.einsum("Bca, c, ickb, iakb, jakb, Aij -> AB", U_1[:, sv, sv], ev, T_iajb, 1 / D_iajb, eri0_mo[so, sv, so, sv], S_1_mo[:, so, so])
    + 2 * np.einsum("Bca, a, ickb, iakb, jakb, Aij -> AB", U_1[:, sv, sv], ev, T_iajb, 1 / D_iajb, eri0_mo[so, sv, so, sv], S_1_mo[:, so, so])
    - 2 * np.einsum("Bcb, c, iakc, iakb, jakb, Aij -> AB", U_1[:, sv, sv], ev, T_iajb, 1 / D_iajb, eri0_mo[so, sv, so, sv], S_1_mo[:, so, so])
    + 2 * np.einsum("Bcb, b, iakc, iakb, jakb, Aij -> AB", U_1[:, sv, sv], ev, T_iajb, 1 / D_iajb, eri0_mo[so, sv, so, sv], S_1_mo[:, so, so])
    + 2 * np.einsum("Bca, ickb, jakb, Aij -> AB", U_1[:, sv, sv], T_iajb, eri0_mo[so, sv, so, sv], S_1_mo[:, so, so])
    + 2 * np.einsum("Bcb, iakc, jakb, Aij -> AB", U_1[:, sv, sv], T_iajb, eri0_mo[so, sv, so, sv], S_1_mo[:, so, so])
).sum()
[28]:
7.09647114885409e-17

关于上一个代码块为何为零,可以参考上一段对双电子密度“旋转不变”性质的讨论。

涉及角标 \(i, j, k\) 的项

首先,我们将所有涉及角标 \(i, j, k\) 且与轨道“旋转”有关的项单列出来:

[29]:
np.allclose(
    # not rotated
    - 2 * np.einsum("Bmi, makb, iakb, jakb, Aij -> AB", U_1[:, sa, so], eri0_mo[sa, sv, so, sv], 1 / D_iajb, T_iajb * D_iajb, S_1_mo[:, so, so])
    - 2 * np.einsum("Bmk, iamb, iakb, jakb, Aij -> AB", U_1[:, sa, so], eri0_mo[so, sv, sa, sv], 1 / D_iajb, T_iajb * D_iajb, S_1_mo[:, so, so])
    + 2 * np.einsum("Bli, lakb, iakb, jakb, Aij -> AB", gradh.pdA_F_0_mo[:, so, so], t_iajb, 1 / D_iajb, T_iajb * D_iajb, S_1_mo[:, so, so])
    + 2 * np.einsum("Blk, ialb, iakb, jakb, Aij -> AB", gradh.pdA_F_0_mo[:, so, so], t_iajb, 1 / D_iajb, T_iajb * D_iajb, S_1_mo[:, so, so])
    - 2 * np.einsum("Bmj, iakb, makb, Aij -> AB", U_1[:, sa, so], T_iajb, eri0_mo[sa, sv, so, sv], S_1_mo[:, so, so])
    - 2 * np.einsum("Bmk, iakb, jamb, Aij -> AB", U_1[:, sa, so], T_iajb, eri0_mo[so, sv, sa, sv], S_1_mo[:, so, so])
    + np.einsum("Bmi, ij, Amj -> AB", U_1[:, sa, so], W_I[so, so], S_1_mo[:, sa, so])
    + np.einsum("Bmj, ij, Ami -> AB", U_1[:, sa, so], W_I[so, so], S_1_mo[:, sa, so])
    ,
    # rotated
    - 2 * np.einsum("Bmi, makb, iakb, jakb, Aij -> AB", U_1_nr[:, sa, so], eri0_mo[sa, sv, so, sv], 1 / D_iajb, T_iajb * D_iajb, S_1_mo[:, so, so])
    - 2 * np.einsum("Bmk, iamb, iakb, jakb, Aij -> AB", U_1_nr[:, sa, so], eri0_mo[so, sv, sa, sv], 1 / D_iajb, T_iajb * D_iajb, S_1_mo[:, so, so])
    + 2 * np.einsum("Bli, lakb, iakb, jakb, Aij -> AB", gradh_nr.pdA_F_0_mo[:, so, so], t_iajb, 1 / D_iajb, T_iajb * D_iajb, S_1_mo[:, so, so])
    + 2 * np.einsum("Blk, ialb, iakb, jakb, Aij -> AB", gradh_nr.pdA_F_0_mo[:, so, so], t_iajb, 1 / D_iajb, T_iajb * D_iajb, S_1_mo[:, so, so])
    - 2 * np.einsum("Bmj, iakb, makb, Aij -> AB", U_1_nr[:, sa, so], T_iajb, eri0_mo[sa, sv, so, sv], S_1_mo[:, so, so])
    - 2 * np.einsum("Bmk, iakb, jamb, Aij -> AB", U_1_nr[:, sa, so], T_iajb, eri0_mo[so, sv, sa, sv], S_1_mo[:, so, so])
    + np.einsum("Bmi, ij, Amj -> AB", U_1_nr[:, sa, so], W_I[so, so], S_1_mo[:, sa, so])
    + np.einsum("Bmj, ij, Ami -> AB", U_1_nr[:, sa, so], W_I[so, so], S_1_mo[:, sa, so])
)
[29]:
True

我们将上述的 \(\partial_\mathbb{B} F_{pq}\) 作展开:

[30]:
np.allclose(
    # not rotated
    - 2 * np.einsum("Bmi, makb, iakb, jakb, Aij -> AB", U_1[:, sa, so], eri0_mo[sa, sv, so, sv], 1 / D_iajb, T_iajb * D_iajb, S_1_mo[:, so, so])
    - 2 * np.einsum("Bmk, iamb, iakb, jakb, Aij -> AB", U_1[:, sa, so], eri0_mo[so, sv, sa, sv], 1 / D_iajb, T_iajb * D_iajb, S_1_mo[:, so, so])
    + 2 * np.einsum("Bli, lakb, iakb, jakb, Aij -> AB", F_1_mo[:, so, so], t_iajb, 1 / D_iajb, T_iajb * D_iajb, S_1_mo[:, so, so])
    + 2 * np.einsum("Bil, i, lakb, iakb, jakb, Aij -> AB", U_1[:, so, so], eo, t_iajb, 1 / D_iajb, T_iajb * D_iajb, S_1_mo[:, so, so])
    + 2 * np.einsum("Bli, l, lakb, iakb, jakb, Aij -> AB", U_1[:, so, so], eo, t_iajb, 1 / D_iajb, T_iajb * D_iajb, S_1_mo[:, so, so])
    + 2 * np.einsum("Bli, lakb, iakb, jakb, Aij -> AB", Ax0_Core(so, so, sa, so)(U_1[:, sa, so]), t_iajb, 1 / D_iajb, T_iajb * D_iajb, S_1_mo[:, so, so])
    + 2 * np.einsum("Blk, ialb, iakb, jakb, Aij -> AB", F_1_mo[:, so, so], t_iajb, 1 / D_iajb, T_iajb * D_iajb, S_1_mo[:, so, so])
    + 2 * np.einsum("Bkl, k, ialb, iakb, jakb, Aij -> AB", U_1[:, so, so], eo, t_iajb, 1 / D_iajb, T_iajb * D_iajb, S_1_mo[:, so, so])
    + 2 * np.einsum("Blk, l, ialb, iakb, jakb, Aij -> AB", U_1[:, so, so], eo, t_iajb, 1 / D_iajb, T_iajb * D_iajb, S_1_mo[:, so, so])
    + 2 * np.einsum("Blk, ialb, iakb, jakb, Aij -> AB", Ax0_Core(so, so, sa, so)(U_1[:, sa, so]), t_iajb, 1 / D_iajb, T_iajb * D_iajb, S_1_mo[:, so, so])
    - 2 * np.einsum("Bmj, iakb, makb, Aij -> AB", U_1[:, sa, so], T_iajb, eri0_mo[sa, sv, so, sv], S_1_mo[:, so, so])
    - 2 * np.einsum("Bmk, iakb, jamb, Aij -> AB", U_1[:, sa, so], T_iajb, eri0_mo[so, sv, sa, sv], S_1_mo[:, so, so])
    + np.einsum("Bmi, ij, Amj -> AB", U_1[:, sa, so], W_I[so, so], S_1_mo[:, sa, so])
    + np.einsum("Bmj, ij, Ami -> AB", U_1[:, sa, so], W_I[so, so], S_1_mo[:, sa, so])
    ,
    # rotated
    - 2 * np.einsum("Bmi, makb, iakb, jakb, Aij -> AB", U_1_nr[:, sa, so], eri0_mo[sa, sv, so, sv], 1 / D_iajb, T_iajb * D_iajb, S_1_mo[:, so, so])
    - 2 * np.einsum("Bmk, iamb, iakb, jakb, Aij -> AB", U_1_nr[:, sa, so], eri0_mo[so, sv, sa, sv], 1 / D_iajb, T_iajb * D_iajb, S_1_mo[:, so, so])
    + 2 * np.einsum("Bli, lakb, iakb, jakb, Aij -> AB", gradh_nr.pdA_F_0_mo[:, so, so], t_iajb, 1 / D_iajb, T_iajb * D_iajb, S_1_mo[:, so, so])
    + 2 * np.einsum("Blk, ialb, iakb, jakb, Aij -> AB", gradh_nr.pdA_F_0_mo[:, so, so], t_iajb, 1 / D_iajb, T_iajb * D_iajb, S_1_mo[:, so, so])
    - 2 * np.einsum("Bmj, iakb, makb, Aij -> AB", U_1_nr[:, sa, so], T_iajb, eri0_mo[sa, sv, so, sv], S_1_mo[:, so, so])
    - 2 * np.einsum("Bmk, iakb, jamb, Aij -> AB", U_1_nr[:, sa, so], T_iajb, eri0_mo[so, sv, sa, sv], S_1_mo[:, so, so])
    + np.einsum("Bmi, ij, Amj -> AB", U_1_nr[:, sa, so], W_I[so, so], S_1_mo[:, sa, so])
    + np.einsum("Bmj, ij, Ami -> AB", U_1_nr[:, sa, so], W_I[so, so], S_1_mo[:, sa, so])
)
[30]:
True

我们将所有与轨道“旋转”无关的项单列出来,并且作一些角标更换,以及利用 \(\mathscr{U}_{pq}^\mathbb{B} + \mathscr{U}_{qp}^\mathbb{B} + S_{pq}^\mathbb{B} = 0\) 的性质,在 U 矩阵中尽量让角标 \(l\) 提前,就得到:

[31]:
np.allclose(
    # not rotated
    - 2 * np.einsum("Bli, lakb, iakb, jakb, Aij -> AB", U_1[:, so, so], eri0_mo[so, sv, so, sv], 1 / D_iajb, T_iajb * D_iajb, S_1_mo[:, so, so])
    - 2 * np.einsum("Blk, ialb, iakb, jakb, Aij -> AB", U_1[:, so, so], eri0_mo[so, sv, so, sv], 1 / D_iajb, T_iajb * D_iajb, S_1_mo[:, so, so])
    - 2 * np.einsum("Bli, i, lakb, iakb, jakb, Aij -> AB", U_1[:, so, so], eo, t_iajb, 1 / D_iajb, T_iajb * D_iajb, S_1_mo[:, so, so])
    + 2 * np.einsum("Bli, l, lakb, iakb, jakb, Aij -> AB", U_1[:, so, so], eo, t_iajb, 1 / D_iajb, T_iajb * D_iajb, S_1_mo[:, so, so])
    - 2 * np.einsum("Blk, k, ialb, iakb, jakb, Aij -> AB", U_1[:, so, so], eo, t_iajb, 1 / D_iajb, T_iajb * D_iajb, S_1_mo[:, so, so])
    + 2 * np.einsum("Blk, l, ialb, iakb, jakb, Aij -> AB", U_1[:, so, so], eo, t_iajb, 1 / D_iajb, T_iajb * D_iajb, S_1_mo[:, so, so])
    - 2 * np.einsum("Blk, iakb, jalb, Aij -> AB", U_1[:, so, so], T_iajb, eri0_mo[so, sv, so, sv], S_1_mo[:, so, so])
    + 2 * np.einsum("Bli, lakb, jakb, Aij -> AB", U_1[:, so, so], T_iajb, eri0_mo[so, sv, so, sv], S_1_mo[:, so, so])
    # rotation-unrelated
    - 2 * np.einsum("Bci, cakb, iakb, jakb, Aij -> AB", U_1[:, sv, so], eri0_mo[sv, sv, so, sv], 1 / D_iajb, T_iajb * D_iajb, S_1_mo[:, so, so])
    - 2 * np.einsum("Bck, iacb, iakb, jakb, Aij -> AB", U_1[:, sv, so], eri0_mo[so, sv, sv, sv], 1 / D_iajb, T_iajb * D_iajb, S_1_mo[:, so, so])
    + 2 * np.einsum("Bli, lakb, iakb, jakb, Aij -> AB", F_1_mo[:, so, so], t_iajb, 1 / D_iajb, T_iajb * D_iajb, S_1_mo[:, so, so])
    + 2 * np.einsum("Blk, ialb, iakb, jakb, Aij -> AB", F_1_mo[:, so, so], t_iajb, 1 / D_iajb, T_iajb * D_iajb, S_1_mo[:, so, so])
    + 2 * np.einsum("Bli, lakb, iakb, jakb, Aij -> AB", Ax0_Core(so, so, sa, so)(U_1[:, sa, so]), t_iajb, 1 / D_iajb, T_iajb * D_iajb, S_1_mo[:, so, so])
    + 2 * np.einsum("Blk, ialb, iakb, jakb, Aij -> AB", Ax0_Core(so, so, sa, so)(U_1[:, sa, so]), t_iajb, 1 / D_iajb, T_iajb * D_iajb, S_1_mo[:, so, so])
    - 2 * np.einsum("Bcj, iakb, cakb, Aij -> AB", U_1[:, sv, so], T_iajb, eri0_mo[sv, sv, so, sv], S_1_mo[:, so, so])
    - 2 * np.einsum("Bck, iakb, jacb, Aij -> AB", U_1[:, sv, so], T_iajb, eri0_mo[so, sv, sv, sv], S_1_mo[:, so, so])
    + np.einsum("Bci, ij, Acj -> AB", U_1[:, sv, so], W_I[so, so], S_1_mo[:, sv, so])
    + np.einsum("Bcj, ij, Aci -> AB", U_1[:, sv, so], W_I[so, so], S_1_mo[:, sv, so])
    + 2 * np.einsum("Bjl, iakb, lakb, Aij -> AB", S_1_mo[:, so, so], T_iajb, eri0_mo[so, sv, so, sv], S_1_mo[:, so, so])
    - 2 * np.einsum("Bli, i, lakb, iakb, jakb, Aij -> AB", S_1_mo[:, so, so], eo, t_iajb, 1 / D_iajb, T_iajb * D_iajb, S_1_mo[:, so, so])
    - 2 * np.einsum("Bkl, k, ialb, iakb, jakb, Aij -> AB", S_1_mo[:, so, so], eo, t_iajb, 1 / D_iajb, T_iajb * D_iajb, S_1_mo[:, so, so])
    + 2 * np.einsum("Bli, lakb, jakb, Aij -> AB", S_1_mo[:, so, so], T_iajb, eri0_mo[so, sv, so, sv], S_1_mo[:, so, so])
    ,
    # rotated
    - 2 * np.einsum("Bmi, makb, iakb, jakb, Aij -> AB", U_1_nr[:, sa, so], eri0_mo[sa, sv, so, sv], 1 / D_iajb, T_iajb * D_iajb, S_1_mo[:, so, so])
    - 2 * np.einsum("Bmk, iamb, iakb, jakb, Aij -> AB", U_1_nr[:, sa, so], eri0_mo[so, sv, sa, sv], 1 / D_iajb, T_iajb * D_iajb, S_1_mo[:, so, so])
    + 2 * np.einsum("Bli, lakb, iakb, jakb, Aij -> AB", gradh_nr.pdA_F_0_mo[:, so, so], t_iajb, 1 / D_iajb, T_iajb * D_iajb, S_1_mo[:, so, so])
    + 2 * np.einsum("Blk, ialb, iakb, jakb, Aij -> AB", gradh_nr.pdA_F_0_mo[:, so, so], t_iajb, 1 / D_iajb, T_iajb * D_iajb, S_1_mo[:, so, so])
    - 2 * np.einsum("Bmj, iakb, makb, Aij -> AB", U_1_nr[:, sa, so], T_iajb, eri0_mo[sa, sv, so, sv], S_1_mo[:, so, so])
    - 2 * np.einsum("Bmk, iakb, jamb, Aij -> AB", U_1_nr[:, sa, so], T_iajb, eri0_mo[so, sv, sa, sv], S_1_mo[:, so, so])
    + np.einsum("Bmi, ij, Amj -> AB", U_1_nr[:, sa, so], W_I[so, so], S_1_mo[:, sa, so])
    + np.einsum("Bmj, ij, Ami -> AB", U_1_nr[:, sa, so], W_I[so, so], S_1_mo[:, sa, so])
)
[31]:
True

随后我们就可以分离角标 \(i, k\) 分别进行讨论。其中,对于关于角标 \(i\) 的四行,我们去除其中关于 \(\mathscr{U}_{li}^\mathbb{B}\) 的部分 (即不对该矩阵进行张量缩并),得到的仍然是零张量:

[32]:
np.abs(
    - 2 * np.einsum("lakb, iakb, jakb, Aij -> Ali", eri0_mo[so, sv, so, sv], 1 / D_iajb, T_iajb * D_iajb, S_1_mo[:, so, so])
    - 2 * np.einsum("i, lakb, iakb, jakb, Aij -> Ali", eo, t_iajb, 1 / D_iajb, T_iajb * D_iajb, S_1_mo[:, so, so])
    + 2 * np.einsum("l, lakb, iakb, jakb, Aij -> Ali", eo, t_iajb, 1 / D_iajb, T_iajb * D_iajb, S_1_mo[:, so, so])
    + 2 * np.einsum("lakb, jakb, Aij -> Ali", T_iajb, eri0_mo[so, sv, so, sv], S_1_mo[:, so, so])
).sum()
[32]:
6.082105753059246e-16

其为零的缘由在前文已经有所描述了。

我们就不再进行更多推导了。我们相信,对于角标 \(k\) 而言,也有相同的情况。我们方才仅仅对

\[\partial_\mathbb{A} \partial_\mathbb{B} E_\mathrm{MP2, c} \leftarrow \partial_\mathbb{B} W_{ij}^\mathrm{MP2} [\mathrm{I}] \cdot S_{ij}^\mathbb{A} + W_{ij}^\mathrm{MP2} [\mathrm{I}] \cdot \partial_\mathbb{B} S_{ij}^\mathbb{A}\]

的情况作了推导;而对于 W 矩阵在非占-占据、非占-非占的情况下,相信有着相同的结果。

弛豫密度贡献项“旋转不变”性质

\[\partial_\mathbb{A} \partial_\mathbb{B} E_\mathrm{MP2, c} \leftarrow \partial_\mathbb{B} D_{pq}^\mathrm{MP2, oo-vv} B_{pq}^\mathbb{A} + \mathtt{RHS}_{ai}^\mathbb{B} U_{ai}^\mathbb{A} + D_{pq}^\mathrm{MP2} \cdot \partial_\mathbb{B} B_{pq}^\mathbb{A}\]

我们可以将弛豫密度的贡献项分为占据-占据、非占-非占、非占-占据贡献项三种。下面我们只对占据-占据提供讨论的思路;详细的讨论确实过于繁杂,这里就不再详述了。

\[\partial_\mathbb{A} \partial_\mathbb{B} E_\mathrm{MP2, c} \leftarrow \partial_\mathbb{B} D_{ij}^\mathrm{MP2} B_{ij}^\mathbb{A} + D_{ij}^\mathrm{MP2} \cdot \partial_\mathbb{B} B_{ij}^\mathbb{A}\]

我们首先,判断轨道旋转与未旋转的情况下,对相关能二阶梯度的贡献是否相等:

[33]:
np.allclose(
    # D_r * B, rotation
    + np.einsum("Bij, Aij -> AB", hessh.pdB_D_r_oovv[:, so, so], B_1[:, so, so])
    + np.einsum("ij, ABij -> AB", D_r[so, so], hessh.pdB_B_A[:, :, so, so])
    ,
    # D_r * B, no rotation
    + np.einsum("Bij, Aij -> AB", hessh_nr.pdB_D_r_oovv[:, so, so], B_1[:, so, so])
    + np.einsum("ij, ABij -> AB", D_r[so, so], hessh_nr.pdB_B_A[:, :, so, so])
)
[33]:
True

我们首先对 \(\partial_\mathbb{B} D_{ij}^\mathrm{MP2} B_{ij}^\mathbb{A}\)\(\partial_\mathbb{B} B_{ij}^\mathbb{A}\) 作初步展开,得到:

[34]:
np.allclose(
    # pd D_r * B, rotation
    - 2 * np.einsum("Biakb, jakb, Aij -> AB", gradh.pdA_t_iajb, T_iajb, F_1_mo[:, so, so])
    + 2 * np.einsum("Biakb, jakb, Aij, j -> AB", gradh.pdA_t_iajb, T_iajb, S_1_mo[:, so, so], eo)
    + np.einsum("Biakb, jakb, Aij -> AB", gradh.pdA_t_iajb, T_iajb, Ax0_Core(so, so, so, so)(S_1_mo[:, so, so]))
    - 2 * np.einsum("Bjakb, iakb, Aij -> AB", gradh.pdA_t_iajb, T_iajb, F_1_mo[:, so, so])
    + 2 * np.einsum("Bjakb, iakb, Aij, j -> AB", gradh.pdA_t_iajb, T_iajb, S_1_mo[:, so, so], eo)
    + np.einsum("Bjakb, iakb, Aij -> AB", gradh.pdA_t_iajb, T_iajb, Ax0_Core(so, so, so, so)(S_1_mo[:, so, so]))
    # D_r * pd B, rotation
    - 2 *  np.einsum("iakb, jakb, ABij -> AB", t_iajb, T_iajb, hessh.pdB_F_A_mo[:, :, so, so])
    + 2 * np.einsum("iakb, jakb, ABij, j -> AB", t_iajb, T_iajb, hessh.pdB_S_A_mo[:, :, so, so], eo)
    + 2 * np.einsum("iakb, jakb, Ami, Bmj -> AB", t_iajb, T_iajb, S_1_mo[:, :, so], gradh.pdA_F_0_mo[:, :, so])
    + np.einsum("iakb, jakb, ABij -> AB", t_iajb, T_iajb, Ax0_Core(so, so, so, so)(hessh.pdB_S_A_mo[:, :, so, so]))
    + np.einsum("iakb, jakb, ABij -> AB", t_iajb, T_iajb, Ax1_Core(so, so, so, so)(S_1_mo[:, so, so]).swapaxes(0, 1))
    + 2 * np.einsum("iakb, jakb, ABij -> AB", t_iajb, T_iajb, Ax0_Core(so, so, sa, so)(np.einsum("Bml, Akl -> ABmk", U_1[:, :, so], S_1_mo[:, so, so])))
    + np.einsum("iakb, jakb, Aim, Bmj -> AB", t_iajb, T_iajb, Ax0_Core(so, sa, so, so)(S_1_mo[:, so, so]), U_1[:, :, so])
    + np.einsum("iakb, jakb, Amj, Bmi -> AB", t_iajb, T_iajb, Ax0_Core(sa, so, so, so)(S_1_mo[:, so, so]), U_1[:, :, so])
    ,
    # D_r * B, no rotation
    + np.einsum("Bij, Aij -> AB", hessh_nr.pdB_D_r_oovv[:, so, so], B_1[:, so, so])
    + np.einsum("ij, ABij -> AB", D_r[so, so], hessh_nr.pdB_B_A[:, :, so, so]),
)
[34]:
True

我们将其中的三种贡献项分开;我们知道

\[B_{pq}^\mathbb{A} = F_{pq}^\mathbb{A} - S_{pm}^\mathbb{A} F_{qm} - \frac{1}{2} A_{pq, kl} S_{kl}^\mathbb{A}\]

这三种贡献项我们就拆分如下:

[35]:
np.allclose(
    # rotation: Fock part
    - 2 * np.einsum("Biakb, jakb, Aij -> AB", gradh.pdA_t_iajb, T_iajb, F_1_mo[:, so, so])
    - 2 * np.einsum("Bjakb, iakb, Aij -> AB", gradh.pdA_t_iajb, T_iajb, F_1_mo[:, so, so])
    - 2 * np.einsum("iakb, jakb, ABij -> AB", t_iajb, T_iajb, hessh.pdB_F_A_mo[:, :, so, so])
    # rotation: S part
    + 2 * np.einsum("Biakb, jakb, Aij, j -> AB", gradh.pdA_t_iajb, T_iajb, S_1_mo[:, so, so], eo)
    + 2 * np.einsum("Bjakb, iakb, Aij, j -> AB", gradh.pdA_t_iajb, T_iajb, S_1_mo[:, so, so], eo)
    + 2 * np.einsum("iakb, jakb, ABij, j -> AB", t_iajb, T_iajb, hessh.pdB_S_A_mo[:, :, so, so], eo)
    + 2 * np.einsum("iakb, jakb, Ami, Bmj -> AB", t_iajb, T_iajb, S_1_mo[:, :, so], gradh.pdA_F_0_mo[:, :, so])
    # rotation: Ax0.S part, subscript ij
    + np.einsum("Biakb, jakb, Aij -> AB", gradh.pdA_t_iajb, T_iajb, Ax0_Core(so, so, so, so)(S_1_mo[:, so, so]))
    + np.einsum("Bjakb, iakb, Aij -> AB", gradh.pdA_t_iajb, T_iajb, Ax0_Core(so, so, so, so)(S_1_mo[:, so, so]))
    + np.einsum("iakb, jakb, Aim, Bmj -> AB", t_iajb, T_iajb, Ax0_Core(so, sa, so, so)(S_1_mo[:, so, so]), U_1[:, :, so])
    + np.einsum("iakb, jakb, Amj, Bmi -> AB", t_iajb, T_iajb, Ax0_Core(sa, so, so, so)(S_1_mo[:, so, so]), U_1[:, :, so])
    # rotation: Ax0.S part, subscript kl
    + np.einsum("iakb, jakb, ABij -> AB", t_iajb, T_iajb, Ax0_Core(so, so, so, so)(hessh.pdB_S_A_mo[:, :, so, so]))
    + np.einsum("iakb, jakb, ABij -> AB", t_iajb, T_iajb, Ax1_Core(so, so, so, so)(S_1_mo[:, so, so]).swapaxes(0, 1))
    + 2 * np.einsum("iakb, jakb, ABij -> AB", t_iajb, T_iajb, Ax0_Core(so, so, sa, so)(np.einsum("Bml, Akl -> ABmk", U_1[:, :, so], S_1_mo[:, so, so])))
    ,
    # D_r * B, no rotation
    + np.einsum("Bij, Aij -> AB", hessh_nr.pdB_D_r_oovv[:, so, so], B_1[:, so, so])
    + np.einsum("ij, ABij -> AB", D_r[so, so], hessh_nr.pdB_B_A[:, :, so, so]),
)
[35]:
True

其中,每一部分的贡献项都是“旋转不变”的。拿 Fock 矩阵贡献项举例:

[36]:
np.allclose(
    - 2 * np.einsum("Biakb, jakb, Aij -> AB", gradh.pdA_t_iajb, T_iajb, F_1_mo[:, so, so])
    - 2 * np.einsum("Bjakb, iakb, Aij -> AB", gradh.pdA_t_iajb, T_iajb, F_1_mo[:, so, so])
    - 2 *  np.einsum("iakb, jakb, ABij -> AB", t_iajb, T_iajb, hessh.pdB_F_A_mo[:, :, so, so])
    ,
    - 2 * np.einsum("Biakb, jakb, Aij -> AB", gradh_nr.pdA_t_iajb, T_iajb, F_1_mo[:, so, so])
    - 2 * np.einsum("Bjakb, iakb, Aij -> AB", gradh_nr.pdA_t_iajb, T_iajb, F_1_mo[:, so, so])
    - 2 *  np.einsum("iakb, jakb, ABij -> AB", t_iajb, T_iajb, hessh_nr.pdB_F_A_mo[:, :, so, so])
)
[36]:
True

GGA Hessian

在这一节,我们会非常简略地提及 GGA 泛函的 Hessian 求取。GGA 的 Hessian 求取过程与 RHF 的过程近乎于完全一致。但我们也注意到,GGA 相比于 RHF 而言,多了泛函核的计算。对于 GGA 的 Hessian 而言,我们唯一需要补充的,会是 GGA 能量贡献项的二阶 Skeleton 导数。

后文的 GGA 是以 B3LYP 为例。

准备工作

[1]:
%matplotlib notebook

from pyscf import gto, scf, dft, lib, grad, hessian
import numpy as np
from functools import partial
import warnings
from matplotlib import pyplot as plt
from pyxdh.Utilities import NucCoordDerivGenerator, DipoleDerivGenerator, NumericDiff, GridHelper, KernelHelper
from pyxdh.DerivOnce import GradSCF
from pyxdh.DerivTwice import HessSCF

np.einsum = partial(np.einsum, optimize=["greedy", 1024 ** 3 * 2 / 8])
np.allclose = partial(np.allclose, atol=1e-6, rtol=1e-4)
np.set_printoptions(5, linewidth=150, suppress=True)
warnings.filterwarnings("ignore")
[2]:
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()
[2]:
<pyscf.gto.mole.Mole at 0x7fd3d41ae1c0>
[3]:
def mol_to_grids(mol, atom_grid=(75, 302)):
    grids = dft.Grids(mol)
    grids.atom_grid = atom_grid
    grids.becke_scheme = dft.gen_grid.stratmann
    grids.prune = None
    grids.build()
    return grids
grids = mol_to_grids(mol)
[4]:
def mol_to_scf(mol):
    scf_eng = dft.RKS(mol)
    scf_eng.grids = mol_to_grids(mol)
    scf_eng.xc = "B3LYPg"
    scf_eng.conv_tol = 1e-10
    return scf_eng.run()
[5]:
gradh = GradSCF({"scf_eng": mol_to_scf(mol), "cphf_tol": 1e-12})
hessh = HessSCF({"deriv_A": gradh, "deriv_B": gradh})
[6]:
nmo, nao, natm, nocc, nvir = gradh.nao, gradh.nao, gradh.natm, gradh.nocc, gradh.nvir
so, sv, sa = gradh.so, gradh.sv, gradh.sa
mol_slice = gradh.mol_slice
C, Co, Cv, e, eo, ev, D = gradh.C, gradh.Co, gradh.Cv, gradh.e, gradh.eo, gradh.ev, gradh.D
H_0_ao, S_0_ao, eri0_ao, F_0_ao = gradh.H_0_ao, gradh.S_0_ao, gradh.eri0_ao, gradh.F_0_ao
H_0_mo, S_0_mo, eri0_mo, F_0_mo = gradh.H_0_mo, gradh.S_0_mo, gradh.eri0_mo, gradh.F_0_mo
H_1_ao, S_1_ao, eri1_ao, F_1_ao = gradh.H_1_ao, gradh.S_1_ao, gradh.eri1_ao, gradh.F_1_ao
H_1_mo, S_1_mo, eri1_mo, F_1_mo = gradh.H_1_mo, gradh.S_1_mo, gradh.eri1_mo, gradh.F_1_mo
Ax0_Core, B_1, U_1, U_1_vo = gradh.Ax0_Core, gradh.B_1, gradh.U_1, gradh.U_1_vo
H_2_ao, S_2_ao, eri2_ao = hessh.H_2_ao, hessh.S_2_ao, hessh.eri2_ao
H_2_mo, S_2_mo, eri2_mo = hessh.H_2_mo, hessh.S_2_mo, hessh.eri2_mo
[7]:
grdh = GridHelper(mol, grids, D)
kerh = KernelHelper(grdh, "B3LYPg")
cx = gradh.cx

下面的 A_rho_1 \(\rho^{A_t}\)A_gamma_1 \(\gamma^{A_t}\) 的维度将会重设置为 \((A_t, g)\),这与 pyxdh 的默认设定不太一样,但更改后的维度比较容易代入到 Hessian 的计算中:

[8]:
ngrid = grdh.ngrid
ao_0, ao_1, ao_2, ao_3 = grdh.ao_0, grdh.ao_1, grdh.ao_2, grdh.ao_3
rho_1, rho_2 = grdh.rho_1, grdh.rho_2
A_rho_1 = grdh.A_rho_1.reshape(natm * 3, ngrid)
A_gamma_1 = grdh.A_gamma_1.reshape(natm * 3, ngrid)
fr, fg, frr, frg, fgg = kerh.fr, kerh.fg, kerh.frr, kerh.frg, kerh.fgg
[9]:
def grad_generator(mol):
    scf_eng = mol_to_scf(mol)
    config = {"scf_eng": scf_eng, "cphf_tol": 1e-12}
    return GradSCF(config)
gradn = NucCoordDerivGenerator(mol, grad_generator)

GGA Hessian 求取

我们在 RHF 部分已经给出了 Hessian 公式了。对于 GGA 而言,公式的表达差距不大,但在 Skeleton 导数部分则有所差别:

\[\begin{split}\begin{align} \frac{\partial^2 E_\mathrm{tot}}{\partial B_s \partial A_t} &= h_{\mu \nu}^{A_t B_s} D_{\mu \nu} + \frac{1}{2} (\mu \nu | \kappa \lambda)^{A_t B_s} D_{\mu \nu} D_{\kappa \lambda} - \frac{c_\mathrm{x}}{4} (\mu \kappa | \nu \lambda)^{A_t B_s} D_{\mu \nu} D_{\kappa \lambda} + E_\mathrm{GGA, Skeleton}^{A_t B_s} \\ &\quad - 2 S_{ij}^{A_t B_s} F_{ij} - 2 F_{ij}^{B_s} S_{ij}^{A_t} - 2 F_{ij}^{A_t} S_{ij}^{B_s} \\ &\quad + 2 (\varepsilon_i + \varepsilon_j) S_{ij}^{A_t} S_{ij}^{B_s} + 4 B_{ai}^{B_s} U_{ai}^{A_t} + S_{ij}^{A_t} A_{ij, kl} S_{kl}^{B_s} \\ &\quad + \partial_{A_t} \partial_{B_s} E_\mathrm{nuc} \end{align}\end{split}\]

我们这里就不对推导过程作更多说明,仅仅指出,所有与 GGA 能量有关的 U 导数都被打包到后续的项中 (譬如 \(F_{ij}^{A_t} S_{ij}^{B_s}\))。

我们回顾到,一阶导数中,

\[\partial_{A_t} E_\mathrm{elec} = h_{\mu \nu}^{A_t} D_{\mu \nu} + \frac{1}{2} D_{\mu \nu} (\mu \nu | \kappa \lambda)^{A_t} D_{\kappa \lambda} - \frac{c_\mathrm{x}}{4} D_{\mu \nu} (\mu \kappa | \nu \lambda)^{A_t} D_{\kappa \lambda} + f_\rho \rho^{A_t} + f_\gamma \gamma^{A_t} - 2 F_{ij} S_{ij}^{A_t}\]

那么,其中与 GGA 有关的二阶 Skeleton 导数可以通过上式给出:

\[E_\mathrm{GGA, Skeleton}^{A_t B_s} = f_{\rho \rho} \rho^{A_t} \rho^{B_s} + f_{\rho \gamma} \rho^{A_t} \gamma^{B_s} + f_{\rho \gamma} \rho^{B_s} \gamma^{A_t} + f_{\gamma \gamma} \gamma^{A_t} \gamma^{B_s} + f_\rho \rho^{A_t B_s} + f_\gamma \gamma^{A_t B_s}\]

我们以前没有求取过的两项是 \(\rho^{A_t B_s}\)\(\gamma^{A_t B_s}\)。作为 Skeleton 导数,它们的求导不会产生 U 矩阵。我们给出下述 AB_rho_2 \(\rho^{A_t B_s}\) 的导出式:

\[\rho^{A_t B_s} = 2 D_{\mu \nu} \phi_{ts \mu_{AB}} \phi_\nu + 2 D_{\mu \nu} \phi_{t \mu_A} \phi_{s \nu_B}\]
[10]:
AB_rho_2 = np.zeros((natm, 3, natm, 3, ngrid))
for A in range(natm):
    sA = mol_slice(A)
    AB_rho_2[A, :, A, :] += 2 * np.einsum("uv, tsgu, gv -> tsg", D[sA, :], ao_2[:, :, :, sA], ao_0)
    for B in range(natm):
        sB = mol_slice(B)
        AB_rho_2[A, :, B, :] += 2 * np.einsum("uv, tgu, sgv -> tsg", D[sA, sB], ao_1[:, :, sA], ao_1[:, :, sB])
AB_rho_2.shape = (natm * 3, natm * 3, ngrid)
[11]:
np.allclose(AB_rho_2, grdh.AB_rho_2.swapaxes(1, 2).reshape(natm * 3, natm * 3, ngrid))  # pyxdh approach
[11]:
True

AB_gamma_2 \(\gamma^{A_t B_s}\) 的表达式则为:

\[\gamma^{A_t B_s} = 2 \rho_r^{A_t B_s} \rho_r + 2 \rho_r^{A_t} \rho_r^{B_s}\]

在此之前,我们还需要求取下述 A_rho_2 \(\rho_r^{A_t}\) (维度 \(A_t, r, g\)) 与 AB_rho_3 \(\rho_r^{A_t B_s}\) (维度 \(A_t, B_s, r, g\)):

\[\rho_r^{A_t} = - 2 D_{\mu \nu} \phi_{tr \mu_A} \phi_\nu - 2 D_{\mu \nu} \phi_{t \mu_A} \phi_{r \nu}\]
[12]:
A_rho_2 = np.zeros((natm, 3, 3, ngrid))
for A in range(natm):
    sA = mol_slice(A)
    A_rho_2[A] -= 2 * np.einsum("uv, trgu, gv -> trg", D[sA], ao_2[:, :, :, sA], ao_0)
    A_rho_2[A] -= 2 * np.einsum("uv, tgu, rgv -> trg", D[sA], ao_1[:, :, sA], ao_1)
A_rho_2.shape = (natm * 3, 3, ngrid)
[13]:
np.allclose(A_rho_2, grdh.A_rho_2.reshape(natm * 3, 3, ngrid))  # pyxdh approach
[13]:
True
\[\rho_r^{A_t B_s} = 2 D_{\mu \nu} \phi_{tsr \mu_{AB}} \phi_\nu + 2 D_{\mu \nu} \phi_{tr \mu_A} \phi_{s \nu_B} + 2 D_{\mu \nu} \phi_{ts \mu_{AB}} \phi_{r \nu} + 2 D_{\mu \nu} \phi_{t \mu_A} \phi_{sr \nu_B}\]
[14]:
AB_rho_3 = np.zeros((natm, 3, natm, 3, 3, ngrid))
for A in range(natm):
    sA = mol_slice(A)
    AB_rho_3[A, :, A] += 2 * np.einsum("uv, tsrgu, gv -> tsrg", D[sA], ao_3[:, :, :, :, sA], ao_0)
    AB_rho_3[A, :, A] += 2 * np.einsum("uv, tsgu, rgv -> tsrg", D[sA], ao_2[:, :, :, sA], ao_1)
    for B in range(natm):
        sB = mol_slice(B)
        AB_rho_3[A, :, B] += 2 * np.einsum("uv, trgu, sgv -> tsrg", D[sA, sB], ao_2[:, :, :, sA], ao_1[:, :, sB])
        AB_rho_3[A, :, B] += 2 * np.einsum("uv, tgu, srgv -> tsrg", D[sA, sB], ao_1[:, :, sA], ao_2[:, :, :, sB])
AB_rho_3.shape = (natm * 3, natm * 3, 3, ngrid)
[15]:
np.allclose(AB_rho_3, grdh.AB_rho_3.swapaxes(1, 2).reshape(natm * 3, natm * 3, 3, ngrid))  # pyxdh approach
[15]:
True

因此,AB_gamma_2 \(\gamma^{A_t B_s}\) (维度 \(A_t, B_s, g\)):

\[\gamma^{A_t B_s} = 2 \rho_r^{A_t B_s} \rho_r + 2 \rho_r^{A_t} \rho_r^{B_s}\]
[16]:
AB_gamma_2 = (
    + 2 * np.einsum("ABrg, rg -> ABg", AB_rho_3, rho_1)
    + 2 * np.einsum("Arg, Brg -> ABg", A_rho_2, A_rho_2)
)
AB_gamma_2.shape
[16]:
(12, 12, 90600)
[17]:
np.allclose(AB_gamma_2, grdh.AB_gamma_2.swapaxes(1, 2).reshape(natm * 3, natm * 3, ngrid))
[17]:
True

在作了这些准备后,我们就可以立即求得 GGA 所对 Skeleton 导数的贡献 E_2_Skeleton_GGA

\[E_\mathrm{GGA, Skeleton}^{A_t B_s} = f_{\rho \rho} \rho^{A_t} \rho^{B_s} + f_{\rho \gamma} \rho^{A_t} \gamma^{B_s} + f_{\rho \gamma} \rho^{B_s} \gamma^{A_t} + f_{\gamma \gamma} \gamma^{A_t} \gamma^{B_s} + f_\rho \rho^{A_t B_s} + f_\gamma \gamma^{A_t B_s}\]
[18]:
E_2_Skeleton_GGA = (
    + np.einsum("g, Ag, Bg -> AB", frr, A_rho_1, A_rho_1)
    + np.einsum("g, Ag, Bg -> AB", frg, A_rho_1, A_gamma_1)
    + np.einsum("g, Bg, Ag -> AB", frg, A_rho_1, A_gamma_1)
    + np.einsum("g, Ag, Bg -> AB", fgg, A_gamma_1, A_gamma_1)
    + np.einsum("g, ABg -> AB", fr, AB_rho_2)
    + np.einsum("g, ABg -> AB", fg, AB_gamma_2)
)

我们将会把 GGA Hessian 中的 Skeleton 二阶导数贡献写为 E_2_Skeleton

\[\frac{\partial^2 E_\mathrm{tot}}{\partial B_s \partial A_t} \xleftarrow{\text{Skeleton derivative}} h_{\mu \nu}^{A_t B_s} D_{\mu \nu} + \frac{1}{2} (\mu \nu | \kappa \lambda)^{A_t B_s} D_{\mu \nu} D_{\kappa \lambda} - \frac{c_\mathrm{x}}{4} (\mu \kappa | \nu \lambda)^{A_t B_s} D_{\mu \nu} D_{\kappa \lambda} + E_\mathrm{GGA, Skeleton}^{A_t B_s}\]
[19]:
E_2_Skeleton = (
    + np.einsum("ABuv, uv -> AB", H_2_ao, D)
    + 0.5 * np.einsum("ABuvkl, uv, kl -> AB", eri2_ao, D, D)
    - cx * 0.25 * np.einsum("ABukvl, uv, kl -> AB", eri2_ao, D, D)
    + E_2_Skeleton_GGA
)
E_2_Skeleton.shape
[19]:
(12, 12)
[20]:
np.allclose(E_2_Skeleton, hessh._get_E_2_Skeleton())  # pyxdh approach
[20]:
True

那么,GGA 的总梯度贡献则可以表示为

\[\begin{split}\begin{align} \frac{\partial^2 E_\mathrm{tot}}{\partial B_s \partial A_t} &= h_{\mu \nu}^{A_t B_s} D_{\mu \nu} + \frac{1}{2} (\mu \nu | \kappa \lambda)^{A_t B_s} D_{\mu \nu} D_{\kappa \lambda} - \frac{c_\mathrm{x}}{4} (\mu \kappa | \nu \lambda)^{A_t B_s} D_{\mu \nu} D_{\kappa \lambda} + E_\mathrm{GGA, Skeleton}^{A_t B_s} \\ &\quad - 2 S_{ij}^{A_t B_s} F_{ij} - 2 F_{ij}^{B_s} S_{ij}^{A_t} - 2 F_{ij}^{A_t} S_{ij}^{B_s} \\ &\quad + 2 (\varepsilon_i + \varepsilon_j) S_{ij}^{A_t} S_{ij}^{B_s} + 4 B_{ai}^{B_s} U_{ai}^{A_t} + S_{ij}^{A_t} A_{ij, kl} S_{kl}^{B_s} \\ &\quad + \partial_{A_t} \partial_{B_s} E_\mathrm{nuc} \end{align}\end{split}\]
[21]:
np.allclose(
    + np.einsum("ABuv, uv -> AB", H_2_ao, D)
    + 0.5 * np.einsum("ABuvkl, uv, kl -> AB", eri2_ao, D, D)
    - cx * 0.25 * np.einsum("ABukvl, uv, kl -> AB", eri2_ao, D, D)
    + E_2_Skeleton_GGA
    - 2 * np.einsum("ABij, ij -> AB", S_2_mo[:, :, so, so], F_0_mo[so, so])
    - 2 * np.einsum("Bij, Aij -> AB", F_1_mo[:, so, so], S_1_mo[:, so, so])
    - 2 * np.einsum("Aij, Bij -> AB", F_1_mo[:, so, so], S_1_mo[:, so, so])
    + 2 * np.einsum("ij, Aij, Bij -> AB", eo[:, None] + eo[None, :], S_1_mo[:, so, so], S_1_mo[:, so, so])
    + 4 * np.einsum("Bai, Aai -> AB", B_1[:, sv, so], U_1_vo)
    + np.einsum("Bij, Aij -> AB", Ax0_Core(so, so, so, so)(S_1_mo[:, so, so]), S_1_mo[:, so, so])
    + hessian.RHF(gradh.scf_eng).hess_nuc().swapaxes(1, 2).reshape((12, 12)),
    hessh.E_2
)
[21]:
True

最后,我们不妨验证一下,能量一阶梯度的再一阶数值导数能否求得与上述解析导数一致的结果:

[22]:
nd_E_1 = NumericDiff(gradn, lambda gradh: gradh.E_1.flatten()).derivative
np.allclose(hessh.E_2, nd_E_1, atol=1e-4)
[22]:
True

需要指出,上面的计算过程的误差相当大。

[23]:
np.abs(hessh.E_2 - nd_E_1).sum() / hessh.E_2.size
[23]:
8.730667769423729e-06

如果我们将格点大小增大,则误差可以进一步减小。

[24]:
def mol_to_scf_99590(mol):
    scf_eng = dft.RKS(mol)
    scf_eng.grids = mol_to_grids(mol, atom_grid=(99, 590))
    scf_eng.xc = "B3LYPg"
    scf_eng.conv_tol = 1e-10
    return scf_eng.run()
[25]:
gradh_99590 = GradSCF({"scf_eng": mol_to_scf_99590(mol), "cphf_tol": 1e-12})
hessh_99590 = HessSCF({"deriv_A": gradh_99590, "deriv_B": gradh_99590})
[26]:
def grad_generator_99590(mol):
    scf_eng = mol_to_scf_99590(mol)
    config = {"scf_eng": scf_eng, "cphf_tol": 1e-12}
    return GradSCF(config)
gradn_99590 = NucCoordDerivGenerator(mol, grad_generator_99590)
[27]:
nd_E_1_99590 = NumericDiff(gradn_99590, lambda gradh: gradh.E_1.flatten()).derivative
np.allclose(hessh_99590.E_2, nd_E_1_99590, atol=3e-5)
[27]:
True
[28]:
np.abs(hessh_99590.E_2 - nd_E_1_99590).sum() / hessh.E_2.size
[28]:
3.8146258984312884e-06

事实上,GGA 计算过程中,还存在格点的偏移导致的梯度变化。这部分梯度变化没有纳入我们的计算过程中。但应当认为,若格点越密集,那么因格点偏移导致的变化会因为积分的精度提高而被掩盖;因此,格点精度越高,越有可能得到更为接近数值梯度的解析梯度。

GGA 核坐标二阶 U 矩阵

我们曾经已经讨论过 RHF 下二阶 U 矩阵的计算了;但对于 GGA 而言,它还要引入一部分 GGA 的贡献。这种 GGA 的贡献并非是相当容易求取的,其最为困难之处在于求得 \(A_{pq, rs}^\mathbb{A}\)\(F_{\mu \nu}^{A_t B_s}\) 的过程。尽管该量仅仅包含了一阶导数,但确实只在二阶导数的计算过程中才会用上。

我们这一节的的重点问题就会是讨论 \(A_{pq, rs}^\mathbb{A}\)\(F_{\mu \nu}^{A_t B_s}\) 的计算,进而以 GGA 的二阶“未旋转”的 U 矩阵来作验证。我们在 MP2 的推导过程中知道,我们并不需要在二阶解析梯度中用到二阶 U 矩阵,因此,二阶 U 矩阵只是验证中间变量是否计算正确的手段。

程序变量名变更

出于程序简便的考量,这一节中特别地,所有 U_1 代表的不是 \(\mathscr{U}_{pq}^\mathbb{A}\),而是原先用 U_1_nr 所指代的未经轨道旋转的 \(U_{pq}^\mathbb{A}\)

类似地,gradh_nr 会被 gradhhessh_nr 会被 hessh 替代。

准备工作

[31]:
%matplotlib notebook

from pyscf import gto, scf, dft, lib, grad, hessian
import numpy as np
from functools import partial
import warnings
from matplotlib import pyplot as plt
from pyxdh.Utilities import NucCoordDerivGenerator, DipoleDerivGenerator, NumericDiff, GridHelper, KernelHelper, GridIterator
from pyxdh.DerivOnce import GradSCF
from pyxdh.DerivTwice import HessSCF

np.einsum = partial(np.einsum, optimize=["greedy", 1024 ** 3 * 2 / 8])
np.allclose = partial(np.allclose, atol=1e-6, rtol=1e-4)
np.set_printoptions(5, linewidth=150, suppress=True)
warnings.filterwarnings("ignore")
[2]:
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()
[2]:
<pyscf.gto.mole.Mole at 0x7f8f545a6340>
[3]:
def mol_to_grids(mol, atom_grid=(75, 302)):
    grids = dft.Grids(mol)
    grids.atom_grid = atom_grid
    grids.becke_scheme = dft.gen_grid.stratmann
    grids.prune = None
    grids.build()
    return grids
grids = mol_to_grids(mol)
[4]:
def mol_to_scf(mol):
    scf_eng = dft.RKS(mol)
    scf_eng.grids = mol_to_grids(mol)
    scf_eng.xc = "B3LYPg"
    scf_eng.conv_tol = 1e-10
    return scf_eng.run()
[5]:
gradh = GradSCF({"scf_eng": mol_to_scf(mol), "cphf_tol": 1e-12, "rotation": False})
hessh = HessSCF({"deriv_A": gradh, "deriv_B": gradh, "rotation": False})
[6]:
nmo, nao, natm, nocc, nvir = gradh.nao, gradh.nao, gradh.natm, gradh.nocc, gradh.nvir
so, sv, sa = gradh.so, gradh.sv, gradh.sa
mol_slice = gradh.mol_slice
C, Co, Cv, e, eo, ev, D = gradh.C, gradh.Co, gradh.Cv, gradh.e, gradh.eo, gradh.ev, gradh.D
H_0_ao, S_0_ao, eri0_ao, F_0_ao = gradh.H_0_ao, gradh.S_0_ao, gradh.eri0_ao, gradh.F_0_ao
H_0_mo, S_0_mo, eri0_mo, F_0_mo = gradh.H_0_mo, gradh.S_0_mo, gradh.eri0_mo, gradh.F_0_mo
H_1_ao, S_1_ao, eri1_ao, F_1_ao = gradh.H_1_ao, gradh.S_1_ao, gradh.eri1_ao, gradh.F_1_ao
H_1_mo, S_1_mo, eri1_mo, F_1_mo = gradh.H_1_mo, gradh.S_1_mo, gradh.eri1_mo, gradh.F_1_mo
Ax0_Core, B_1, U_1, U_1_vo = gradh.Ax0_Core, gradh.B_1, gradh.U_1, gradh.U_1_vo
H_2_ao, S_2_ao, eri2_ao = hessh.H_2_ao, hessh.S_2_ao, hessh.eri2_ao
H_2_mo, S_2_mo, eri2_mo = hessh.H_2_mo, hessh.S_2_mo, hessh.eri2_mo
[38]:
grdh = GridHelper(mol, grids, D)
kerh = KernelHelper(grdh, "B3LYPg", deriv=3)
cx, xc = gradh.cx, kerh.xc
[20]:
ngrid = grdh.ngrid
ao_0, ao_1, ao_2, ao_3 = grdh.ao_0, grdh.ao_1, grdh.ao_2, grdh.ao_3
rho_1, rho_2 = grdh.rho_1, grdh.rho_2
A_rho_1 = grdh.A_rho_1.reshape(natm * 3, ngrid)
A_gamma_1 = grdh.A_gamma_1.reshape(natm * 3, ngrid)
fr, fg, frr, frg, fgg = kerh.fr, kerh.fg, kerh.frr, kerh.frg, kerh.fgg
frrr, frrg, frgg, fggg = kerh.frrr, kerh.frrg, kerh.frgg, kerh.fggg
[9]:
def grad_generator(mol):
    scf_eng = mol_to_scf(mol)
    config = {"scf_eng": scf_eng, "cphf_tol": 1e-12, "rotation": False}
    return GradSCF(config)
gradn = NucCoordDerivGenerator(mol, grad_generator)

与 RHF 相同地,我们也可以求取数值二阶 U 矩阵 n_U_2 \(U_{pq}^{\mathbb{AB}}\)

\[U_{pq}^\mathbb{AB} = \frac{\partial U_{pq}^\mathbb{A}}{\partial \mathbb{B}} + U_{pm}^\mathbb{B} U_{mq}^\mathbb{A}\]
[10]:
nd_U_1 = NumericDiff(gradn, lambda gradh_nr: gradh_nr.U_1).derivative.swapaxes(0, 1)
n_U_2 = nd_U_1 + np.einsum("Bpm, Amq -> ABpq", U_1, U_1)
n_U_2.shape
[10]:
(12, 12, 22, 22)

我们下面补充定义一个函数 two_dim_to_one,其作用是使类似于 \((A, t, \mu, \nu)\) 的维度变为 \((A_t, \mu, \nu)\),即将前两个维度合并。

[28]:
def two_dim_to_one(mat):
    dim = mat.shape
    return mat.reshape([dim[0] * dim[1]] + list(dim[2:]))

一阶 A 张量计算

我们知道,二阶 U 矩阵的计算过程中,摆在我们面前的问题会有 Fock 矩阵的二阶 Skeleton 导数 \(F_{\mu \nu}^\mathbb{AB}\),以及一阶 A 张量 \(A_{\mu \nu, \kappa \lambda}^\mathbb{A}\) 或等价地 \(A_{pq, rs}^\mathbb{A}\) 的计算过程。看起来,\(F_{\mu \nu}^\mathbb{AB}\) 作为 Fock 矩阵的二阶 Skeleton 导数相对比较容易推导与求取;当然事实也确实如此。但从定义过程上,应当是一阶 A 张量的计算在前。我们回顾一下推导过程。

\[\frac{\partial F_{\mu \nu}^\mathbb{A}}{\partial \mathbb{B}} = F_{\mu \nu}^\mathbb{AB} + \frac{\partial A_{\mu \nu, \kappa \lambda}}{\partial \mathbb{A}} C_{\kappa m} U_{mk}^\mathbb{B} C_{\lambda k}\]

其中,等式左边与右边的第二项是可以确切定义的项,因此它们可以用以定义 \(F_{\mu \nu}^\mathbb{AB}\)。而一阶 A 张量的定义是根据等式右边的第二项而来:

\[A_{pq, rs}^\mathbb{A} = C_{\mu p} C_{\nu q} \frac{\partial A_{\mu \nu, \kappa \lambda}}{\partial \mathbb{A}} C_{\kappa r} C_{\lambda s} = C_{\mu p} C_{\nu q} A_{\mu \nu, \kappa \lambda}^\mathbb{A} C_{\kappa r} C_{\lambda s}\]

由于在实际运算过程中,使用以分子轨道的 \(A_{pq, rs}^\mathbb{A}\) 进行缩并的效率,比原始的 \(A_{\mu \nu, \kappa \lambda}^\mathbb{A}\) 要好不少,因此我们下面会实际介绍的是缩并函数 \(A_{pq, rs}^\mathbb{A} X_{rs}^\mathbb{B}\) Ax1_Core 的编写。

HF 贡献部分

我们先以 HF 贡献部分的程序编写,来熟悉 Ax1_Core 函数的编写原则。

Ax1_Core 的输入会是 \(p, q, r, s\) 所需要的轨道分割;输出则是另一个函数,暂且记为 fxfx 的输入则是 \(X_{rs}^\mathbb{B}\),输出的是维度以 \((\mathbb{A}, \mathbb{B}, p, q)\) 储存的,对 \(r, s\) 角标求和的 \(A_{pq, rs}^\mathbb{A} X_{rs}^\mathbb{B}\) 的张量。

对于 \(A_{pq, rs}^\mathbb{A} X_{rs}^\mathbb{B}\) 而言,我们先计算 Ax1_Core_HF \(A_{pq, rs}^{\mathrm{HF}, \mathbb{A}} X_{rs}^\mathbb{B}\),并且我们假定 \(\mathbb{B}\) 是 5 维度的,\((p, q, r, s)\) 分别代表非占-占据-非占-占据的分割 (在这里 \(p, q, r, s\) 暂且当作维度不确定的分子轨道):

[11]:
X = np.random.randn(5, nvir, nocc)
[12]:
def Ax1_Core_HF(sp, sq, sr, ss):                                                # Line  1
    size_A = U_1.shape[:-2]                                                     # Line  2
    def fx(X):                                                                  # Line  3
        dmX = C[:, sr] @ X @ C[:, ss].T                                         # Line  4
        dmX += dmX.swapaxes(-1, -2)                                             # Line  5
        size_B = dmX.shape[:-2]                                                 # Line  6
        dmX.shape = (np.prod(size_B, dtype=int), dmX.shape[-2], dmX.shape[-1])  # Line  7
        ax_ao = np.zeros([np.prod(size_A, dtype=int)] + list(dmX.shape))        # Line  8
        ax_ao += np.einsum("Auvkl, Bkl -> ABuv", eri1_ao, dmX)                  # Line  9
        ax_ao -= 0.5 * cx * np.einsum("Aukvl, Bkl -> ABuv", eri1_ao, dmX)       # Line 10
        ax_ao += ax_ao.swapaxes(-1, -2)                                         # Line 11
        Ax = C[:, sp].T @ ax_ao @ C[:, sq]                                      # Line 12
        Ax.shape = list(size_A) + list(size_B) + list(Ax.shape[-2:])            # Line 13
        return Ax                                                               # Line 14
    return fx                                                                   # Line 15

我们可以用下述代码验证结果:

\[A_{pq, rs}^{\mathrm{HF}, \mathbb{A}} X_{rs}^\mathbb{B} = \big( 4 (pq | rs)^\mathbb{AB} - (pr | qs)^\mathbb{AB} - (ps | qr)^\mathbb{AB} \big) X_{rs}^\mathbb{B}\]
[13]:
np.allclose(
    Ax1_Core_HF(sv, so, sv, so)(X),
    + 4 * np.einsum("Aaibj, Bbj -> ABai", eri1_mo[:, sv, so, sv, so], X)
    - cx * np.einsum("Aabij, Bbj -> ABai", eri1_mo[:, sv, sv, so, so], X)
    - cx * np.einsum("Aajib, Bbj -> ABai", eri1_mo[:, sv, so, so, sv], X)
)
[13]:
True

现在,我们对上述代码作细致的说明:

  • Line 2:记录 \(\mathbb{A}\) 的原始维度。由于 \(\mathbb{A}\) 在核坐标中,可能代表维度 \((A_t, )\),也可能代表 \((A, t)\)size_A 则记录下该维度。

  • Line 4-5:求出对 \(r, s\) 角标求和的 dmX \(C_{\kappa r} X_{rs}^\mathbb{B} C_{\lambda s} + C_{\lambda r} X_{rs}^\mathbb{B} C_{\kappa s}\),但保留 \(\mathbb{B}\) 的原始维度。

  • Line 6:记录 \(\mathbb{B}\) 的原始维度。同 Line 2。

  • Line 7:压平 \(\mathbb{B}\) 所指代的维度,并让 dmX 成为三维张量,维度为 \((\mathbb{B}, \kappa, \lambda)\)

  • Line 8:初始化 ax_ao,该张量在最后储存的会是对 \(\kappa, \lambda, r, s\) 角标求和的 \(A_{\mu \nu, \kappa \lambda}^{\mathrm{HF}, \mathbb{A}} C_{\kappa r} C_{\lambda s} X_{rs}^\mathbb{B}\),维度为四维度的 \((\mathbb{A}, \mathbb{B}, r, s)\)

  • Line 9-11:具体计算了 \(A_{\mu \nu, \kappa \lambda}^{\mathrm{HF}, \mathbb{A}} C_{\kappa r} X_{rs}^\mathbb{B} C_{\lambda s}\)

  • Line 12:对分子轨道张量进行进一步缩并,得到 \(A_{pq, rs}^{\mathrm{HF}, \mathbb{A}} X_{rs}^\mathbb{B}\)

  • Line 13:最终对 A 张量的维度作一定的改变回到应有的情况。

我们注意到在整个流程中,我们使用到了

\[X_{\kappa \lambda}^\mathbb{B} = C_{\kappa b} X_{bj}^\mathbb{B} C_{\lambda j} + \mathrm{swap} (\kappa, \lambda)\]

以及最后的计算中,用到 \(\mathrm{swap} (\mu, \nu)\)。因此即使是相当简单的 HF 贡献的一阶 A 张量缩并 (从公式表达上来看仅有三项),但为了效率上的考量,我们会用代码的复杂化作牺牲。在 GGA 的一阶 A 张量计算过程中,我们的整体过程也大致如此。

GGA 贡献部分:代码

这里的内容推导需要先参考 pyxdh A 张量的张量缩并方式 或 XYG3 CheatSheet。由于该张量计算方式的特殊性,我们几乎需要一次性地用代码生成;并且不能方便地对代码的正确性作直接的验证。因此,我们先对代码作说明,随后依着代码的流程进行公式推导。

[62]:
def Ax1_Core(sp, sq, sr, ss):
    size_A = U_1.shape[:-2]
    # Block 1: Generate `dmU`
    dmU = C @ U_1[:, :, so] @ Co.T
    dmU += dmU.swapaxes(-1, -2)
    def fx(X):
        dmX = C[:, sr] @ X @ C[:, ss].T
        dmX += dmX.swapaxes(-1, -2)
        size_B = dmX.shape[:-2]
        dmX.shape = (np.prod(size_B, dtype=int), dmX.shape[-2], dmX.shape[-1])
        ax_ao = np.zeros([np.prod(size_A, dtype=int)] + list(dmX.shape))
        # Block 2: Generate `grdit`
        grdit = GridIterator(mol, grids, D, deriv=3)
        for grdh in grdit:
            kerh = KernelHelper(grdh, xc, deriv=3)
            # Block 3: Define some kernel and density skeleton derivative
            pd_frr = kerh.frrr * two_dim_to_one(grdh.A_rho_1) + kerh.frrg * two_dim_to_one(grdh.A_gamma_1)
            pd_frg = kerh.frrg * two_dim_to_one(grdh.A_rho_1) + kerh.frgg * two_dim_to_one(grdh.A_gamma_1)
            pd_fgg = kerh.frgg * two_dim_to_one(grdh.A_rho_1) + kerh.fggg * two_dim_to_one(grdh.A_gamma_1)
            pd_fg = kerh.frg * two_dim_to_one(grdh.A_rho_1) + kerh.fgg * two_dim_to_one(grdh.A_gamma_1)
            pd_rho_1 = two_dim_to_one(grdh.A_rho_2)
            # Block 4: Form `dmX` density grid
            rho_X_0 = np.array([grdh.get_rho_0(dm) for dm in dmX])
            rho_X_1 = np.array([grdh.get_rho_1(dm) for dm in dmX])
            pd_rho_X_0 = np.array([two_dim_to_one(grdh.get_A_rho_1(dm)) for dm in dmX]).swapaxes(0, 1)
            pd_rho_X_1 = np.array([two_dim_to_one(grdh.get_A_rho_2(dm)) for dm in dmX]).swapaxes(0, 1)
            # Block 5: Define temporary M intermediates (Original and Skeleton derivative)
            M_0 = (
                    + np.einsum("g, Bg -> Bg", kerh.frr, rho_X_0)
                    + 2 * np.einsum("g, wg, Bwg -> Bg", kerh.frg, grdh.rho_1, rho_X_1)
            )
            M_1 = (
                    + 4 * np.einsum("g, Bg, rg -> Brg", kerh.frg, rho_X_0, grdh.rho_1)
                    + 8 * np.einsum("g, wg, Bwg, rg -> Brg", kerh.fgg, grdh.rho_1, rho_X_1, grdh.rho_1)
                    + 4 * np.einsum("g, Brg -> Brg", kerh.fg, rho_X_1)
            )
            pd_M_0 = (
                    + np.einsum("Ag, Bg -> ABg", pd_frr, rho_X_0)
                    + np.einsum("g, ABg -> ABg", kerh.frr, pd_rho_X_0)
                    + 2 * np.einsum("Ag, wg, Bwg -> ABg", pd_frg, grdh.rho_1, rho_X_1)
                    + 2 * np.einsum("g, Awg, Bwg -> ABg", kerh.frg, pd_rho_1, rho_X_1)
                    + 2 * np.einsum("g, wg, ABwg -> ABg", kerh.frg, grdh.rho_1, pd_rho_X_1)
            )
            pd_M_1 = (
                    + 4 * np.einsum("Ag, Bg, rg -> ABrg", pd_frg, rho_X_0, grdh.rho_1)
                    + 4 * np.einsum("g, Bg, Arg -> ABrg", kerh.frg, rho_X_0, pd_rho_1)
                    + 4 * np.einsum("g, ABg, rg -> ABrg", kerh.frg, pd_rho_X_0, grdh.rho_1)
                    + 8 * np.einsum("Ag, wg, Bwg, rg -> ABrg", pd_fgg, grdh.rho_1, rho_X_1, grdh.rho_1)
                    + 8 * np.einsum("g, Awg, Bwg, rg -> ABrg", kerh.fgg, pd_rho_1, rho_X_1, grdh.rho_1)
                    + 8 * np.einsum("g, wg, Bwg, Arg -> ABrg", kerh.fgg, grdh.rho_1, rho_X_1, pd_rho_1)
                    + 8 * np.einsum("g, wg, ABwg, rg -> ABrg", kerh.fgg, grdh.rho_1, pd_rho_X_1, grdh.rho_1)
                    + 4 * np.einsum("Ag, Brg -> ABrg", pd_fg, rho_X_1)
                    + 4 * np.einsum("g, ABrg -> ABrg", kerh.fg, pd_rho_X_1)
            )
            # Contribution 1: pdSkeleton_M * ao_grid
            contrib1 = np.zeros((natm * 3, dmX.shape[0], nao, nao))
            contrib1 += np.einsum("ABg, gu, gv -> ABuv", pd_M_0, grdh.ao_0, grdh.ao_0)
            contrib1 += np.einsum("ABrg, rgu, gv -> ABuv", pd_M_1, grdh.ao_1, grdh.ao_0)
            # Contribution 2: M * pdSkeleton_ao_grid
            tmp_contrib = (
                    - 2 * np.einsum("Bg, tgu, gv -> tBuv", M_0, grdh.ao_1, grdh.ao_0)
                    - np.einsum("Brg, trgu, gv -> tBuv", M_1, grdh.ao_2, grdh.ao_0)
                    - np.einsum("Brg, tgu, rgv -> tBuv", M_1, grdh.ao_1, grdh.ao_1)
            )
            contrib2 = np.zeros((natm, 3, dmX.shape[0], nao, nao))
            for A in range(natm):
                sA = mol_slice(A)
                contrib2[A, :, :, sA] += tmp_contrib[:, :, sA]
            contrib2.shape = (natm * 3, dmX.shape[0], nao, nao)
            # Block 6: U contribution to pdU_M
            rho_U_0 = np.einsum("Auv, gu, gv -> Ag", dmU, grdh.ao_0, grdh.ao_0)
            rho_U_1 = 2 * np.einsum("Auv, rgu, gv -> Arg", dmU, grdh.ao_1, grdh.ao_0)
            gamma_U_0 = 2 * np.einsum("rg, Arg -> Ag", grdh.rho_1, rho_U_1)
            pdU_frr = kerh.frrr * rho_U_0 + kerh.frrg * gamma_U_0
            pdU_frg = kerh.frrg * rho_U_0 + kerh.frgg * gamma_U_0
            pdU_fgg = kerh.frgg * rho_U_0 + kerh.fggg * gamma_U_0
            pdU_fg = kerh.frg * rho_U_0 + kerh.fgg * gamma_U_0
            pdU_rho_1 = rho_U_1
            # Block 7: Define temporary M intermediates (U derivative)
            pdU_M_0 = (
                    + np.einsum("Ag, Bg -> ABg", pdU_frr, rho_X_0)
                    + 2 * np.einsum("Ag, wg, Bwg -> ABg", pdU_frg, grdh.rho_1, rho_X_1)
                    + 2 * np.einsum("g, Awg, Bwg -> ABg", kerh.frg, pdU_rho_1, rho_X_1)
            )
            pdU_M_1 = (
                    + 4 * np.einsum("Ag, Bg, rg -> ABrg", pdU_frg, rho_X_0, grdh.rho_1)
                    + 4 * np.einsum("g, Bg, Arg -> ABrg", kerh.frg, rho_X_0, pdU_rho_1)
                    + 8 * np.einsum("Ag, wg, Bwg, rg -> ABrg", pdU_fgg, grdh.rho_1, rho_X_1, grdh.rho_1)
                    + 8 * np.einsum("g, Awg, Bwg, rg -> ABrg", kerh.fgg, pdU_rho_1, rho_X_1, grdh.rho_1)
                    + 8 * np.einsum("g, wg, Bwg, Arg -> ABrg", kerh.fgg, grdh.rho_1, rho_X_1, pdU_rho_1)
                    + 4 * np.einsum("Ag, Brg -> ABrg", pdU_fg, rho_X_1)
            )
            # Contribution 3: pdU_M * ao_grid
            contrib3 = np.zeros((natm * 3, dmX.shape[0], nao, nao))
            contrib3 += np.einsum("ABg, gu, gv -> ABuv", pdU_M_0, grdh.ao_0, grdh.ao_0)
            contrib3 += np.einsum("ABrg, rgu, gv -> ABuv", pdU_M_1, grdh.ao_1, grdh.ao_0)
            # GGA Contribution Summation
            ax_ao += contrib1 + contrib2 + contrib3
        # HF contribution
        ax_ao += np.einsum("Auvkl, Bkl -> ABuv", eri1_ao, dmX)
        ax_ao -= 0.5 * cx * np.einsum("Aukvl, Bkl -> ABuv", eri1_ao, dmX)
        # Swap mu, nu and final contraction
        ax_ao += ax_ao.swapaxes(-1, -2)
        Ax = C[:, sp].T @ ax_ao @ C[:, sq]
        Ax.shape = list(size_A) + list(size_B) + list(Ax.shape[-2:])
        return Ax
    return fx
[65]:
np.allclose(
    Ax1_Core(sv, so, sv, so)(X),
    gradh.Ax1_Core(sv, so, sv, so)(X)  # pyxdh approach
)
[65]:
True

我们下面对其中一部分的代码块作说明。由于记号表达的不顺畅,很多公式项难以确切地表示出来,可能需要读者自行理解了。

至于要如何验证,上述生成的或者 pyxdh 所生成的 Ax1_Core 是正确的,这还需要我们用它来生成二阶 B 矩阵,从而得到二阶 U 矩阵来验证。

Block 1:生成 dmU \(U_{\mu \nu}^\mathbb{A}\)

由于我们会经常地使用到以 \(C_{\mu m} U_{mi}^\mathbb{A} C_{\nu i} + \mathrm{swap} (\mu, \nu)\) 作为密度矩阵所生成的格点。我们定义 dmU \(U_{\mu \nu}^\mathbb{A}\)

\[U_{\mu \nu}^\mathbb{A} = C_{\mu m} U_{mi}^\mathbb{A} C_{\nu i} + \mathrm{swap} (\mu, \nu)\]

我们以后会用 \(\rho[U_{\mu \nu}^\mathbb{A}]\) 来表示使用了 \(U_{\mu \nu}^\mathbb{A}\) 的密度格点。相应地,我们也会用 \(\rho[X_{\mu \nu}^\mathbb{A}]\) 表示使用了 \(X_{\mu \nu}^\mathbb{A}\) 的密度格点。但若使用的是 \(D_{\mu \nu}\),我们可能会不明确写出其所使用的原子轨道密度。

Block 2:生成实例 grdit

pyxdh 中,实际用于格点积分的量是 GridIterator 的实例,而非以前文档中出现的 GridHelper 实例;但这两者是相当相似的。GridIterator 的实例 grdit 是一个迭代器,用于分批产生 DFT 积分格点。之所以这么做,是因为当格点数量太大时,就可以在较少的内存下分批次地作格点积分。它有些像 PySCF 中的 NumInt.block_loop,一定程度上就是它的外封装。

我们注意到下述语句:

for grdh in grdit:

我们说,每次 GridIterator 实例所迭代的内容可以看作是 GridHelper 的实例,同样地都能生成原子轨道格点、密度格点、以及各种梯度量,拥有极其相似的 API。但一方面,GridIterator 事实上与 GridHelper 毫无关系,只是从程序的调用方式上非常类似;二来,GridIterator 类还允许我们用函数,譬如 get_rho_0,从密度矩阵直接生成密度格点。

Block 3:生成密度与格点 Skeleton 导数偏导量

尽管我们以前不断声明,对解析梯度以 Skeleton 导数与 U 导数为分类进行拆分是没有明确的物理意义或依据的——特别是对于与 Fock 矩阵相关的量,由于 \(F_{\mu \nu}^\mathbb{A}\) 中同时存在着原子轨道与密度矩阵,因此作者认为,如果抛开约定俗成,似乎多少不太适合说 \(F_{\mu \nu}^\mathbb{A}\) 是 Fock 矩阵的 Skeleton 导数,因为它并不等于 \(\partial_\mathbb{A} F_{\mu \nu}\)

但将导数分离的做法而言,分成 Skeleton 导数与 U 导数还是对公式的整理有相当的好处。我们先回顾到,在零阶 A 张量计算过程中,我们使用到了

\[A_{\mu \nu, \kappa \lambda} X_{\kappa \lambda}^\mathbb{A} = (\mu \nu | \kappa \lambda) X_{\kappa \lambda}^\mathbb{A} - \frac{c_\mathrm{x}}{2} (\mu \kappa | \nu \lambda) X_{\kappa \lambda}^\mathbb{A} + M^\mathbb{A} \phi_\mu \phi_\nu + M_r^\mathbb{A} \phi_{r \mu} \phi_\nu + \mathrm{swap} (\mu, \nu)\]

其中,上式的前两项是 HF 贡献的部分,后面的两项则是 GGA 的贡献。那么,GGA 的贡献大致地拆分为三类贡献:一类是 \(M^\mathbb{A}\) 或类似项的 Skeleton 导数,一类是 \(\phi_\mu\) 或类似项的 Skeleton 导数,最后则是 \(M^\mathbb{A}\) 或类似项的 U 导数。

我们回顾到 M_0 \(M^\mathbb{B}\) 表达为

\[M^\mathbb{B} = f_{\rho \rho} \rho[X_{\kappa \lambda}^\mathbb{B}] + 2 f_{\rho \gamma} \rho_w \rho_w[X_{\kappa \lambda}^\mathbb{B}]\]

M_1 \(M_r^\mathbb{B}\) 表达为

\[M_r^\mathbb{B} = 4 f_{\rho \gamma} \rho_r \rho[X_{\kappa \lambda}^\mathbb{B}] + 8 f_{\rho \gamma} \rho_w \rho_r \rho_w[X_{\kappa \lambda}^\mathbb{B}] + 4 f_\gamma \rho_r[X_{\kappa \lambda}^\mathbb{B}]\]

那么,为了求取第一类导数,我们需要给出上式计算中出现项所对应的 Skeleton 导数。我们先求与 \(\rho[X_{\kappa \lambda}^\mathbb{B}]\) 无关的所有 Skeleton 导数。

  • pd_frr \(\partial_\mathbb{A} f_{\rho \rho} \xleftarrow{\text{Skeleton Derivative}} f_{\rho \rho \rho} \rho^\mathbb{A} + f_{\rho \rho \gamma} \gamma^\mathbb{A}\)

  • pd_frg \(\partial_\mathbb{A} f_{\rho \gamma} \xleftarrow{\text{Skeleton Derivative}} f_{\rho \rho \gamma} \rho^\mathbb{A} + f_{\rho \gamma \gamma} \gamma^\mathbb{A}\)

  • pd_fgg \(\partial_\mathbb{A} f_{\gamma \gamma} \xleftarrow{\text{Skeleton Derivative}} f_{\rho \gamma \gamma} \rho^\mathbb{A} + f_{\gamma \gamma \gamma} \gamma^\mathbb{A}\)

  • pd_fg \(\partial_\mathbb{A} f_{\gamma} \xleftarrow{\text{Skeleton Derivative}} f_{\rho \gamma} \rho^\mathbb{A} + f_{\gamma \gamma} \gamma^\mathbb{A}\)

  • pd_rho_1 \(\partial_\mathbb{A} \rho_r \xleftarrow{\text{Skeleton Derivative}} \rho_r^\mathbb{A}\)

Block 4:生成与 \(\rho[X_{\kappa \lambda}^\mathbb{B}]\) 有关的 Skeleton 导数

  • rho_X_0 \(\rho[X_{\kappa \lambda}^\mathbb{B}] = X_{\kappa \lambda}^\mathbb{B} \phi_\kappa \phi_\lambda\)

  • rho_X_1 \(\rho_r[X_{\kappa \lambda}^\mathbb{B}] = 2 X_{\kappa \lambda}^\mathbb{B} \phi_{r \kappa} \phi_\lambda\)

  • pd_rho_X_0 \(\rho^{A_t}[X_{\kappa \lambda}^\mathbb{B}] = - 2 X_{\kappa \lambda}^\mathbb{B} \phi_{t \kappa_A} \phi_\lambda\)

  • pd_rho_X_1 \(\rho_r^\mathbb{A}[X_{\kappa \lambda}^\mathbb{B}] = - 2 X_{\kappa \lambda}^\mathbb{B} \phi_{tr \kappa_A} \phi_\lambda - 2 X_{\kappa \lambda}^\mathbb{B} \phi_{r \kappa} \phi_{t \lambda_A}\)

由于 GridIterator 类提供了通过密度直接生成格点的函数,因此我们不需要额外地编写生成代码了。

Block 5:M 矩阵及其一阶 Skeleton 导数

  • M_0 \(M^\mathbb{B} = f_{\rho \rho} \rho[X_{\kappa \lambda}^\mathbb{B}] + f_{\rho \gamma} \rho_w \rho_w[X_{\kappa \lambda}^\mathbb{B}]\)

  • M_1 \(M_r^\mathbb{B} = 4 f_{\rho \gamma} \rho_r \rho[X_{\kappa \lambda}^\mathbb{B}] + 8 f_{\rho \gamma} \rho_w \rho_r \rho_w[X_{\kappa \lambda}^\mathbb{B}] + 4 f_\gamma \rho_r[X_{\kappa \lambda}^\mathbb{B}]\)

  • pd_M_0\(M^\mathbb{B}\) 的每一项依链式法则,分别求取 Skeleton 导数

  • pd_M_1\(M_r^\mathbb{B}\) 的每一项依链式法则,分别求取 Skeleton 导数

Block 6:U 矩阵作为密度矩阵所生成的格点

上面仅仅讨论了 Skeleton 导数的问题,但所有的 U 矩阵导数也是我们所需要求取的。我们注意到,在 GGA 对 A 张量缩并的贡献项中,\(\phi_{\mu}\)\(\phi_{r \mu}\) 是不会产生 U 导数贡献的。因此,我们只需要考虑与 \(\partial_\mathbb{A} M^\mathbb{B}\)\(\partial_\mathbb{A} M_r^\mathbb{B}\) 的项。

但我们还需要注意到,不是所有的项都需要被求到 U 导数。譬如,对于 \(\rho[X_{\kappa \lambda}^\mathbb{B}]\) 而言,我们会注意到

\[\rho[X_{\kappa \lambda}^\mathbb{B}] = X_{rs}^\mathbb{B} \phi_{\kappa} \phi_\lambda (C_{\kappa r} C_{\lambda s} + C_{\kappa s} C_{\lambda r})\]

上式中,\(X_{rs}^\mathbb{B}\) 是不适合求导数的;因此能得到 U 导数的项只有两个轨道系数矩阵 \(C_{\kappa r}\) 等项。但我们留意到一阶 A 张量的定义:

\[A_{pq, rs}^\mathbb{A} = C_{\mu p} C_{\nu q} \frac{\partial A_{\mu \nu, \kappa \lambda}}{\partial \mathbb{A}} C_{\kappa r} C_{\lambda s}\]

因此,事实上类似于 \(C_{\kappa r}\) 等项并不应当被求导。因此,我们之后在求取 \(\partial_\mathbb{A} M^\mathbb{B}\)\(\partial_\mathbb{A} M_r^\mathbb{B}\) 的 U 导数贡献时,就无需要考虑与 \(X_{\kappa \lambda}^\mathbb{B}\) 有关的密度格点了。

文档未完成

这份文档可能在短期之内不会再更新了。这是应由于作者的编写热情降低、以及公式符号的复杂化引起的。

作者希望读者在读到这里之后,能自行地推导 Fock 矩阵的二阶 Skeleton 导数,并且以此生成 GGA 的二阶解析 U 矩阵,并与数值 U 矩阵作核验。

这之后恐怕就没有更困难的计算了。对于 XYG3 型泛函的 Hessian 而言,只需要在现在的基础上,在 MP2 生成的部分注意 \(L_{ai}^\mathrm{PT2+}\) 的项,补上非自洽的 \(F_{ai}^\mathrm{n}\) 即可。对于其它导数,需要留意被求导量 \(\mathbb{A}, \mathbb{B}\) 的顺序是否正确。

如果要拿代码进行核验,不妨 Hack 一下 pyxdh 的代码。你或许会发现 HessNCDFTHessMP2HessXDH 的代码意外地很简单,因此真正需要加深理解的,只有 RHF, MP2 与 GGA 的 Hessian 求取,与二阶 U 矩阵的计算。有这些基础后,其它的解析导数譬如 B2PLYP 型、XYG3 型求取就会轻松许多。

Unrestricted Kohn-Sham 一阶梯度与中间矩阵

这一节我们相对系统地讨论一阶 GGA 梯度的相关性质,包括能量表达式、一阶梯度、U 矩阵的计算与关联;从而对后续的 UKS 的计算打下基础。

准备工作

[1]:
%load_ext autoreload
%autoreload 2
%matplotlib notebook

from matplotlib import pyplot as plt
import numpy as np
from pyscf import gto, scf, dft, grad
from pyscf.scf import ucphf
from functools import partial
from pyxdh.DerivOnce import GradUSCF, GradSCF
from pyxdh.Utilities import GridHelper, KernelHelper
from pyxdh.Utilities import NucCoordDerivGenerator, NumericDiff
import warnings

np.einsum = partial(np.einsum, optimize=["greedy", 1024 ** 3 * 2 / 8])
np.allclose = partial(np.allclose, atol=1e-6, rtol=1e-4)
np.set_printoptions(5, linewidth=180, suppress=True)
warnings.filterwarnings("ignore")

为了简化计算量,我们大多数时候对格点积分不作非常精细的计算,因此使用非常小的格点 (50, 194)。使用的分子是非对称的 CH3 自由基。

[2]:
mol = gto.Mole()
mol.atom = """
C  0. 0. 0.
H  1. 0. 0.
H  0. 2. 0.
H  0. 0. 1.5
"""
mol.basis = "6-31G"
mol.spin = 1
mol.verbose = 0
mol.build()
# mol = gto.Mole()
# mol.atom = """
# N  0. 0. 0.
# H  1. 0. 0.
# H  0. 2. 0.
# H  0. 0. 1.5
# """
# mol.basis = "6-31G"
# mol.spin = 0
# mol.verbose = 0
# mol.build()
[2]:
<pyscf.gto.mole.Mole at 0x7fbfa1f4fcd0>
[3]:
grids = dft.Grids(mol)
grids.atom_grid = (50, 194)
grids.build()
[3]:
<pyscf.dft.gen_grid.Grids at 0x7fbfa1f4f7f0>
[4]:
scf_eng = dft.UKS(mol)
scf_eng.xc = "B3LYPg"
scf_eng.grids = grids
scf_eng.run()
scf_eng.e_tot
[4]:
-39.60377211830869
[5]:
gradh = GradUSCF({"scf_eng": scf_eng, "cphf_tol": 1e-10})
gradh.eng
[5]:
-39.60377211830869

我们顺便定义一下数值导数计算量 gradn。可以用它来进行若干矩阵的数值导数计算。

[6]:
def mol_to_grad_helper(mol):
    g = dft.Grids(mol)
    g.atom_grid = (50, 194)
    g.build()
    mf = dft.UKS(mol)
    mf.xc = "B3LYPg"
    mf.grids = g
    return GradUSCF({"scf_eng": mf.run()})

gradn = NucCoordDerivGenerator(mol, mol_to_grad_helper)
[7]:
def plot_diff(anal_mat, num_mat):
    fig, ax = plt.subplots(figsize=(2.4, 1.8)); ax.set_xscale("log")
    ax.hist(abs(anal_mat.ravel() - num_mat.ravel()), bins=np.logspace(np.log10(1e-10), np.log10(1e-1), 50), alpha=0.5)
    ax.hist(abs(num_mat.ravel()), bins=np.logspace(np.log10(1e-10), np.log10(1e-1), 50), alpha=0.5)
    return fig.tight_layout()

能量 UKS 计算与相关矩阵

新的基础数据结构

由于 Unrestricted 计算会在同一分子中涉及到两套占据轨道信息,因此会产生 RKS 所不会出现的各种不便利,并且要求一种新的数据结构。我们会作简单的说明。

  • 对于 GGA 而言,cx \(c_\mathrm{x}\)xc 泛函名称与 RKS 情形相同

[8]:
cx, xc = gradh.cx, gradh.xc
cx, xc
[8]:
(0.2, 'B3LYPg')
  • nmo \(n_\mathrm{MO}\)nao \(n_\mathrm{AO}\)natm \(n_\mathrm{Atom}\) 与 RKS 的情形相同

[9]:
nmo, nao, natm = gradh.nmo, gradh.nao, gradh.natm
nmo, nao, natm
[9]:
(15, 15, 4)
  • nocc \((n_\mathrm{occ}^\alpha, n_\mathrm{occ}^\beta)\), nvir \((n_\mathrm{vir}^\alpha, n_\mathrm{vir}^\beta)\) 则是 Tuple[int] 类型

[10]:
nocc, nvir = gradh.nocc, gradh.nvir
nocc, nvir
[10]:
((5, 4), (10, 11))
  • so, sv, sa 作为占据、非占、全轨道的分割,其类型也变为了 Tuple[slice] 类型

[11]:
so, sv, sa = gradh.so, gradh.sv, gradh.sa
so, sv, sa
[11]:
((slice(0, 5, None), slice(0, 4, None)),
 (slice(5, 15, None), slice(4, 15, None)),
 (slice(0, 15, None), slice(0, 15, None)))
  • C \(C_{\mu p}^\sigma\), e \(e_p^\sigma\) 分别是轨道系数与轨道能,维度分别是 \((\sigma, \mu, p)\)\((\sigma, p)\)

  • 一般来说,\(\sigma\) 在 np.einsum 程序中会用 x, y 等表示

  • \(\sigma\) 在以后的程序中,通常置于维度的第一位置,比被求导量 \(\mathbb{A}\) 优先

  • 由于 C[0] \(C_{\mu p}^\alpha\)C[1] \(C_{\mu p}^\beta\) 维度相同,因此 C 使用 np.ndarray 储存;e 同理

[12]:
C, e = gradh.C, gradh.e
C.shape, e.shape
[12]:
((2, 15, 15), (2, 15))
  • Co \(C_{\mu i}^\sigma\) 为占据轨道系数,维度是 \((\sigma, \mu, i)\)

  • 但是留意,由于 \(\alpha\)\(\beta\) 自旋的占据轨道数不同,因此使用 Tuple[np.ndarray] 储存;以后的矩阵通常也按照这两种方式分别处理

[13]:
Co = gradh.Co
Co[0].shape, Co[1].shape
[13]:
((15, 5), (15, 4))
  • eo \(e_i^\sigma\) 为占据轨道能,维度 \((\sigma, i)\),类型同理 Tuple[np.ndarray]

[14]:
eo = gradh.eo
eo[0].shape, eo[1].shape
[14]:
((5,), (4,))

上述的情况也可以用于定义非占轨道系数与非占轨道能:

[15]:
Cv, ev = gradh.Cv, gradh.ev
  • mo_occ \(\delta_{p \in \mathrm{occ}}^\sigma\) 表示轨道占据情况

[16]:
mo_occ = gradh.mo_occ
mo_occ
[16]:
array([[1., 1., 1., 1., 1., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0.],
       [1., 1., 1., 1., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0.]])

电子积分

  • H_0_ao \(h_{\mu \nu}\), S_0_ao \(S_{\mu \nu}\), eri0_ao \((\mu \nu | \kappa \lambda)\), H_1_ao \(h_{\mu \nu}^\mathbb{A}\), S_1_ao \(S_{\mu \nu}^\mathbb{A}\), eri1_ao \((\mu \nu | \kappa \lambda)^\mathbb{A}\) 与 RKS 没有区别

[17]:
H_0_ao, S_0_ao, eri0_ao, H_1_ao, S_1_ao, eri1_ao = gradh.H_0_ao, gradh.S_0_ao, gradh.eri0_ao, gradh.H_1_ao, gradh.S_1_ao, gradh.eri1_ao
  • H_0_mo \(h_{pq}^\sigma\), S_0_mo \(S_{pq}^\sigma\), dim: \((\sigma, p, q)\), type: np.ndarray

  • H_1_mo \(h_{pq}^{\mathbb{A}, \sigma}\), S_1_mo \(S_{\mu \nu}^{\mathbb{A}, \sigma}\), dim: \((\sigma, \mathbb{A}, p, q)\), type: np.ndarray

[18]:
H_0_mo, S_0_mo = gradh.H_0_mo, gradh.S_0_mo
H_0_mo.shape, S_0_mo.shape
[18]:
((2, 15, 15), (2, 15, 15))
[19]:
H_1_mo, S_1_mo = gradh.H_1_mo, gradh.S_1_mo
H_1_mo.shape, S_1_mo.shape
[19]:
((2, 12, 15, 15), (2, 12, 15, 15))

H_1_mo \(h_{pq}^{\mathbb{A}, \sigma}\) 为例,

\[h_{pq}^{\mathbb{A}, \sigma} = h_{\mu \nu}^\mathbb{A} C_{\mu p}^\sigma C_{\nu q}^\sigma\]
[20]:
np.allclose(np.einsum("Auv, xup, xvq -> xApq", H_1_ao, C, C), H_1_mo)
[20]:
True
  • eri0_mo \((pq|rs)^{\sigma \sigma'}\), dim: \((\sigma \sigma', p, q, r, s)\), type: np.ndarray

  • 上述的 \(\sigma \sigma'\) 所实际指代的是 \(\alpha \alpha, \alpha \beta, \beta \beta\)\((pq|rs)^{\sigma \sigma'}\) 中,\(p, q\)\(\sigma\) 自旋的,而 \(r, s\)\(\sigma'\) 自旋的

\[(pq|rs)^{\sigma \sigma'} = (\mu \nu | \kappa \lambda) C_{\mu p}^\sigma C_{\nu q}^\sigma C_{\kappa r}^{\sigma'} C_{\lambda s}^{\sigma'}\]
[21]:
eri0_mo = gradh.eri0_mo
eri0_mo.shape
[21]:
(3, 15, 15, 15, 15)
[22]:
np.allclose(
    np.array([
        np.einsum("uvkl, up, vq, kr, ls -> pqrs", eri0_ao, C[0], C[0], C[0], C[0]),
        np.einsum("uvkl, up, vq, kr, ls -> pqrs", eri0_ao, C[0], C[0], C[1], C[1]),
        np.einsum("uvkl, up, vq, kr, ls -> pqrs", eri0_ao, C[1], C[1], C[1], C[1]),
    ]),
    eri0_mo
)
[22]:
True
  • eri1_mo \((pq|rs)^{\mathbb{A}, \sigma \sigma'}\), dim: \((\sigma \sigma', \mathbb{A}, p, q, r, s)\), type: np.ndarray,与 eri0_mo \((pq|rs)^{\sigma \sigma'}\) 同理

[23]:
eri1_mo = gradh.eri1_mo
eri1_mo.shape
[23]:
(3, 12, 15, 15, 15, 15)

密度矩阵 \(D_{\mu \nu}^\sigma\)

  • D \(D_{\mu \nu}^\sigma\), dim: \((\sigma, \mu, \nu)\), type: np.ndarray

\[D_{\mu \nu}^\sigma = C_{\mu p}^\sigma C_{\nu p}^\sigma \delta_{p \in \mathrm{occ}}^\sigma\]
[24]:
D = gradh.D
D.shape
[24]:
(2, 15, 15)
[25]:
np.allclose(np.einsum("xup, xvp, xp -> xuv", C, C, mo_occ), D)
[25]:
True

另一种验证方式是

\[D_{\mu \nu}^\sigma = C_{\mu i}^\sigma C_{\nu i}^\sigma\]

但需要留意到,两种自旋的占据轨道数量并不相同;因此,不能像上面一行代码即可验证。

[26]:
(
    np.allclose(np.einsum("ui, vi -> uv", Co[0], Co[0]), D[0]),
    np.allclose(np.einsum("ui, vi -> uv", Co[1], Co[1]), D[1])
)
[26]:
(True, True)

库伦积分 \(J_{\mu \nu}\) 与交换积分 \(K_{\mu \nu}\)

该积分尽管常用,但以后不经常用该记号。通常以后会显式地写出如何从原子轨道与密度作张量缩并。

该积分的定义与 RKS 相同。如果我们定义影响库伦积分的密度矩阵为 \(X_{\kappa \lambda}\),那么

  • \(J_{\mu \nu} = (\mu \nu | \kappa \lambda) X_{\kappa \lambda}\)

  • \(K_{\mu \nu} = (\mu \kappa | \nu \lambda) X_{\kappa \lambda}\)

[27]:
X = np.random.randn(nao, nao)
[28]:
np.allclose(np.einsum("uvkl, kl -> uv", eri0_ao, X), scf_eng.get_j(dm=X))
[28]:
True
[29]:
np.allclose(np.einsum("ukvl, kl -> uv", eri0_ao, X), scf_eng.get_k(dm=X, hermi=0))
[29]:
True

轨道与密度格点、泛函核格点

  • 轨道与密度格点仍然用 grdh 记号表示,但在文档中,会使用 Tuple[GridHelper] 类型储存,长度为 2。两个 GridHelper 类型分别代表 \(\alpha, \beta\) 密度下的轨道与密度格点。在 pyxdh 程序中,使用 zip[GridIterator]

[30]:
grdh = (GridHelper(mol, grids, D[0]), GridHelper(mol, grids, D[1]))
[31]:
ngrid = grdh[0].ngrid
ngrid
[31]:
26836
  • 泛函核格点仍然用 kerh 记号表示

[32]:
kerh = KernelHelper(grdh, xc, deriv=3)

但由于使用了带自旋密度,因此许多量与 RKS 稍有不同。简单的情况中,在 RKS 中为向量的 \(f_\rho\) 在这里变成两条向量 \((f_{\rho^\alpha}, f_{\rho^\beta})\)

[33]:
kerh.fr
[33]:
array([[-0.     , -0.     , -0.     , ..., -0.01168, -0.01275, -0.01399],
       [-0.     , -0.     , -0.     , ..., -0.01172, -0.01275, -0.014  ]])

\(f_{\gamma}\) 则分为了 \((f_{\gamma^{\alpha \alpha}}, f_{\gamma^{\alpha \beta}}, f_{\gamma^{\beta \beta}})\)

[34]:
kerh.fg
[34]:
array([[-0.     , -0.     , -0.     , ..., -0.36551, -0.46675, -0.43452],
       [ 0.     ,  0.     ,  0.     , ...,  0.21322,  0.34198,  0.32137],
       [-0.     , -0.     , -0.     , ..., -0.36812, -0.43788, -0.4247 ]])

我们下面系统地整理一下 PySCF 中对泛函核导数的 说明:

  • exc \(f\) 与 RKS 较为接近

[35]:
kerh.exc.shape
[35]:
(26836,)
  • fr 2: u, d

    • \(f_{\rho^\alpha}, f_{\rho^\beta}\)

  • fg 3: uu, ud, dd

    • \(f_{\gamma^{\alpha \alpha}}, f_{\gamma^{\alpha \beta}}, f_{\gamma^{\beta \beta}}\)

[36]:
kerh.fr.shape[0], kerh.fg.shape[0]
[36]:
(2, 3)
  • frr 3: u_u, u_d, d_d

    • \(f_{\rho^\alpha \rho^\alpha}, f_{\rho^\alpha \rho^\beta}, f_{\rho^\beta \rho^\beta}\)

  • frg 6: u_uu, u_ud, u_dd, d_uu, d_ud, d_dd

    • \(f_{\rho^\alpha \gamma^{\alpha \alpha}}, f_{\rho^\alpha \gamma^{\alpha \beta}}, f_{\rho^\alpha \gamma^{\beta \beta}}, f_{\rho^\beta \gamma^{\alpha \alpha}}, f_{\rho^\beta \gamma^{\alpha \beta}}, f_{\rho^\beta \gamma^{\beta \beta}}\)

  • fgg 6: uu_uu, uu_ud, uu_dd, ud_ud, ud_dd, dd_dd

    • \(f_{\gamma^{\alpha \alpha} \gamma^{\alpha \alpha}}, f_{\gamma^{\alpha \alpha} \gamma^{\alpha \beta}}, f_{\gamma^{\alpha \alpha} \gamma^{\beta \beta}}, f_{\gamma^{\alpha \beta} \gamma^{\alpha \beta}}, f_{\gamma^{\alpha \beta} \gamma^{\beta \beta}}, f_{\gamma^{\beta \beta} \gamma^{\beta \beta}}\)

[37]:
kerh.frr.shape[0], kerh.frg.shape[0], kerh.fgg.shape[0]
[37]:
(3, 6, 6)
  • frrr 4: u_u_u, u_u_d, u_d_d, d_d_d

    • \(f_{\rho^\alpha \rho^\alpha \rho^\alpha}, f_{\rho^\alpha \rho^\alpha \rho^\beta}, f_{\rho^\alpha \rho^\beta \rho^\beta}, f_{\rho^\beta \rho^\beta \rho^\beta}\)

  • frrg 9: u_u_uu, u_u_ud, u_u_dd, u_d_uu, u_d_ud, u_d_dd, d_d_uu, d_d_ud, d_d_dd

    • \(f_{\rho^\alpha \rho^\alpha \gamma^{\alpha \alpha}}, f_{\rho^\alpha \rho^\alpha \gamma^{\alpha \beta}}, f_{\rho^\alpha \rho^\alpha \gamma^{\beta \beta}}, f_{\rho^\alpha \rho^\beta \gamma^{\alpha \alpha}}, f_{\rho^\alpha \rho^\beta \gamma^{\alpha \beta}}, f_{\rho^\alpha \rho^\beta \gamma^{\beta \beta}}, f_{\rho^\beta \rho^\beta \gamma^{\alpha \alpha}}, f_{\rho^\beta \rho^\beta \gamma^{\alpha \beta}}, f_{\rho^\beta \rho^\beta \gamma^{\beta \beta}}\)

  • frgg 12: u_uu_uu, u_uu_ud, u_uu_dd, u_ud_ud, u_ud_dd, u_dd_dd, d_uu_uu, d_uu_ud, d_uu_dd, d_ud_ud, d_ud_dd, d_dd_dd

    • \(f_{\rho^\alpha \gamma^{\alpha \alpha} \gamma^{\alpha \alpha}}, f_{\rho^\alpha \gamma^{\alpha \alpha} \gamma^{\alpha \beta}}, f_{\rho^\alpha \gamma^{\alpha \alpha} \gamma^{\beta \beta}}, f_{\rho^\alpha \gamma^{\alpha \beta} \gamma^{\alpha \beta}}, f_{\rho^\alpha \gamma^{\alpha \beta} \gamma^{\beta \beta}}, f_{\rho^\alpha \gamma^{\beta \beta} \gamma^{\beta \beta}}, f_{\rho^\beta \gamma^{\alpha \alpha} \gamma^{\alpha \alpha}}, f_{\rho^\beta \gamma^{\alpha \alpha} \gamma^{\alpha \beta}}, f_{\rho^\beta \gamma^{\alpha \alpha} \gamma^{\beta \beta}}, f_{\rho^\beta \gamma^{\alpha \beta} \gamma^{\alpha \beta}}, f_{\rho^\beta \gamma^{\alpha \beta} \gamma^{\beta \beta}}, f_{\rho^\beta \gamma^{\beta \beta} \gamma^{\beta \beta}}\)

  • fggg 10: uu_uu_uu, uu_uu_ud, uu_uu_dd, uu_ud_ud, uu_ud_dd, uu_dd_dd, ud_ud_ud, ud_ud_dd, ud_dd_dd, dd_dd_dd

    • \(f_{\gamma^{\alpha \alpha} \gamma^{\alpha \alpha} \gamma^{\alpha \alpha}}, f_{\gamma^{\alpha \alpha} \gamma^{\alpha \alpha} \gamma^{\alpha \beta}}, f_{\gamma^{\alpha \alpha} \gamma^{\alpha \alpha} \gamma^{\beta \beta}}, f_{\gamma^{\alpha \alpha} \gamma^{\alpha \beta} \gamma^{\alpha \beta}}, f_{\gamma^{\alpha \alpha} \gamma^{\alpha \beta} \gamma^{\beta \beta}}, f_{\gamma^{\alpha \alpha} \gamma^{\beta \beta} \gamma^{\beta \beta}}, f_{\gamma^{\alpha \beta} \gamma^{\alpha \beta} \gamma^{\alpha \beta}}, f_{\gamma^{\alpha \beta} \gamma^{\alpha \beta} \gamma^{\beta \beta}}, f_{\gamma^{\alpha \beta} \gamma^{\beta \beta} \gamma^{\beta \beta}}, f_{\gamma^{\beta \beta} \gamma^{\beta \beta} \gamma^{\beta \beta}}\)

[38]:
kerh.frrr.shape[0], kerh.frrg.shape[0], kerh.frgg.shape[0], kerh.fggg.shape[0]
[38]:
(4, 9, 12, 10)

能量计算

\[E_\mathrm{elec} = h_{\mu \nu} D_{\mu \nu}^\sigma + \frac{1}{2} (\mu \nu | \kappa \lambda) D_{\mu \nu}^\sigma D_{\kappa \lambda}^{\sigma'} - \frac{c_\mathrm{x}}{2} (\mu \kappa | \nu \lambda) D_{\mu \nu}^\sigma D_{\kappa \lambda}^\sigma + f \rho^\sigma\]
[39]:
(
    + np.einsum("uv, xuv -> ", H_0_ao, D)
    + 0.5 * np.einsum("uvkl, xuv, ykl -> ", eri0_ao, D, D)
    - 0.5 * cx * np.einsum("ukvl, xuv, xkl -> ", eri0_ao, D, D)
    + np.einsum("g, g -> ", kerh.exc, grdh[0].rho_0)
    + np.einsum("g, g -> ", kerh.exc, grdh[1].rho_0)
)
[39]:
-47.22493668809642
[40]:
scf_eng.energy_elec()[0]
[40]:
-47.22493669052412

Fock 矩阵

  • F_0_ao \(F_{\mu \nu}^\sigma\), dim: \((\sigma, \mu, \nu)\), type: np.ndarray

\[F_{\mu \nu}^\sigma \xleftarrow{\textsf{HF contrib}} h_{\mu \nu} + (\mu \nu | \kappa \lambda) D_{\kappa \lambda}^{\sigma'} - c_\mathrm{x} (\mu \kappa | \nu \lambda) D_{\kappa \lambda}^\sigma\]
\[F_{\mu \nu}^\alpha \xleftarrow{\textsf{GGA contrib}} f_{\rho^\alpha} \phi_\mu \phi_\nu + \big[ (2 f_{\gamma^{\alpha \alpha}} \rho_r^\alpha + f_{\gamma^{\alpha \beta}} \rho_r^{\beta}) \phi_{r \mu} \phi_\nu + \mathrm{swap} (\mu, \nu) \big]\]

需要注意,不像其他电子积分的原子轨道矩阵一样,Fock 矩阵是有自旋之分的。生成 Fock 矩阵的过程稍复杂,因此我们需要将 HF 贡献部分先计算,随后计算 GGA 贡献部分,最后加和。

[41]:
F_0_ao = gradh.F_0_ao
F_0_ao.shape
[41]:
(2, 15, 15)
[42]:
F_0_ao_ = (
    + H_0_ao
    + np.einsum("uvkl, ykl -> uv", eri0_ao, D)
    - cx * np.einsum("ukvl, xkl -> xuv", eri0_ao, D)
)
[43]:
F_0_ao_GGA_ = np.zeros_like(F_0_ao_)
F_0_ao_GGA_[0] = (
    + 2 * np.einsum("g, rg, rgu, gv -> uv", kerh.fg[0], grdh[0].rho_1, grdh[0].ao_1, grdh[0].ao_0)
    + np.einsum("g, rg, rgu, gv -> uv", kerh.fg[1], grdh[1].rho_1, grdh[0].ao_1, grdh[0].ao_0)
)
F_0_ao_GGA_[1] = (
    + 2 * np.einsum("g, rg, rgu, gv -> uv", kerh.fg[2], grdh[1].rho_1, grdh[0].ao_1, grdh[0].ao_0)
    + np.einsum("g, rg, rgu, gv -> uv", kerh.fg[1], grdh[0].rho_1, grdh[0].ao_1, grdh[0].ao_0)
)
F_0_ao_GGA_ += F_0_ao_GGA_.swapaxes(-1, -2)
F_0_ao_GGA_ += np.einsum("xg, gu, gv -> xuv", kerh.fr, grdh[0].ao_0, grdh[0].ao_0)
[44]:
F_0_ao_ += F_0_ao_GGA_
[45]:
np.allclose(F_0_ao_, F_0_ao)
[45]:
True
  • F_0_ao \(F_{pq}^\sigma\), dim: \((\sigma, p, q)\), type: np.ndarray

\[F_{pq}^\sigma = F_{\mu \nu}^\sigma C_{\mu p}^\sigma C_{\nu q}^\sigma\]
[46]:
F_0_mo = gradh.F_0_mo
F_0_mo.shape
[46]:
(2, 15, 15)
[47]:
np.allclose(np.einsum("xuv, xup, xvq -> xpq", F_0_ao, C, C), F_0_mo)
[47]:
True

一阶 Skeleton 导数

一阶核坐标梯度

\[E_\mathrm{tot}^\mathbb{A} \xleftarrow{\textsf{HF/nuc contrib}} h_{\mu \nu}^\mathbb{A} D_{\mu \nu}^\sigma + \frac{1}{2} (\mu \nu | \kappa \lambda)^\mathbb{A} D_{\mu \nu}^\sigma D_{\kappa \lambda}^{\sigma'} - \frac{c_\mathrm{x}}{2} (\mu \kappa | \nu \lambda)^\mathbb{A} D_{\mu \nu}^\sigma D_{\kappa \lambda}^{\sigma} - S_{i}^{\mathbb{A}, \sigma} \varepsilon_{i}^\sigma + E_\mathrm{nuc}^\mathbb{A}\]
\[E_\mathrm{tot}^\mathbb{A} \xleftarrow{\textsf{GGA contrib}} f_{\rho^\alpha} \rho^{\mathbb{A}, \alpha} + 2 f_{\gamma^{\alpha \alpha}} \rho_r^{\alpha} \rho_r^{\mathbb{A}, \alpha} + f_{\gamma^{\alpha \beta}} \rho_r^{\beta} \rho_r^{\mathbb{A}, \beta} + \mathrm{swap} (\alpha, \beta)\]
[48]:
E_1 = gradh.E_1
E_1
[48]:
array([[ 0.06565, -0.08058, -0.11324],
       [-0.0919 ,  0.01814,  0.02577],
       [ 0.00907,  0.05126,  0.00839],
       [ 0.01718,  0.01118,  0.07909]])
[49]:
E_1_ = (
    + np.einsum("Auv, xuv -> A", H_1_ao, D)
    + 0.5 * np.einsum("Auvkl, yuv, xkl -> A", eri1_ao, D, D)
    - 0.5 * cx * np.einsum("Aukvl, xuv, xkl -> A", eri1_ao, D, D)
    # - np.einsum("xApq, xpq, xp, xq -> A", S_1_mo, F_0_mo, mo_occ, mo_occ)
    - np.einsum("Ai, i -> A", S_1_mo[0, :, so[0], so[0]].diagonal(axis1=-1, axis2=-2), eo[0])
    - np.einsum("Ai, i -> A", S_1_mo[1, :, so[1], so[1]].diagonal(axis1=-1, axis2=-2), eo[1])
    + grad.rhf.grad_nuc(mol).reshape(-1)
).reshape((natm, 3))
[50]:
E_1_ += (
    + np.einsum("g, Atg -> At", kerh.fr[0], grdh[0].A_rho_1)
    + np.einsum("g, Atg -> At", kerh.fr[1], grdh[1].A_rho_1)
    + 2 * np.einsum("g, rg, Atrg -> At", kerh.fg[0], grdh[0].rho_1, grdh[0].A_rho_2)
    + 2 * np.einsum("g, rg, Atrg -> At", kerh.fg[2], grdh[1].rho_1, grdh[1].A_rho_2)
    + 1 * np.einsum("g, rg, Atrg -> At", kerh.fg[1], grdh[1].rho_1, grdh[0].A_rho_2)
    + 1 * np.einsum("g, rg, Atrg -> At", kerh.fg[1], grdh[0].rho_1, grdh[1].A_rho_2)
)
[51]:
E_1
[51]:
array([[ 0.06565, -0.08058, -0.11324],
       [-0.0919 ,  0.01814,  0.02577],
       [ 0.00907,  0.05126,  0.00839],
       [ 0.01718,  0.01118,  0.07909]])

Fock Skeleton 一阶导数:HF 部分

  • F_1_ao \(F_{\mu \nu}^{\mathbb{A}, \sigma}\), dim: \((\sigma, \mathbb{A}, \mu, \nu)\), type: np.ndarray

[52]:
F_1_ao = gradh.F_1_ao
F_1_ao.shape
[52]:
(2, 12, 15, 15)

该式的推导方式比 RKS 的情形要复杂很多。我们首先需要定义变量

  • A_gamma_1 \(\gamma^{\mathbb{A}, \sigma \sigma'}\), dim: \((\sigma \sigma', A, t, g)\), type: np.ndarray

\[\gamma^{\mathbb{A}, \sigma \sigma'} = \rho_r^{\mathbb{A}, \sigma} \rho_r^{\sigma'} + \rho_r^{\mathbb{A}, \sigma'} \rho_r^{\sigma}\]
[53]:
A_gamma_1 = np.zeros((3, natm, 3, grdh[0].ngrid))
A_gamma_1[0] = 2 * np.einsum("Atrg, rg -> Atg", grdh[0].A_rho_2, grdh[0].rho_1)
A_gamma_1[1] = (
    + np.einsum("Atrg, rg -> Atg", grdh[0].A_rho_2, grdh[1].rho_1)
    + np.einsum("Atrg, rg -> Atg", grdh[1].A_rho_2, grdh[0].rho_1)
)
A_gamma_1[2] = 2 * np.einsum("Atrg, rg -> Atg", grdh[1].A_rho_2, grdh[1].rho_1)

我们之后生成的 Fock 矩阵的 Skeleton 导数会放在变量 F_1_ao_ 中 (后面加上下划线以示区别)。HF 部分的贡献相当容易给出:

\[F_{\mu \nu}^{\mathbb{A}, \sigma} \xleftarrow{\textsf{HF contrib}} h_{\mu \nu}^\mathbb{A} + (\mu \nu | \kappa \lambda)^\mathbb{A} D_{\kappa \lambda}^{\sigma'} - c_\mathrm{x} (\mu \kappa | \nu \lambda)^\mathbb{A} D_{\kappa \lambda}^\sigma\]
[54]:
F_1_ao_ = (
    + H_1_ao
    + np.einsum("Auvkl, ykl -> Auv", eri1_ao, D)
    - cx * np.einsum("Aukvl, xkl -> xAuv", eri1_ao, D)
)

Fock Skeleton 一阶导数:GGA \(\alpha\) 自旋部分

GGA 部分的贡献会很复杂。我们先回顾一下 GGA 对 Fock 矩阵的贡献方式:

\[F_{\mu \nu}^\alpha \xleftarrow{\textsf{GGA contrib}} f_{\rho^\alpha} \phi_\mu \phi_\nu + \big[ (2 f_{\gamma^{\alpha \alpha}} \rho_r^\alpha + f_{\gamma^{\alpha \beta}} \rho_r^{\beta}) \phi_{r \mu} \phi_\nu + \mathrm{swap} (\mu, \nu) \big]\]

我们按照下述方式给出 Skeleton 导数。

  • 0:\(f_{\rho^\alpha}\) 的所有 Skeleton 导数贡献

  • 1:\(f_{\gamma^{\alpha \beta}}\) 关于 \(\rho^\sigma\) 的所有偏导部分的 Skeleton 导数贡献

  • 2:\(f_{\gamma^{\alpha \beta}}\) 关于 \(\gamma^{\sigma \sigma'}\) 的所有偏导部分的 Skeleton 导数贡献

  • 3:上式中出现的 \(\rho_r^\sigma\) 的 Skeleton 导数贡献

  • 4:上式中出现的原子轨道格点的 Skeleton 导数贡献

\[F_{\mu \nu}^{\mathbb{A}, \alpha} \xleftarrow{\textsf{GGA contrib 0}} \left[ f_{\rho^\alpha \rho^\alpha} \rho^{\mathbb{A}, \alpha} + f_{\rho^\alpha \rho^\beta} \rho^{\mathbb{A}, \beta} + f_{\rho^\alpha \gamma^{\alpha \alpha}} \gamma^{\mathbb{A}, \alpha \alpha} + f_{\rho^\alpha \gamma^{\alpha \beta}} \gamma^{\mathbb{A}, \alpha \beta} + f_{\rho^\alpha \gamma^{\beta \beta}} \gamma^{\mathbb{A}, \beta \beta} \right] \phi_\mu \phi_\nu\]
\[F_{\mu \nu}^{\mathbb{A}, \alpha} \xleftarrow{\textsf{GGA contrib 1}} \big[ (2 f_{\rho^\alpha \gamma^{\alpha \alpha}} \rho_r^\alpha + f_{\rho^\alpha \gamma^{\alpha \beta}} \rho_r^{\beta}) \rho^{\mathbb{A}, \alpha} + (2 f_{\rho^\beta \gamma^{\alpha \alpha}} \rho_r^\alpha + f_{\rho^\beta \gamma^{\alpha \beta}} \rho_r^{\beta}) \rho^{\mathbb{A}, \beta} \big] \phi_{r \mu} \phi_\nu + \mathrm{swap} (\mu, \nu)\]
\[F_{\mu \nu}^{\mathbb{A}, \alpha} \xleftarrow{\textsf{GGA contrib 2}} \big[ (2 f_{\gamma^{\alpha \alpha} \gamma^{\alpha \alpha}} \rho_r^\alpha + f_{\gamma^{\alpha \beta} \gamma^{\alpha \alpha}} \rho_r^\beta) \gamma^{\mathbb{A}, \alpha \alpha} + (2 f_{\gamma^{\alpha \alpha} \gamma^{\alpha \beta}} \rho_r^\alpha + f_{\gamma^{\alpha \beta} \gamma^{\alpha \beta}} \rho_r^\beta) \gamma^{\mathbb{A}, \alpha \beta} + (2 f_{\gamma^{\alpha \alpha} \gamma^{\beta \beta}} \rho_r^\alpha + f_{\gamma^{\alpha \beta} \gamma^{\beta \beta}} \rho_r^\beta) \gamma^{\mathbb{A}, \beta \beta} \big] \phi_{r \mu} \phi_\nu + \mathrm{swap} (\mu, \nu)\]
\[F_{\mu \nu}^{\mathbb{A}, \alpha} \xleftarrow{\textsf{GGA contrib 3}} \big[ 2 f_{\gamma^{\alpha \alpha}} \rho_r^{\mathbb{A}, \alpha} + f_{\gamma^{\alpha \beta}} \rho_r^{\mathbb{A}, \beta} \big] \phi_{r \mu} \phi_\nu + \mathrm{swap} (\mu, \nu)\]
\[F_{\mu \nu}^{A_t, \alpha} \xleftarrow{\textsf{GGA contrib 4}} - \big[ f_{\rho^\alpha} \phi_{t \mu_A} \phi_\nu + (2 f_{\gamma^{\alpha \alpha}} \rho_r^\alpha + f_{\gamma^{\alpha \beta}} \rho_r^{\beta}) (\phi_{tr \mu_A} \phi_\nu + \phi_{t \mu_A} \phi_{r \nu}) \big] + \mathrm{swap} (\mu, \nu)\]

由于两种自旋所取用泛函核不同,因此 \(\alpha, \beta\) 两种自旋对 Fock 矩阵 Skeleton 导数的贡献代码应当要分开写。我们将所有 \(\alpha\) 自旋下,GGA 的所有贡献写到 F_1_ao_GGA_alpha_ 中。

  • F_1_ao_GGA_alpha_, type: List[np.ndarray]; for each element in list, dim: \((A, t, \mu, \nu)\)

    • 之所以每个列表元素的维度不是 \((\mathbb{A}, \mu, \nu)\),单纯地是因为在 GridHelper 中的所有格点导数均采用 \((A, t)\)\((n_\mathrm{Atom}, 3)\) 的维度大小的形式,而不是使用 pyxdh 项目中通常会用的 \((\mathbb{A}, )\)\((n_\mathrm{Atom} \times 3, )\) 的形式。

[55]:
F_1_ao_GGA_alpha_ = [None for _ in range(5)]
\[F_{\mu \nu}^{\mathbb{A}, \alpha} \xleftarrow{\textsf{GGA contrib 0}} \left[ f_{\rho^\alpha \rho^\alpha} \rho^{\mathbb{A}, \alpha} + f_{\rho^\alpha \rho^\beta} \rho^{\mathbb{A}, \beta} + f_{\rho^\alpha \gamma^{\alpha \alpha}} \gamma^{\mathbb{A}, \alpha \alpha} + f_{\rho^\alpha \gamma^{\alpha \beta}} \gamma^{\mathbb{A}, \alpha \beta} + f_{\rho^\alpha \gamma^{\beta \beta}} \gamma^{\mathbb{A}, \beta \beta} \right] \phi_\mu \phi_\nu\]
[56]:
tmp = (
    + kerh.frr[0] * grdh[0].A_rho_1 + kerh.frr[1] * grdh[1].A_rho_1
    + kerh.frg[0] * A_gamma_1[0] + kerh.frg[1] * A_gamma_1[1] + kerh.frg[2] * A_gamma_1[2]
)
F_1_ao_GGA_alpha_[0] = np.einsum("Atg, gu, gv -> Atuv", tmp, grdh[0].ao_0, grdh[0].ao_0)
\[F_{\mu \nu}^{\mathbb{A}, \alpha} \xleftarrow{\textsf{GGA contrib 1}} \big[ (2 f_{\rho^\alpha \gamma^{\alpha \alpha}} \rho_r^\alpha + f_{\rho^\alpha \gamma^{\alpha \beta}} \rho_r^{\beta}) \rho^{\mathbb{A}, \alpha} + (2 f_{\rho^\beta \gamma^{\alpha \alpha}} \rho_r^\alpha + f_{\rho^\beta \gamma^{\alpha \beta}} \rho_r^{\beta}) \rho^{\mathbb{A}, \beta} \big] \phi_{r \mu} \phi_\nu + \mathrm{swap} (\mu, \nu)\]
[57]:
tmp = (
    + np.einsum("rg, Atg -> Atrg", 2 * kerh.frg[0] * grdh[0].rho_1 + kerh.frg[1] * grdh[1].rho_1, grdh[0].A_rho_1)
    + np.einsum("rg, Atg -> Atrg", 2 * kerh.frg[3] * grdh[0].rho_1 + kerh.frg[4] * grdh[1].rho_1, grdh[1].A_rho_1)
)
F_1_ao_GGA_alpha_[1] = np.einsum("Atrg, rgu, gv -> Atuv", tmp, grdh[0].ao_1, grdh[0].ao_0)
F_1_ao_GGA_alpha_[1] += F_1_ao_GGA_alpha_[1].swapaxes(-1, -2)
\[F_{\mu \nu}^{\mathbb{A}, \alpha} \xleftarrow{\textsf{GGA contrib 2}} \big[ (2 f_{\gamma^{\alpha \alpha} \gamma^{\alpha \alpha}} \rho_r^\alpha + f_{\gamma^{\alpha \beta} \gamma^{\alpha \alpha}} \rho_r^\beta) \gamma^{\mathbb{A}, \alpha \alpha} + (2 f_{\gamma^{\alpha \alpha} \gamma^{\alpha \beta}} \rho_r^\alpha + f_{\gamma^{\alpha \beta} \gamma^{\alpha \beta}} \rho_r^\beta) \gamma^{\mathbb{A}, \alpha \beta} + (2 f_{\gamma^{\alpha \alpha} \gamma^{\beta \beta}} \rho_r^\alpha + f_{\gamma^{\alpha \beta} \gamma^{\beta \beta}} \rho_r^\beta) \gamma^{\mathbb{A}, \beta \beta} \big] \phi_{r \mu} \phi_\nu + \mathrm{swap} (\mu, \nu)\]
[58]:
tmp = (
    + np.einsum("rg, Atg -> Atrg", 2 * kerh.fgg[0] * grdh[0].rho_1 + kerh.fgg[1] * grdh[1].rho_1, A_gamma_1[0])
    + np.einsum("rg, Atg -> Atrg", 2 * kerh.fgg[1] * grdh[0].rho_1 + kerh.fgg[3] * grdh[1].rho_1, A_gamma_1[1])
    + np.einsum("rg, Atg -> Atrg", 2 * kerh.fgg[2] * grdh[0].rho_1 + kerh.fgg[4] * grdh[1].rho_1, A_gamma_1[2])
)
F_1_ao_GGA_alpha_[2] = np.einsum("Atrg, rgu, gv -> Atuv", tmp, grdh[0].ao_1, grdh[0].ao_0)
F_1_ao_GGA_alpha_[2] += F_1_ao_GGA_alpha_[2].swapaxes(-1, -2)
\[F_{\mu \nu}^{\mathbb{A}, \alpha} \xleftarrow{\textsf{GGA contrib 4}} \big[ 2 f_{\gamma^{\alpha \alpha}} \rho_r^{\mathbb{A}, \alpha} + f_{\gamma^{\alpha \beta}} \rho_r^{\mathbb{A}, \beta} \big] \phi_{r \mu} \phi_\nu + \mathrm{swap} (\mu, \nu)\]
[59]:
tmp = 2 * kerh.fg[0] * grdh[0].A_rho_2 + kerh.fg[1] * grdh[1].A_rho_2
F_1_ao_GGA_alpha_[3] = np.einsum("Atrg, rgu, gv -> Atuv", tmp, grdh[0].ao_1, grdh[0].ao_0)
F_1_ao_GGA_alpha_[3] += F_1_ao_GGA_alpha_[3].swapaxes(-1, -2)
\[F_{\mu \nu}^{A_t, \alpha} \xleftarrow{\textsf{GGA contrib 5}} - \big[ f_{\rho^\alpha} \phi_{t \mu_A} \phi_\nu + (2 f_{\gamma^{\alpha \alpha}} \rho_r^\alpha + f_{\gamma^{\alpha \beta}} \rho_r^{\beta}) (\phi_{tr \mu_A} \phi_\nu + \phi_{t \mu_A} \phi_{r \nu}) \big] + \mathrm{swap} (\mu, \nu)\]
[60]:
mol_slice = gradh.mol_slice
[61]:
tmp = 2 * kerh.fg[0] * grdh[0].rho_1 + kerh.fg[1] * grdh[1].rho_1

F_1_ao_GGA_alpha_[4] = np.zeros((natm, 3, nao, nao))
for A in range(natm):
    sA = mol_slice(A)
    F_1_ao_GGA_alpha_[4][A, :, sA, :] += - np.einsum("g, tgu, gv -> tuv", kerh.fr[0], grdh[0].ao_1[:, :, sA], grdh[0].ao_0)
    F_1_ao_GGA_alpha_[4][A, :, sA, :] += - np.einsum("rg, trgu, gv -> tuv", tmp, grdh[0].ao_2[:, :, :, sA], grdh[0].ao_0)
    F_1_ao_GGA_alpha_[4][A, :, sA, :] += - np.einsum("rg, tgu, rgv -> tuv", tmp, grdh[0].ao_1[:, :, sA], grdh[0].ao_1)
F_1_ao_GGA_alpha_[4] += F_1_ao_GGA_alpha_[4].swapaxes(-1, -2)

最后,我们将 \(\alpha\) 自旋的 GGA 贡献部分加到 HF 贡献部分,得到最终的 \(F_{\mu \nu}^{\mathbb{A}, \alpha}\)

[62]:
F_1_ao_[0] += np.array(F_1_ao_GGA_alpha_).sum(axis=0).reshape((natm * 3, nao, nao))
[63]:
np.allclose(F_1_ao_[0], F_1_ao[0])
[63]:
True

Fock Skeleton 一阶导数:GGA \(\beta\) 自旋部分

但需要留意,我们尚没有实现 \(\beta\) 自旋的贡献部分。尽管思路是相同的,但代码应当要重新写一遍。

[64]:
F_1_ao_GGA_beta_ = [None for _ in range(5)]
\[F_{\mu \nu}^{\mathbb{A}, \beta} \xleftarrow{\textsf{GGA contrib 0}} \left[ f_{\rho^\beta \rho^\alpha} \rho^{\mathbb{A}, \alpha} + f_{\rho^\beta \rho^\beta} \rho^{\mathbb{A}, \beta} + f_{\rho^\beta \gamma^{\alpha \alpha}} \gamma^{\mathbb{A}, \alpha \alpha} + f_{\rho^\beta \gamma^{\beta \alpha}} \gamma^{\mathbb{A}, \beta \alpha} + f_{\rho^\beta \gamma^{\beta \beta}} \gamma^{\mathbb{A}, \beta \beta} \right] \phi_\mu \phi_\nu\]
[65]:
tmp = (
    + kerh.frr[1] * grdh[0].A_rho_1 + kerh.frr[2] * grdh[1].A_rho_1
    + kerh.frg[3] * A_gamma_1[0] + kerh.frg[4] * A_gamma_1[1] + kerh.frg[5] * A_gamma_1[2]
)
F_1_ao_GGA_beta_[0] = np.einsum("Atg, gu, gv -> Atuv", tmp, grdh[0].ao_0, grdh[0].ao_0)
\[F_{\mu \nu}^{\mathbb{A}, \beta} \xleftarrow{\textsf{GGA contrib 1}} \big[ (2 f_{\rho^\alpha \gamma^{\beta \beta}} \rho_r^\beta + f_{\rho^\alpha \gamma^{\beta \alpha}} \rho_r^{\alpha}) \rho^{\mathbb{A}, \alpha} + (2 f_{\rho^\beta \gamma^{\beta \beta}} \rho_r^\beta + f_{\rho^\beta \gamma^{\beta \alpha}} \rho_r^{\alpha}) \rho^{\mathbb{A}, \beta} \big] \phi_{r \mu} \phi_\nu + \mathrm{swap} (\mu, \nu)\]
[66]:
tmp = (
    + np.einsum("rg, Atg -> Atrg", 2 * kerh.frg[2] * grdh[1].rho_1 + kerh.frg[1] * grdh[0].rho_1, grdh[0].A_rho_1)
    + np.einsum("rg, Atg -> Atrg", 2 * kerh.frg[5] * grdh[1].rho_1 + kerh.frg[4] * grdh[0].rho_1, grdh[1].A_rho_1)
)
F_1_ao_GGA_beta_[1] = np.einsum("Atrg, rgu, gv -> Atuv", tmp, grdh[0].ao_1, grdh[0].ao_0)
F_1_ao_GGA_beta_[1] += F_1_ao_GGA_beta_[1].swapaxes(-1, -2)
\[F_{\mu \nu}^{\mathbb{A}, \beta} \xleftarrow{\textsf{GGA contrib 2}} \big[ (2 f_{\gamma^{\beta \beta} \gamma^{\alpha \alpha}} \rho_r^\beta + f_{\gamma^{\beta \alpha} \gamma^{\alpha \alpha}} \rho_r^\alpha) \gamma^{\mathbb{A}, \alpha \alpha} + (2 f_{\gamma^{\beta \beta} \gamma^{\beta \alpha}} \rho_r^\beta + f_{\gamma^{\beta \alpha} \gamma^{\beta \alpha}} \rho_r^\alpha) \gamma^{\mathbb{A}, \beta \alpha} + (2 f_{\gamma^{\beta \beta} \gamma^{\beta \beta}} \rho_r^\beta + f_{\gamma^{\beta \alpha} \gamma^{\beta \beta}} \rho_r^\alpha) \gamma^{\mathbb{A}, \beta \beta} \big] \phi_{r \mu} \phi_\nu + \mathrm{swap} (\mu, \nu)\]
[67]:
tmp = (
    + np.einsum("rg, Atg -> Atrg", 2 * kerh.fgg[2] * grdh[1].rho_1 + kerh.fgg[1] * grdh[0].rho_1, A_gamma_1[0])
    + np.einsum("rg, Atg -> Atrg", 2 * kerh.fgg[4] * grdh[1].rho_1 + kerh.fgg[3] * grdh[0].rho_1, A_gamma_1[1])
    + np.einsum("rg, Atg -> Atrg", 2 * kerh.fgg[5] * grdh[1].rho_1 + kerh.fgg[4] * grdh[0].rho_1, A_gamma_1[2])
)
F_1_ao_GGA_beta_[2] = np.einsum("Atrg, rgu, gv -> Atuv", tmp, grdh[0].ao_1, grdh[0].ao_0)
F_1_ao_GGA_beta_[2] += F_1_ao_GGA_beta_[2].swapaxes(-1, -2)
\[F_{\mu \nu}^{\mathbb{A}, \beta} \xleftarrow{\textsf{GGA contrib 4}} \big[ 2 f_{\gamma^{\beta \beta}} \rho_r^{\mathbb{A}, \beta} + f_{\gamma^{\beta \alpha}} \rho_r^{\mathbb{A}, \alpha} \big] \phi_{r \mu} \phi_\nu + \mathrm{swap} (\mu, \nu)\]
[68]:
tmp = 2 * kerh.fg[2] * grdh[1].A_rho_2 + kerh.fg[1] * grdh[0].A_rho_2
F_1_ao_GGA_beta_[3] = np.einsum("Atrg, rgu, gv -> Atuv", tmp, grdh[0].ao_1, grdh[0].ao_0)
F_1_ao_GGA_beta_[3] += F_1_ao_GGA_beta_[3].swapaxes(-1, -2)
\[F_{\mu \nu}^{A_t, \beta} \xleftarrow{\textsf{GGA contrib 5}} - \big[ f_{\rho^\beta} \phi_{t \mu_A} \phi_\nu + (2 f_{\gamma^{\beta \beta}} \rho_r^\beta + f_{\gamma^{\beta \alpha}} \rho_r^{\alpha}) (\phi_{tr \mu_A} \phi_\nu + \phi_{t \mu_A} \phi_{r \nu}) \big] + \mathrm{swap} (\mu, \nu)\]
[69]:
mol_slice = gradh.mol_slice
[70]:
tmp = 2 * kerh.fg[2] * grdh[1].rho_1 + kerh.fg[1] * grdh[0].rho_1

F_1_ao_GGA_beta_[4] = np.zeros((natm, 3, nao, nao))
for A in range(natm):
    sA = mol_slice(A)
    F_1_ao_GGA_beta_[4][A, :, sA, :] += - np.einsum("g, tgu, gv -> tuv", kerh.fr[1], grdh[0].ao_1[:, :, sA], grdh[0].ao_0)
    F_1_ao_GGA_beta_[4][A, :, sA, :] += - np.einsum("rg, trgu, gv -> tuv", tmp, grdh[0].ao_2[:, :, :, sA], grdh[0].ao_0)
    F_1_ao_GGA_beta_[4][A, :, sA, :] += - np.einsum("rg, tgu, rgv -> tuv", tmp, grdh[0].ao_1[:, :, sA], grdh[0].ao_1)
F_1_ao_GGA_beta_[4] += F_1_ao_GGA_beta_[4].swapaxes(-1, -2)

\(\beta\) 自旋的 GGA 贡献部分加到 HF 贡献部分,得到最终的 \(F_{\mu \nu}^{\mathbb{A}, \beta}\)

[71]:
F_1_ao_[1] += np.array(F_1_ao_GGA_beta_).sum(axis=0).reshape((natm * 3, nao, nao))

最终,我们可以验证,我们计算所得到的 Fock 矩阵与 pyxdh 给出的结果一致:

[72]:
np.allclose(F_1_ao_, F_1_ao)
[72]:
True

需要指出的是,pyxdh 中计算 F_1_ao 的方式并不是直接执行类似于上述的代码,而是利用了 PySCF 的 hessian.uks.make_h1 方法。

A 张量

A 张量计算函数:前置工作

我们应当留意到 A 张量从定义上是从 Fock 矩阵 \(F_{\mu \nu}^\sigma\) 的 U 导数产生的。我们下面对当输入的矩阵 X \(X_{ai}^{\mathbb{A}, \sigma}\) 为任意矩阵时的情况作讨论。这里的 \(\mathbb{A}\) 可以是任意的,未必要指代原子核坐标分量。

[73]:
Ax0_Core = gradh.Ax0_Core
X = (np.random.randn(3, nvir[0], nocc[0]), np.random.randn(3, nvir[1], nocc[1]))

我们将 pyxdh 输出的 \(\mathtt{AX}_{ai}^{\mathbb{A}, \sigma}\) 储存在 Ax 中。由于 A 张量与 X 矩阵之间的缩并方式与 RKS 不太一样,因此我们不会采用类似于 \(A_{ai, bj} X_{bj}^\mathbb{A}\) 的写法。

  • X \(X_{ai}^{\mathbb{A}, \sigma}\), dim: \((\sigma, \mathbb{A}, a, i)\), type: Tuple[np.ndarray]

  • Ax \(\mathtt{AX}_{ai}^{\mathbb{A}, \sigma}\), dim: \((\sigma, \mathbb{A}, a, i)\), type: Tuple[np.ndarray]

需要指出,Ax0_Core 函数不只可以实现非占-占据轨道的计算,也可以实现任意分子轨道下的分割计算。

[74]:
Ax = Ax0_Core(sv, so, sv, so)(X)

在实际进行 A 张量计算前,我们会对输入的 \(X_{ai}^{\mathbb{A}, \sigma}\) 作到原子轨道的转换:

  • dmX \(X_{\mu \nu}^{\mathbb{A}, \sigma}\), dim: \((\sigma, \mathbb{A}, \mu, \nu)\), type: np.ndarray

\[X_{\mu \nu}^{\mathbb{A}, \sigma} = C_{\mu a}^\sigma X_{ai}^{\mathbb{A}, \sigma} C_{\nu i}^\sigma + \mathrm{swap} (\mu, \nu)\]
[75]:
prop_dim = X[0].shape[0]
dmX = np.zeros((2, prop_dim, nao, nao))
dmX[0] = Cv[0] @ X[0] @ Co[0].T
dmX[1] = Cv[1] @ X[1] @ Co[1].T
dmX += dmX.swapaxes(-1, -2)
dmX.shape
[75]:
(2, 3, 15, 15)

A 张量计算函数:HF 贡献部分

  • ax_ao_HF_, dim: \((\sigma, \mathbb{A}, \mu, \nu)\), type: np.ndarray

\[\mathtt{AX}_{\kappa \lambda}^{\mathbb{A}, \sigma} \xleftarrow{\textsf{HF contrib}} (\mu \nu | \kappa \lambda) X_{\kappa \lambda}^{\sigma'} - c_\mathrm{x} (\mu \kappa | \nu \lambda) X_{\kappa \lambda}^{\sigma}\]
[76]:
ax_ao_HF_ = (
    + np.einsum("uvkl, xAkl -> Auv", eri0_ao, dmX)
    - cx * np.einsum("ukvl, xAkl -> xAuv", eri0_ao, dmX)
)
ax_ao_HF_.shape
[76]:
(2, 3, 15, 15)
  • Ax_HF_, dim: \((\sigma, \mathbb{A}, a, i)\)

\[\mathtt{AX}_{ai}^{\mathbb{A}, \sigma} \xleftarrow{\textsf{HF contrib}} C_{\mu a}^\sigma \mathtt{AX}_{\kappa \lambda}^{\mathbb{A}, \sigma} C_{\nu i}^\sigma\]
[77]:
Ax_HF_ = (np.zeros((prop_dim, nvir[0], nocc[0])), np.zeros((prop_dim, nvir[1], nocc[1])))
Ax_HF_[0][:] = Cv[0].T @ ax_ao_HF_[0] @ Co[0]
Ax_HF_[1][:] = Cv[1].T @ ax_ao_HF_[1] @ Co[1]

A 张量计算函数:\(f_{\rho}\) 导数贡献部分

  • rho_X_0 \(\varrho^{\mathbb{A}, \sigma}\), dim: \((\sigma, \mathbb{A}, g)\)

  • rho_X_1 \(\varrho_g^{\mathbb{A}, \sigma}\), dim: \((\sigma, \mathbb{A}, r, g)\)

\[\begin{split}\begin{align} \varrho^{\mathbb{A}, \sigma} &= X_{\kappa \lambda}^{\mathbb{A}, \sigma} \phi_\kappa \phi_\lambda \\ \varrho_r^{\mathbb{A}, \sigma} &= 2 X_{\kappa \lambda}^{\mathbb{A}, \sigma} \phi_{r \kappa} \phi_\lambda \end{align}\end{split}\]
[78]:
tmp_K = np.einsum("xAkl, gl -> xAgk", dmX, grdh[0].ao_0)
rho_X_0 = np.einsum("gk, xAgk -> xAg", grdh[0].ao_0, tmp_K)
rho_X_1 = 2 * np.einsum("rgk, xAgk -> xArg", grdh[0].ao_1, tmp_K)
  • gamma_X_0 \(\gamma^{\mathbb{A}, \sigma \sigma'}\), dim: \((\sigma \sigma', \mathbb{A}, g)\)

需要留意到,这里单纯地因为符号不够用,因此与 Fock Skeleton 导数处使用了相同的 \(\gamma^{\mathbb{A}, \sigma \sigma'}\) 符号。但这两者的定义是不同的 (即使是相似的)。

\[\gamma^{\mathbb{A}, \sigma \sigma'} = \varrho_r^{\mathbb{A}, \sigma} \rho_r^{\sigma'} + \varrho_r^{\mathbb{A}, \sigma'} \rho_r^{\sigma}\]
[79]:
gamma_X_0 = np.zeros((3, prop_dim, grdh[0].ngrid))
gamma_X_0[0] = 2 * np.einsum("Arg, rg -> Ag", rho_X_1[0], grdh[0].rho_1)
gamma_X_0[1] = (
    + np.einsum("Arg, rg -> Ag", rho_X_1[0], grdh[1].rho_1)
    + np.einsum("Arg, rg -> Ag", rho_X_1[1], grdh[0].rho_1)
)
gamma_X_0[2] = 2 * np.einsum("Arg, rg -> Ag", rho_X_1[1], grdh[1].rho_1)
  • M_0_ \(M^{\mathbb{A}, \sigma}\), dim: \((\sigma, \mathbb{A}, g)\)

[80]:
M_0_ = np.zeros((2, prop_dim, ngrid))
\[M^{\mathbb{A}, \alpha} = f_{\rho^\alpha \rho^\alpha} \varrho^{\mathbb{A}, \alpha} + f_{\rho^\alpha \rho^\beta} \varrho^{\mathbb{A}, \beta} + f_{\rho^\alpha \gamma^{\alpha \alpha}} \gamma_r^{\mathbb{A}, \alpha \alpha} + f_{\rho^\alpha \gamma^{\alpha \beta}} \gamma_r^{\mathbb{A}, \alpha \beta} + f_{\rho^\alpha \gamma^{\beta \beta}} \gamma_r^{\mathbb{A}, \beta \beta}\]
[81]:
M_0_[0] = (
    + kerh.frr[0] * rho_X_0[0]
    + kerh.frr[1] * rho_X_0[1]
    + kerh.frg[0] * gamma_X_0[0]
    + kerh.frg[1] * gamma_X_0[1]
    + kerh.frg[2] * gamma_X_0[2]
)
\[M^{\mathbb{A}, \beta} = f_{\rho^\beta \rho^\alpha} \varrho^{\mathbb{A}, \alpha} + f_{\rho^\beta \rho^\beta} \varrho^{\mathbb{A}, \beta} + f_{\rho^\beta \gamma^{\alpha \alpha}} \gamma^{\mathbb{A}, \alpha \alpha} + f_{\rho^\beta \gamma^{\beta \alpha}} \gamma^{\mathbb{A}, \beta \alpha} + f_{\rho^\beta \gamma^{\beta \beta}} \gamma^{\mathbb{A}, \beta \beta}\]
[82]:
M_0_[1] = (
    + kerh.frr[1] * rho_X_0[0]
    + kerh.frr[2] * rho_X_0[1]
    + kerh.frg[3] * gamma_X_0[0]
    + kerh.frg[4] * gamma_X_0[1]
    + kerh.frg[5] * gamma_X_0[2]
)
  • Ax_GGA_M_0_, dim: \((\sigma, \mathbb{A}, a, i)\), type: Tuple[np.ndarray]

\[\mathtt{AX}_{ai}^{\mathbb{A}, \alpha} \xleftarrow{M^{\mathbb{A}, \alpha} \textsf{ contrib}} M^{\mathbb{A}, \alpha} \phi_\mu \phi_\nu C_{\mu a}^\alpha C_{\nu i}^\alpha\]
[83]:
ax_ao_M_0_ = np.einsum("xAg, gu, gv -> xAuv", M_0_, grdh[0].ao_0, grdh[0].ao_0)
[84]:
Ax_GGA_M_0_ = (
    np.einsum("Auv, ua, vi -> Aai", ax_ao_M_0_[0], Cv[0], Co[0]),
    np.einsum("Auv, ua, vi -> Aai", ax_ao_M_0_[1], Cv[1], Co[1])
)

A 张量计算函数:\(f_{\gamma}\)\(\rho\) 导数贡献部分

  • M_1_ \(M_r^{\mathbb{A}, \sigma}\), dim: \((\sigma, \mathbb{A}, r, g)\)

[85]:
M_1_ = np.zeros((2, prop_dim, 3, ngrid))
\[\begin{split}\begin{align} M_r^{\mathbb{A}, \alpha} &= (2 f_{\rho^\alpha \gamma^{\alpha \alpha}} \rho_r^\alpha + f_{\rho^\alpha \gamma^{\alpha \beta}} \rho_r^{\beta}) \varrho^{\mathbb{A}, \alpha} + (2 f_{\rho^\beta \gamma^{\alpha \alpha}} \rho_r^\alpha + f_{\rho^\beta \gamma^{\alpha \beta}} \rho_r^{\beta}) \varrho^{\mathbb{A}, \beta} \\ &\quad+ (2 f_{\gamma^{\alpha \alpha} \gamma^{\alpha \alpha}} \rho_r^\alpha + f_{\gamma^{\alpha \beta} \gamma^{\alpha \alpha}} \rho_r^\beta) \gamma^{\mathbb{A}, \alpha \alpha} + (2 f_{\gamma^{\alpha \alpha} \gamma^{\alpha \beta}} \rho_r^\alpha + f_{\gamma^{\alpha \beta} \gamma^{\alpha \beta}} \rho_r^\beta) \gamma^{\mathbb{A}, \alpha \beta} + (2 f_{\gamma^{\alpha \alpha} \gamma^{\beta \beta}} \rho_r^\alpha + f_{\gamma^{\alpha \beta} \gamma^{\beta \beta}} \rho_r^\beta) \gamma^{\mathbb{A}, \beta \beta} \\ &\quad+ 2 f_{\gamma^{\alpha \alpha}} \varrho_r^{\mathbb{A}, \alpha} + f_{\gamma^{\alpha \beta}} \varrho_r^{\mathbb{A}, \beta} \end{align}\end{split}\]
[86]:
M_1_[0] = (
    + np.einsum("rg, Ag -> Arg", 2 * kerh.frg[0] * grdh[0].rho_1 + kerh.frg[1] * grdh[1].rho_1, rho_X_0[0])
    + np.einsum("rg, Ag -> Arg", 2 * kerh.frg[3] * grdh[0].rho_1 + kerh.frg[4] * grdh[1].rho_1, rho_X_0[1])
    + np.einsum("rg, Ag -> Arg", 2 * kerh.fgg[0] * grdh[0].rho_1 + kerh.fgg[1] * grdh[1].rho_1, gamma_X_0[0])
    + np.einsum("rg, Ag -> Arg", 2 * kerh.fgg[1] * grdh[0].rho_1 + kerh.fgg[3] * grdh[1].rho_1, gamma_X_0[1])
    + np.einsum("rg, Ag -> Arg", 2 * kerh.fgg[2] * grdh[0].rho_1 + kerh.fgg[4] * grdh[1].rho_1, gamma_X_0[2])
    + 2 * kerh.fg[0] * rho_X_1[0] + kerh.fg[1] * rho_X_1[1]
)
\[\begin{split}\begin{align} M_r^{\mathbb{A}, \beta} &= (2 f_{\rho^\alpha \gamma^{\beta \beta}} \rho_r^\beta + f_{\rho^\alpha \gamma^{\beta \alpha}} \rho_r^{\alpha}) \varrho^{\mathbb{A}, \alpha} + (2 f_{\rho^\beta \gamma^{\beta \beta}} \rho_r^\beta + f_{\rho^\beta \gamma^{\beta \alpha}} \rho_r^{\alpha}) \varrho^{\mathbb{A}, \beta} \\ &\quad+ (2 f_{\gamma^{\beta \beta} \gamma^{\alpha \alpha}} \rho_r^\beta + f_{\gamma^{\beta \alpha} \gamma^{\alpha \alpha}} \rho_r^\alpha) \gamma^{\mathbb{A}, \alpha \alpha} + (2 f_{\gamma^{\beta \beta} \gamma^{\beta \alpha}} \rho_r^\beta + f_{\gamma^{\beta \alpha} \gamma^{\beta \alpha}} \rho_r^\alpha) \gamma^{\mathbb{A}, \beta \alpha} + (2 f_{\gamma^{\beta \beta} \gamma^{\beta \beta}} \rho_r^\beta + f_{\gamma^{\beta \alpha} \gamma^{\beta \beta}} \rho_r^\alpha) \gamma^{\mathbb{A}, \beta \beta} \\ &\quad+ 2 f_{\gamma^{\beta \beta}} \varrho_r^{\mathbb{A}, \beta} + f_{\gamma^{\beta \alpha}} \varrho_r^{\mathbb{A}, \alpha} \end{align}\end{split}\]
[87]:
M_1_[1] = (
    + np.einsum("rg, Ag -> Arg", 2 * kerh.frg[2] * grdh[1].rho_1 + kerh.frg[1] * grdh[0].rho_1, rho_X_0[0])
    + np.einsum("rg, Ag -> Arg", 2 * kerh.frg[5] * grdh[1].rho_1 + kerh.frg[4] * grdh[0].rho_1, rho_X_0[1])
    + np.einsum("rg, Ag -> Arg", 2 * kerh.fgg[2] * grdh[1].rho_1 + kerh.fgg[1] * grdh[0].rho_1, gamma_X_0[0])
    + np.einsum("rg, Ag -> Arg", 2 * kerh.fgg[4] * grdh[1].rho_1 + kerh.fgg[3] * grdh[0].rho_1, gamma_X_0[1])
    + np.einsum("rg, Ag -> Arg", 2 * kerh.fgg[5] * grdh[1].rho_1 + kerh.fgg[4] * grdh[0].rho_1, gamma_X_0[2])
    + 2 * kerh.fg[2] * rho_X_1[1] + kerh.fg[1] * rho_X_1[0]
)
  • Ax_GGA_M_1_, dim: \((\sigma, \mathbb{A}, a, i)\), type: Tuple[np.ndarray]

\[\mathtt{AX}_{ai}^{\mathbb{A}, \alpha} \xleftarrow{M_r^{\mathbb{A}, \alpha} \textsf{ contrib}} M_r^{\mathbb{A}, \alpha} \phi_{r \mu} \phi_\nu C_{\mu a}^\alpha C_{\nu i}^\alpha + \mathrm{swap} (\mu, \nu)\]
[88]:
ax_ao_M_1_ = np.einsum("xArg, rgu, gv -> xAuv", M_1_, grdh[0].ao_1, grdh[0].ao_0)
ax_ao_M_1_ += ax_ao_M_1_.swapaxes(-1, -2)
[89]:
Ax_GGA_M_1_ = (
    np.einsum("Auv, ua, vi -> Aai", ax_ao_M_1_[0], Cv[0], Co[0]),
    np.einsum("Auv, ua, vi -> Aai", ax_ao_M_1_[1], Cv[1], Co[1])
)

最后我们可以验证一下计算过程的正确性:

[90]:
(
    np.allclose(Ax_HF_[0] + Ax_GGA_M_0_[0] + Ax_GGA_M_1_[0], Ax[0]),
    np.allclose(Ax_HF_[1] + Ax_GGA_M_0_[1] + Ax_GGA_M_1_[1], Ax[1])
)
[90]:
(True, True)

作者注意到了 A 张量的生成过程与 Fock Skeleton 导数生成的过程极其相似。在以后的程序版本中,可能会考虑引入对 M_0 \(M^{\mathbb{A}, \sigma}\)M_1 \(M_r^{\mathbb{A}, \sigma}\) 的显式定义,避免程序重复并避免编写失误。

一阶 U 矩阵

事实上,为一阶梯度所使用的所有变量都已经考虑完毕了 (因为一阶梯度不需要使用一阶 U 矩阵)。在此生成的一阶 U 矩阵有两个目的:其一是用来验证各种数值梯度;其二是熟悉 UKS 下 CP-HF 方程。

数值一阶 U 矩阵

  • nd_C \(\partial_\mathbb{A} C_{\mu p}^\sigma\), dim: \((\sigma, \mathbb{A}, \mu, p)\),通过数值导数获得

  • C_inv \((\mathbf{C}^\sigma)^{-1}_{p \mu}\), dim: \((\sigma, p, \mu)\)

[91]:
nd_C = NumericDiff(gradn, lambda gradh: gradh.C).derivative.swapaxes(0, 1)
nd_C.shape
[91]:
(2, 12, 15, 15)
[92]:
C_inv = np.array([np.linalg.inv(C[0]), np.linalg.inv(C[1])])
C_inv.shape
[92]:
(2, 15, 15)

利用 \(\partial_\mathbb{A} C_{\mu p}^\sigma = C_{\mu m}^\sigma U_{mp}^{\mathbb{A}, \sigma}\),我们能得到数值 U 矩阵

  • nd_U \(U_{mp}^{\mathbb{A}, \sigma}\), dim: \((\sigma, \mathbb{A}, m, p)\),通过数值导数获得

[93]:
nd_U = np.einsum("xmu, xAup -> xAmp", C_inv, nd_C)

我们能用数值 U 矩阵立即验证下述公式:

\[U_{pq}^{\mathbb{A}, \sigma} + U_{qp}^{\mathbb{A}, \sigma} + S_{pq}^{\mathbb{A}, \sigma} = 0\]
[94]:
plot_diff(nd_U + nd_U.swapaxes(-1, -2), - S_1_mo)

B 矩阵

  • B_1 \(B_{pq}^{\mathbb{A}, \sigma}\), dim: \((\sigma, \mathbb{A}, p, q)\)

\[B_{pq}^{\mathbb{A}, \sigma} = F_{pq}^{\mathbb{A}, \sigma} - S_{pq}^{\mathbb{A}, \sigma} \varepsilon_q^\sigma - \frac{1}{2} \mathtt{Ax}_{pq}^\sigma [S_{ij}^{\sigma'}]\]
[95]:
B_1 = gradh.B_1
B_1.shape
[95]:
(2, 12, 15, 15)
[96]:
F_1_mo = gradh.F_1_mo
B_1_ = (
    + F_1_mo
    - np.einsum("xApq, xq -> xApq", S_1_mo, e)
    - 0.5 * np.array(Ax0_Core(sa, sa, so, so)((S_1_mo[0, :, so[0], so[0]], S_1_mo[1, :, so[1], so[1]])))
)
[97]:
np.allclose(B_1_, B_1)
[97]:
True

CP-HF 方程验证

\[- (\varepsilon_a^\sigma - \varepsilon_i^\sigma) U_{ai}^{\mathbb{A}, \sigma} - \mathtt{Ax}_{ai}^\sigma [U_{bj}^{\mathbb{A}, \sigma'}] = B_{ai}^{\mathbb{A}, \sigma}\]
[98]:
plot_diff(
    - (ev[0][:, None] - eo[0][None, :]) * nd_U[0][:, sv[0], so[0]]
    - Ax0_Core(sv, so, sv, so)((nd_U[0][:, sv[0], so[0]], nd_U[1][:, sv[1], so[1]]))[0]
    ,
    B_1[0][:, sv[0], so[0]]
)

上述等式还可以将 \(a, i\) 拓展到 \(p \neq q\) 的情形。

CP-HF 方程求解

在 PySCF 中,求解 Unrestricted CP-HF 方程需要使用的是 scf.ucphf 模块。为了讨论问题方便,即使是求取 U 矩阵,我们仍然不会使用到 solve_withs1 函数,而实际使用的是 solve_nos1 函数。当然,在 PySCF 中,两个函数都会被打包在 solve 函数中。

与 Restricted 情形完全不同的是,ucphf.solve 在计算过程中的传参方式与 cphf.solve 完全不同,尽管两个函数有非常类似的参数签名 (signature)。因此,我们传入的第一个参数不可以再像 Restricted CP-HF 一样使用 Ax0_Core(sv, so, sv, so),而必须要额外定义一个函数 fx。传参过程中,U 矩阵会转化为维度 \((\mathbb{A}, n_\mathrm{vir}^\alpha n_\mathrm{occ}^\alpha + n_\mathrm{vir}^\beta n_\mathrm{occ}^\beta)\) 的中间矩阵;这些中间矩阵在下述函数中为 Xresult 所指代。

[99]:
def fx(X):
    prop_dim = X.shape[0]
    X_alpha = X[:, :nocc[0] * nvir[0]].reshape((prop_dim, nvir[0], nocc[0]))
    X_beta = X[:, nocc[0] * nvir[0]:].reshape((prop_dim, nvir[1], nocc[1]))
    Ax = Ax0_Core(sv, so, sv, so, in_cphf=True)((X_alpha, X_beta))
    result = np.concatenate([Ax[0].reshape(prop_dim, -1), Ax[1].reshape(prop_dim, -1)], axis=1)
    return result
  • U_1_vo_ \(U_{ai}^{\mathbb{A}, \sigma}\), dim: \((\sigma, \mathbb{A}, a, i)\), type: Tuple[np.ndarray]

[100]:
U_1_vo_ = ucphf.solve(fx, e, mo_occ, (B_1[0, :, sv[0], so[0]], B_1[1, :, sv[1], so[1]]), max_cycle=100, tol=1e-10)[0]
[101]:
plot_diff(U_1_vo_[0], nd_U[0][:, sv[0], so[0]])
[102]:
plot_diff(U_1_vo_[1], nd_U[1][:, sv[1], so[1]])

完整未“旋转”的 U 矩阵

  • U_1_ \(U_{pq}^{\mathbb{A}, \sigma}\), dim: \((\sigma, \mathbb{A}, p, q)\)

[103]:
U_1_ = np.zeros((2, natm * 3, nmo, nmo))
U_1_[0, :, sv[0], so[0]] = U_1_vo_[0]
U_1_[1, :, sv[1], so[1]] = U_1_vo_[1]
\[U_{ia}^{\mathbb{A}, \sigma} = - S_{ia}^{\mathbb{A}, \sigma} - U_{ai}^{\mathbb{A}, \sigma}\]
[104]:
U_1_[0, :, so[0], sv[0]] = - S_1_mo[0, :, so[0], sv[0]] - U_1_vo_[0].swapaxes(-1, -2)
U_1_[1, :, so[1], sv[1]] = - S_1_mo[1, :, so[1], sv[1]] - U_1_vo_[1].swapaxes(-1, -2)
\[U_{ij}^{\mathbb{A}, \sigma} = - \frac{\mathtt{A}_{ij}^\sigma [U_{ck}^{\mathbb{A}, \sigma'}] + B_{ij}^{\mathbb{A}, \sigma}}{\varepsilon_i^\sigma - \varepsilon_j^\sigma} \quad i \neq j\]

相同的公式可以用于非占-非占的情形。

[105]:
Ax_oo = Ax0_Core(so, so, sv, so)(U_1_vo_)
Ax_vv = Ax0_Core(sv, sv, sv, so)(U_1_vo_)
[106]:
U_1_[0, :, so[0], so[0]] = - (Ax_oo[0] + B_1[0, :, so[0], so[0]]) / (eo[0][:, None] - eo[0][None, :])
U_1_[1, :, so[1], so[1]] = - (Ax_oo[1] + B_1[1, :, so[1], so[1]]) / (eo[1][:, None] - eo[1][None, :])
U_1_[0, :, sv[0], sv[0]] = - (Ax_vv[0] + B_1[0, :, sv[0], sv[0]]) / (ev[0][:, None] - ev[0][None, :])
U_1_[1, :, sv[1], sv[1]] = - (Ax_vv[1] + B_1[1, :, sv[1], sv[1]]) / (ev[1][:, None] - ev[1][None, :])
\[U_{pp}^{\mathbb{A}, \sigma} = - \frac{1}{2} S_{pp}^{\mathbb{A}, \sigma}\]
[107]:
for p in range(nmo):
    U_1_[:, :, p, p] = - S_1_mo[:, :, p, p] / 2
[108]:
plot_diff(U_1_, nd_U)

Unrestricted MP2 一阶梯度与中间量

准备工作

[1]:
%load_ext autoreload
%autoreload 2
%matplotlib notebook

from matplotlib import pyplot as plt
import numpy as np
from pyscf import gto, scf, grad, mp
from pyscf.scf import ucphf
from functools import partial
from pyxdh.DerivOnce import GradUMP2
from pyxdh.Utilities import NucCoordDerivGenerator, NumericDiff
import warnings

np.einsum = partial(np.einsum, optimize=["greedy", 1024 ** 3 * 2 / 8])
np.allclose = partial(np.allclose, atol=1e-6, rtol=1e-4)
np.set_printoptions(5, linewidth=180, suppress=True)
warnings.filterwarnings("ignore")
[2]:
mol = gto.Mole()
mol.atom = """
C  0. 0. 0.
H  1. 0. 0.
H  0. 2. 0.
H  0. 0. 1.5
"""
mol.basis = "6-31G"
mol.spin = 1
mol.verbose = 0
mol.build()
[2]:
<pyscf.gto.mole.Mole at 0x7fdec1f09160>
[3]:
scf_eng = scf.UHF(mol).run()
scf_eng.e_tot
[3]:
-39.315520907160426

下面我们用 PySCF 计算一些 MP2 一阶梯度中的一些中间结论。首先是 MP2 的相关能:

[4]:
mp2_eng = mp.UMP2(scf_eng).run()
mp2_eng.e_corr
[4]:
-0.06954272279822271

MP2 的梯度可以求取如下:

[5]:
mp2_grad = grad.ump2.Gradients(mp2_eng).run()
mp2_grad.de
[5]:
array([[ 0.07528, -0.04775, -0.1069 ],
       [-0.09696,  0.01381,  0.02217],
       [ 0.0069 ,  0.02597,  0.00598],
       [ 0.01478,  0.00797,  0.07876]])

上述梯度中,相关能的梯度贡献可以通过下述方式求取得到:

[6]:
scf_grad = grad.uhf.Gradients(scf_eng).run()
mp2_grad.de - scf_grad.de
[6]:
array([[ 0.01346, -0.01382,  0.01127],
       [-0.01264,  0.00222, -0.00289],
       [ 0.00111,  0.01063,  0.00073],
       [-0.00193,  0.00097, -0.0091 ]])

所有与 SCF 一阶梯度有关的量定义如下:

[7]:
gradh = GradUMP2({"scf_eng": scf_eng, "cphf_tol": 1e-10})
gradh.eng
[7]:
-39.38506362995865
[8]:
nmo, nao, natm = gradh.nmo, gradh.nao, gradh.natm
nocc, nvir = gradh.nocc, gradh.nvir
so, sv, sa = gradh.so, gradh.sv, gradh.sa
C, e = gradh.C, gradh.e
Co, eo = gradh.Co, gradh.eo
Cv, ev = gradh.Cv, gradh.ev
mo_occ = gradh.mo_occ
[9]:
H_0_ao, H_0_mo, H_1_ao, H_1_mo = gradh.H_0_ao, gradh.H_0_mo, gradh.H_1_ao, gradh.H_1_mo
S_0_ao, S_0_mo, S_1_ao, S_1_mo = gradh.S_0_ao, gradh.S_0_mo, gradh.S_1_ao, gradh.S_1_mo
F_0_ao, F_0_mo, F_1_ao, F_1_mo = gradh.F_0_ao, gradh.F_0_mo, gradh.F_1_ao, gradh.F_1_mo
eri0_ao, eri0_mo, eri1_ao, eri1_mo = gradh.eri0_ao, gradh.eri0_mo, gradh.eri1_ao, gradh.eri1_mo
[10]:
Ax0_Core = gradh.Ax0_Core

MP2 能量计算中间张量

  • D_iajb \(D_{ij}^{ab, \sigma \sigma'}\), dim: \((\sigma \sigma', i, a, j, b)\), type: Tuple[np.ndarray]

\[D_{ij}^{ab, \sigma \sigma'} = \varepsilon_i^\sigma - \varepsilon_a^\sigma + \varepsilon_j^{\sigma'} - \varepsilon_b^{\sigma'}\]

其中,\(i, a\)\(\sigma\) 自旋,\(j, b\)\(\sigma'\) 自旋;后同。

[11]:
D_iajb = gradh.D_iajb
[t.shape for t in D_iajb]
[11]:
[(5, 10, 5, 10), (5, 10, 4, 11), (4, 11, 4, 11)]
[12]:
D_iajb_ = (
    eo[0][:, None, None, None] - ev[0][None, :, None, None] + eo[0][None, None, :, None] - ev[0][None, None, None, :],
    eo[0][:, None, None, None] - ev[0][None, :, None, None] + eo[1][None, None, :, None] - ev[1][None, None, None, :],
    eo[1][:, None, None, None] - ev[1][None, :, None, None] + eo[1][None, None, :, None] - ev[1][None, None, None, :]
)
[np.allclose(t_, t) for t_, t in zip(D_iajb_, D_iajb)]
[12]:
[True, True, True]
  • t_iajb \(t_{ij}^{ab, \sigma \sigma'}\), dim: \((\sigma \sigma', i, a, j, b)\), type: Tuple[np.ndarray]

\[t_{ij}^{ab, \sigma \sigma'} = \frac{(ia|jb)^{\sigma \sigma'}}{D_{ij}^{ab, \sigma \sigma'}}\]
[13]:
t_iajb = gradh.t_iajb
[t.shape for t in t_iajb]
[13]:
[(5, 10, 5, 10), (5, 10, 4, 11), (4, 11, 4, 11)]
[14]:
t_iajb_ = (
    eri0_mo[0][so[0], sv[0], so[0], sv[0]] / D_iajb[0],
    eri0_mo[1][so[0], sv[0], so[1], sv[1]] / D_iajb[1],
    eri0_mo[2][so[1], sv[1], so[1], sv[1]] / D_iajb[2]
)
[np.allclose(t_, t) for t_, t in zip(t_iajb_, t_iajb)]
[14]:
[True, True, True]
  • T_iajb \(T_{ij}^{ab, \sigma \sigma'}\), dim: \((\sigma \sigma', i, a, j, b)\), type: Tuple[np.ndarray]

\[\begin{split}\begin{align} T_{ij}^{ab, \alpha \alpha} &= c_\mathrm{c} c_\mathrm{SS} \frac{1}{2} (t_{ij}^{ab, \alpha \alpha} - t_{ij}^{ba, \alpha \alpha}) \\ T_{ij}^{ab, \alpha \beta} &= c_\mathrm{c} c_\mathrm{OS} t_{ij}^{ab, \alpha \beta} \end{align}\end{split}\]

对于 \(T_{ij}^{ab, \beta \beta}\),其情况可以通过将 RHS \(\alpha\)\(\beta\) 互换得到。在普通的 MP2 中,

\[c_\mathrm{c} = c_\mathrm{SS} = c_\mathrm{OS} = 1\]

因此我们可以在尝试 MP2 的实现时可以简化一些代码。但对于 XYGJ-OS 而言,

\[c_\mathrm{c} = 0.4364, \quad c_\mathrm{SS} = 0, \quad c_\mathrm{OS} = 1\]

因此在尝试这些泛函时,需要少许改变下述的代码。

[15]:
T_iajb = gradh.T_iajb
[t.shape for t in T_iajb]
[15]:
[(5, 10, 5, 10), (5, 10, 4, 11), (4, 11, 4, 11)]
[16]:
T_iajb_ = (
    0.5 * (t_iajb[0] - t_iajb[0].swapaxes(-1, -3)),
    t_iajb[1],
    0.5 * (t_iajb[2] - t_iajb[2].swapaxes(-1, -3))
)
[np.allclose(t_, t) for t_, t in zip(T_iajb_, T_iajb)]
[16]:
[True, True, True]

最后,我们可以复现 MP2 的能量:

\[E_\mathrm{corr} = T_{ij}^{ab, \sigma \sigma'} t_{ij}^{ab, \sigma \sigma'} D_{ij}^{ab, \sigma \sigma'}\]
[17]:
np.array([(T_iajb[i] * t_iajb[i] * D_iajb[i]).sum() for i in range(3)]).sum()
[17]:
-0.06954272279822277
[18]:
mp2_eng.e_corr
[18]:
-0.06954272279822271

MP2 梯度中间张量

\(W_{pq}^{\sigma, \mathrm{MP2}}[\mathrm{I}]\)

  • W_I \(W_{pq}^{\sigma, \mathrm{MP2}}[\mathrm{I}]\), dim: \((\sigma, p, q)\);但需要对 \(\alpha, \beta\) 两种自旋分开生成

[19]:
W_I = np.zeros((2, nmo, nmo))
\[W_{ij}^{\alpha, \mathrm{MP2}} [\mathrm{I}] = - 2 T_{ik}^{ab, \alpha \alpha} (ja|kb)^{\alpha \alpha} - T_{ik}^{ab, \alpha \beta} (ja|kb)^{\alpha \beta}\]

\(\beta\) 自旋的情况,交换上式 \(\alpha, \beta\) 即可。

[20]:
W_I[0, so[0], so[0]] = (
    - 2 * np.einsum("iakb, jakb -> ij", T_iajb[0], t_iajb[0] * D_iajb[0])
    -     np.einsum("iakb, jakb -> ij", T_iajb[1], t_iajb[1] * D_iajb[1]))
W_I[1, so[1], so[1]] = (
    - 2 * np.einsum("iakb, jakb -> ij", T_iajb[2], t_iajb[2] * D_iajb[2])
    -     np.einsum("kbia, kbja -> ij", T_iajb[1], t_iajb[1] * D_iajb[1]))
\[W_{ab}^{\alpha, \mathrm{PT2}} [\mathrm{I}] = - 2 T_{ij}^{ac, \alpha \alpha} (ib|jc)^{\alpha \alpha} - T_{ij}^{ac, \alpha \beta} (ib|jc)^{\alpha \beta}\]
[21]:
W_I[0, sv[0], sv[0]] = (
    - 2 * np.einsum("iajc, ibjc -> ab", T_iajb[0], t_iajb[0] * D_iajb[0])
    -     np.einsum("iajc, ibjc -> ab", T_iajb[1], t_iajb[1] * D_iajb[1]))
W_I[1, sv[1], sv[1]] = (
    - 2 * np.einsum("iajc, ibjc -> ab", T_iajb[2], t_iajb[2] * D_iajb[2])
    -     np.einsum("jcia, jcib -> ab", T_iajb[1], t_iajb[1] * D_iajb[1]))
\[W_{ai}^{\alpha, \mathrm{PT2}} [\mathrm{I}] = - 4 T_{jk}^{ab, \alpha \alpha} (ij|bk)^{\alpha \alpha} - 2 T_{jk}^{ab, \alpha \beta} (ij|bk)^{\alpha \beta}\]
[22]:
W_I[0, sv[0], so[0]] = (
    - 4 * np.einsum("jakb, ijbk -> ai", T_iajb[0], eri0_mo[0][so[0], so[0], sv[0], so[0]])
    - 2 * np.einsum("jakb, ijbk -> ai", T_iajb[1], eri0_mo[1][so[0], so[0], sv[1], so[1]]))
W_I[1, sv[1], so[1]] = (
    - 4 * np.einsum("jakb, ijbk -> ai", T_iajb[2], eri0_mo[2][so[1], so[1], sv[1], so[1]])
    - 2 * np.einsum("kbja, bkij -> ai", T_iajb[1], eri0_mo[1][sv[0], so[0], so[1], so[1]]))

可能需要留意,由于我们的程序中只有 eri0_mo[1] \((pq|rs)^{\alpha \beta}\) 而没有 \((pq|rs)^{\beta \alpha}\),因此在程序中若要对后者作张量缩并,首先需要转置为前者的情况,或者在 np.einsum 中调整缩并角标的顺序。

\(D_{ij}^{\sigma, \mathrm{MP2}}\), \(D_{ab}^{\sigma, \mathrm{MP2}}\)

  • D_r_oovv \(D_{pq}^{\sigma, \mathrm{MP2}}\), only occ-occ and vir-vir part, dim: \((\sigma, p, q)\)

[23]:
D_r_oovv = np.zeros((2, nmo, nmo))
\[D_{ij}^{\alpha, \text{MP2}} = - 2 T_{ik}^{ab, \alpha \alpha} t_{jk}^{ab, \alpha \alpha} - T_{ik}^{ab, \alpha \beta} t_{jk}^{ab, \alpha \beta}\]
[24]:
D_r_oovv[0, so[0], so[0]] = (
    - 2 * np.einsum("iakb, jakb -> ij", T_iajb[0], t_iajb[0])
    -     np.einsum("iakb, jakb -> ij", T_iajb[1], t_iajb[1]))
D_r_oovv[1, so[1], so[1]] = (
    - 2 * np.einsum("iakb, jakb -> ij", T_iajb[2], t_iajb[2])
    -     np.einsum("kbia, kbja -> ij", T_iajb[1], t_iajb[1]))
\[D_{ab}^{\alpha, \text{MP2}} = 2 T_{ij}^{ac, \alpha \alpha} t_{ij}^{bc, \alpha \alpha} + T_{ij}^{ac, \alpha \beta} t_{ij}^{bc, \alpha \beta}\]
[25]:
D_r_oovv[0, sv[0], sv[0]] = (
    + 2 * np.einsum("iajc, ibjc -> ab", T_iajb[0], t_iajb[0])
    +     np.einsum("iajc, ibjc -> ab", T_iajb[1], t_iajb[1]))
D_r_oovv[1, sv[1], sv[1]] = (
    + 2 * np.einsum("iajc, ibjc -> ab", T_iajb[2], t_iajb[2])
    +     np.einsum("jcia, jcib -> ab", T_iajb[1], t_iajb[1]))

\(L_{ai}^\sigma\)

  • L \(L_{ai}^\sigma\), dim: \((\sigma, a, i)\), type: Tuple[np.ndarray]

\[L_{ai}^\alpha = \mathtt{Ax}_{ai}^\alpha [D_{kl}^{\sigma, \mathrm{MP2}}] + \mathtt{Ax}_{ai}^\alpha [D_{bc}^{\sigma, \mathrm{MP2}}] - 4 T_{jk}^{ab, \alpha \alpha} (ij|bk)^{\alpha \alpha} - 4 T_{jk}^{ab, \alpha \beta} (ij|bk)^{\alpha \beta} + 4 T_{ij}^{bc, \alpha \alpha} (ab|jc)^{\alpha \alpha} + 4 T_{ij}^{bc, \alpha \beta} (ab|jc)^{\alpha \beta}\]
[26]:
L = Ax0_Core(sv, so, sa, sa)(D_r_oovv)
[27]:
L[0][:] += (
    - 4 * np.einsum("jakb, ijbk -> ai", T_iajb[0], eri0_mo[0][so[0], so[0], sv[0], so[0]])
    - 2 * np.einsum("jakb, ijbk -> ai", T_iajb[1], eri0_mo[1][so[0], so[0], sv[1], so[1]])
    + 4 * np.einsum("ibjc, abjc -> ai", T_iajb[0], eri0_mo[0][sv[0], sv[0], so[0], sv[0]])
    + 2 * np.einsum("ibjc, abjc -> ai", T_iajb[1], eri0_mo[1][sv[0], sv[0], so[1], sv[1]])
)
[28]:
L[1][:] += (
    - 4 * np.einsum("jakb, ijbk -> ai", T_iajb[2], eri0_mo[2][so[1], so[1], sv[1], so[1]])
    - 2 * np.einsum("kbja, bkij -> ai", T_iajb[1], eri0_mo[1][sv[0], so[0], so[1], so[1]])
    + 4 * np.einsum("ibjc, abjc -> ai", T_iajb[2], eri0_mo[2][sv[1], sv[1], so[1], sv[1]])
    + 2 * np.einsum("jcib, jcab -> ai", T_iajb[1], eri0_mo[1][so[0], sv[0], sv[1], sv[1]])
)

\(D_{ai}^{\sigma, \mathrm{MP2}}\)

  • D_r_vo \(D_{ij}^{\sigma, \mathrm{MP2}}\), dim: \((\sigma, a, i)\), type: Type[np.ndarray]

\[- (\varepsilon_a^\sigma - \varepsilon_i^\sigma) D_{ai}^{\sigma, \mathrm{MP2}} - \mathtt{Ax}_{ai}^\sigma [D_{bj}^{\sigma' \mathrm{MP2}}] = L_{ai}^\sigma\]
[29]:
def fx(X):
    X_alpha = X[:, :nocc[0] * nvir[0]].reshape((nvir[0], nocc[0]))
    X_beta = X[:, nocc[0] * nvir[0]:].reshape((nvir[1], nocc[1]))
    Ax = Ax0_Core(sv, so, sv, so, in_cphf=True)((X_alpha, X_beta))
    result = np.concatenate([Ax[0].reshape(-1), Ax[1].reshape(-1)])
    return result

D_r_vo = ucphf.solve(fx, e, mo_occ, L, max_cycle=100, tol=1e-10)[0]

\(D_{pq}^{\sigma, \mathrm{MP2}}\)

  • D_r \(D_{pq}^{\sigma, \mathrm{MP2}}\), dim: \((\sigma, p, q)\)

[30]:
D_r = np.copy(D_r_oovv)
D_r[0][sv[0], so[0]] = D_r_vo[0]
D_r[1][sv[1], so[1]] = D_r_vo[1]

MP2 一阶梯度

弛豫密度贡献

\[\partial_{\mathbb{A}} E_\mathrm{corr} \leftarrow D_{pq}^{\sigma, \mathrm{MP2}} B_{pq}^{\mathbb{A}, \sigma}\]
[31]:
E_1_MP2_contrib1 = np.einsum("xpq, xApq -> A", D_r, gradh.B_1).reshape((natm, 3))
E_1_MP2_contrib1
[31]:
array([[ 0.00819, -0.01257,  0.00115],
       [-0.00897,  0.0027 , -0.00033],
       [ 0.00095,  0.00815,  0.00089],
       [-0.00017,  0.00171, -0.00171]])
\[\partial_{\mathbb{A}} E_\mathrm{corr} \leftarrow W_{pq}^{\sigma} [\mathrm{I}] S_{pq}^{\mathbb{A}, \sigma}\]
[32]:
E_1_MP2_contrib2 = np.einsum("xpq, xApq -> A", W_I, S_1_mo).reshape((natm, 3))
E_1_MP2_contrib2
[32]:
array([[-0.00464, -0.00136,  0.00005],
       [ 0.00391, -0.0006 , -0.00406],
       [ 0.00052,  0.00219, -0.00011],
       [ 0.00021, -0.00023,  0.00413]])
\[\partial_{\mathbb{A}} E_\mathrm{corr} \leftarrow T_{ij}^{ab, \sigma \sigma'} (pq|rs)^{\sigma \sigma'}\]
[33]:
E_1_MP2_contrib3 = (
    + 2 * np.einsum("iajb, Aiajb -> A", T_iajb[0], eri1_mo[0][:, so[0], sv[0], so[0], sv[0]])
    + 2 * np.einsum("iajb, Aiajb -> A", T_iajb[1], eri1_mo[1][:, so[0], sv[0], so[1], sv[1]])
    + 2 * np.einsum("iajb, Aiajb -> A", T_iajb[2], eri1_mo[2][:, so[1], sv[1], so[1], sv[1]])
).reshape((natm, 3))
E_1_MP2_contrib3
[33]:
array([[ 0.00991,  0.0001 ,  0.01007],
       [-0.00758,  0.00012,  0.0015 ],
       [-0.00037,  0.00029, -0.00005],
       [-0.00197, -0.00051, -0.01152]])

总能量梯度可以表示为

[34]:
E_1_MP2_contrib1 + E_1_MP2_contrib2 + E_1_MP2_contrib3
[34]:
array([[ 0.01346, -0.01382,  0.01127],
       [-0.01264,  0.00222, -0.00289],
       [ 0.00111,  0.01063,  0.00073],
       [-0.00193,  0.00097, -0.0091 ]])

我们可以与 PySCF 的梯度作对比:

[35]:
mp2_grad.de - scf_grad.de
[35]:
array([[ 0.01346, -0.01382,  0.01127],
       [-0.01264,  0.00222, -0.00289],
       [ 0.00111,  0.01063,  0.00073],
       [-0.00193,  0.00097, -0.0091 ]])

简单理解 PySCF 临时文件 chkfile 使用

这份文档会介绍 PySCF 的 chkfile 功能。

chkfile 类似于 Gaussian 的 checkpoint 文件。对于自洽场方法,它用于储存分子信息、自洽场轨道等等。

PySCF 是通过 Python 的 h5py 实现;换言之,PySCF 的 chkfile 相当于 h5py 的高级接口。

初始化

[1]:
from pyscf import gto, scf, hessian, lib
import numpy as np
import h5py

np.set_printoptions(5, suppress=True, linewidth=150)
[2]:
mol = gto.Mole()
mol.atom = """
N  0.  0.  0.
H  1.5 0.  0.2
H  0.1 1.2 0.
H  0.  0.  1.
"""
mol.basis = "6-31G"
mol.verbose = 0
mol.build()
[2]:
<pyscf.gto.mole.Mole at 0x7f5a90112be0>
[3]:
scf_eng = scf.RHF(mol).run()
scf_hess = hessian.RHF(scf_eng).run()

介绍

h5py 文件与内容结构

临时文件的位置如下:

[4]:
scf_eng.chkfile
[4]:
'/home/a/Documents/2021-03-01-MP2_pyscf_prop/tmp94oic47h'

该文件可以使用 h5py 直接进行读写。我们先了解文件目录结构。

该文件的顶层目录是

[5]:
with h5py.File(scf_eng.chkfile, "r") as f:
    print(f.keys())
<KeysViewHDF5 ['mol', 'scf', 'scf_f1ao', 'scf_mo1']>

对于 scf 键值,其子目录是

[6]:
with h5py.File(scf_eng.chkfile, "r") as f:
    print(f["scf"].keys())
<KeysViewHDF5 ['e_tot', 'mo_coeff', 'mo_energy', 'mo_occ']>

事实上,我们可以用 scf/e_tot 直接获得能量的结果;但需要注意,在最后需要加 [()] 以获得结果,否则得到的是 h5py.Dataset 类实例:

[7]:
with h5py.File(scf_eng.chkfile, "r") as f:
    print(f["scf/e_tot"][()])
-56.02979155465495

最后,我们可以使用下面的小程序,打印出完整的 chkfile 文件结构:

[8]:
def print_h5py_group_dir(f):
    spl = f.name.split("/")
    level = len(spl) - 1 if len(f.name) > 1 else 0
    name = spl[-1]
    if isinstance(f, h5py.Group):
        if not isinstance(f, (h5py.File)):
            print("  "*(level-1) + "|- " + name)
        for k in f.keys():
            print_h5py_group_dir(f[k])
    else:
        val = f[()]
        if not isinstance(val, np.ndarray):
            print("  "*(level-1) + "|- " + name + ": " + str(type(val)))
        else:
            print("  "*(level-1) + "|- " + name + ": " + str(type(val)) + ", dtype: " + str(val.dtype))
[9]:
with h5py.File(scf_eng.chkfile, "r") as f:
    print_h5py_group_dir(f)
|- mol: <class 'bytes'>
|- scf
  |- e_tot: <class 'numpy.float64'>
  |- mo_coeff: <class 'numpy.ndarray'>, dtype: float64
  |- mo_energy: <class 'numpy.ndarray'>, dtype: float64
  |- mo_occ: <class 'numpy.ndarray'>, dtype: float64
|- scf_f1ao
  |- 0: <class 'numpy.ndarray'>, dtype: float64
  |- 1: <class 'numpy.ndarray'>, dtype: float64
  |- 2: <class 'numpy.ndarray'>, dtype: float64
  |- 3: <class 'numpy.ndarray'>, dtype: float64
|- scf_mo1
  |- 0: <class 'numpy.ndarray'>, dtype: float64
  |- 1: <class 'numpy.ndarray'>, dtype: float64
  |- 2: <class 'numpy.ndarray'>, dtype: float64
  |- 3: <class 'numpy.ndarray'>, dtype: float64

读取数据

PySCF 具有自己的读取 chkfile 的方式。作为高级 API,它确实更方便一些。

读取特定的数组或结果,可以直接用下述代码实现:

[10]:
lib.chkfile.load(scf_eng.chkfile, "scf/mo_coeff").shape
[10]:
(15, 15)

如果读取的是某个目录结构,那么它会将所有的子目录或子数据结果递归地转换为字典,储存到内存:

[11]:
lib.chkfile.load(scf_eng.chkfile, "scf").keys()
[11]:
dict_keys(['e_tot', 'mo_coeff', 'mo_energy', 'mo_occ'])

储存数据

PySCF 支持三种储存的方法,单独的结果、列表与字典。

[12]:
val_float = 0.4
val_list = [1.4, 1.8]
val_dict = {"foo": 2.4, "bar": 2.8}
[13]:
lib.chkfile.dump(scf_eng.chkfile, "val_float", val_float)
lib.chkfile.dump(scf_eng.chkfile, "val_list", val_list)
lib.chkfile.dump(scf_eng.chkfile, "val_dict", val_dict)

其数据结构如下:

[14]:
with h5py.File(scf_eng.chkfile, "r") as f:
    print_h5py_group_dir(f)
|- mol: <class 'bytes'>
|- scf
  |- e_tot: <class 'numpy.float64'>
  |- mo_coeff: <class 'numpy.ndarray'>, dtype: float64
  |- mo_energy: <class 'numpy.ndarray'>, dtype: float64
  |- mo_occ: <class 'numpy.ndarray'>, dtype: float64
|- scf_f1ao
  |- 0: <class 'numpy.ndarray'>, dtype: float64
  |- 1: <class 'numpy.ndarray'>, dtype: float64
  |- 2: <class 'numpy.ndarray'>, dtype: float64
  |- 3: <class 'numpy.ndarray'>, dtype: float64
|- scf_mo1
  |- 0: <class 'numpy.ndarray'>, dtype: float64
  |- 1: <class 'numpy.ndarray'>, dtype: float64
  |- 2: <class 'numpy.ndarray'>, dtype: float64
  |- 3: <class 'numpy.ndarray'>, dtype: float64
|- val_dict
  |- bar: <class 'numpy.float64'>
  |- foo: <class 'numpy.float64'>
|- val_float: <class 'numpy.float64'>
|- val_list__from_list__
  |- 000000: <class 'numpy.float64'>
  |- 000001: <class 'numpy.float64'>

其中,列表的情况是比较特殊的。在 PySCF 中,列表名称以 __from_list__ 结尾。其读取也会生成列表而非字典。

[15]:
lib.chkfile.load(scf_eng.chkfile, "val_list")
[15]:
[1.4, 1.8]

储存数据的值是可以覆盖的。譬如更改 val_float 的数值:

[16]:
lib.chkfile.dump(scf_eng.chkfile, "val_float", 10.5)
lib.chkfile.load(scf_eng.chkfile, "val_float")
[16]:
10.5

直接对 File 对象写入数据

作为一种特殊情况,我们现在拥有的不是 chkfile 的名称 (譬如这里的 scf_eng.chkfile),而是一个可以读写的实际文件:

[17]:
f = h5py.File(scf_eng.chkfile, "a")

此时可以用下述方式直接进行写入操作:

[18]:
f.create_dataset("grp1/grp2", data="val3")
[18]:
<HDF5 dataset "grp2": shape (), type "|O">
[19]:
f["grp1/grp2"][()]
[19]:
b'val3'

这种情况在使用 lib.H5TmpFile() 生成的 hdf5 文件时比较实用。

重新学习 RHF 核坐标梯度笔记

初始化

[1]:
from pyscf import gto, scf, grad, hessian, lib
import numpy as np
from functools import partial
import h5py
from pyscf.scf import _vhf

from pyxdh.DerivOnce import GradSCF

np.set_printoptions(4, suppress=True, linewidth=180)
np.einsum = partial(np.einsum, optimize=["greedy", 1024 ** 3 * 2 / 8])
[2]:
mol = gto.Mole()
mol.atom = """
N  0.  0.  0.
H  1.5 0.  0.2
H  0.1 1.2 0.
H  0.  0.  1.
"""
mol.basis = "6-31G"
mol.verbose = 0
mol.build()
[2]:
<pyscf.gto.mole.Mole at 0x7f2550327640>
[3]:
mf_scf = scf.RHF(mol).run()
mf_grad = grad.RHF(mf_scf).run()
mf_hess = hessian.RHF(mf_scf).run()
mf_grad.de
[3]:
array([[-0.1408, -0.1166, -0.0278],
       [ 0.0947,  0.0102,  0.0289],
       [ 0.0195,  0.0815,  0.0225],
       [ 0.0266,  0.025 , -0.0236]])
[4]:
gradh = GradSCF({"scf_eng": mf_scf})
gradh.E_1
[4]:
array([[-0.1408, -0.1166, -0.0278],
       [ 0.0947,  0.0102,  0.0289],
       [ 0.0195,  0.0815,  0.0225],
       [ 0.0266,  0.025 , -0.0236]])

PySCF 函数

hcore_generator

用于生成 \(h_{\mu \nu}^{A_t}\),与 gradh.H_1_ao 对应。hcore_generator 是函数生成器。生成的函数的输入参量是原子序号 \(A\),输出是 \(h_{\mu \nu}^{A_t}\)

[5]:
hcore_deriv = mf_grad.hcore_generator(mol)
[6]:
np.allclose(hcore_deriv(0), gradh.H_1_ao[:3])
[6]:
True

get_ovlp

用于生成与 \(S_{\mu \nu}^{A_t}\) 有关的量 \(- (\partial_t \mu | \nu)\)注意: 并非直接生成 \(S_{\mu \nu}^{A_t}\)。同时注意负号。

[7]:
np.allclose(mf_grad.get_ovlp(mol), - mol.intor("int1e_ipovlp"))
[7]:
True

make_rdm1e

用于生成轨道能加权密度 \(R_{\mu \nu} [\varepsilon_i]\)

\[R_{\mu \nu} [\varepsilon_i] := 2 C_{\mu i} \varepsilon_i C_{\nu i}\]
[8]:
Co, eo = gradh.Co, gradh.eo
[9]:
np.allclose(
    2 * np.einsum("ui, i, vi -> uv", Co, eo, Co),
    mf_grad.make_rdm1e(mf_scf.mo_energy, mf_scf.mo_coeff, mf_scf.mo_occ),
)
[9]:
True

einsum 效率警告: 此处不适合用 einsum。

[10]:
np.allclose(
    2 * np.einsum("ui, i, vi -> uv", Co, eo, Co),
    2 * (Co * eo) @ Co.T,
)
[10]:
True
[11]:
%%timeit -r 7 -n 1000
2 * np.einsum("ui, i, vi -> uv", Co, eo, Co)
121 µs ± 11.7 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)
[12]:
%%timeit -r 7 -n 1000
2 * (Co * eo) @ Co.T
5.36 µs ± 847 ns per loop (mean ± std. dev. of 7 runs, 1000 loops each)

get_j, get_k

作张量缩并

\[\begin{split}\begin{align} J_{\mu \nu}^t [R_{\lambda \kappa}] &= - (\partial_t \mu \nu | \kappa \lambda) R_{\lambda \kappa} \\ K_{\mu \lambda}^t [R_{\nu \lambda}] &= - (\partial_t \mu \nu | \kappa \lambda) R_{\nu \kappa} \end{align}\end{split}\]

这个程序与 get_jk 有关。其底层调用是 scf._vhf.direct_mapdm。我们在后文讨论该函数。

[13]:
np.allclose(
    - np.einsum("tuvkl, kl -> tuv", mol.intor("int2e_ip1"), gradh.D),
    mf_grad.get_j(),
)
[13]:
True
[14]:
np.allclose(
    - np.einsum("tuvkl, vk -> tul", mol.intor("int2e_ip1"), gradh.D),
    mf_grad.get_k(),
)
[14]:
True

记号误认警告: 注意等式左右的下角标,这里刻意用了与 PySCF 本体程序类似的记号。但在理解程序上比较关键。如果采用 pyxdh 的习惯,公式应写为

\[\begin{split}\begin{align} J_{\mu \nu}^t [R_{\kappa \lambda}] &= - (\partial_t \mu \nu | \kappa \lambda) R_{\kappa \lambda} \\ K_{\mu \nu}^t [R_{\kappa \lambda}] &= - (\partial_t \mu \kappa | \nu \lambda) R_{\kappa \lambda} \end{align}\end{split}\]

函数效率警告: 存在更高效的函数 get_jk。这里不介绍其调用方式。

[15]:
%%timeit -n 10
mf_grad.get_j()
33.5 ms ± 1.35 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)
[16]:
%%timeit -n 10
mf_grad.get_jk()
35.2 ms ± 1.5 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)

_vhf.direct_mapdm

该函数专门地用于计算双电子积分与密度矩阵的缩并。

[17]:
_vhf.direct_mapdm
[17]:
<function pyscf.scf._vhf.direct_mapdm(intor, aosym, jkdescript, dms, ncomp, atm, bas, env, vhfopt=None, cintopt=None, shls_slice=None)>
  • intor 双电子积分的类型

    • 必须要指定积分形式,譬如 int2e_ip1 是不允许的。如果所求的分子积分是球谐形式,那么就需要在后面增加词尾 _sph,从而是 int2e_ip1_sph

    • 词尾的增加可以是 mol._add_suffix("int2e_ip1")。一般球谐是 _sph,笛卡尔是 _cart,其他两种是 _spinor, _ssc

  • aosym 双电子积分的对称性

    • 尽管可以从 intor 关键词推断,但必须要手动指定。使用时需要非常谨慎。如果不清楚用哪个,可以使用 s1 而保证结果不会错,但这会大大降低积分效率。

    • 可能的选项是 s8, s4, s2ij, s2kl, s1, aa4, a4ij, a4kl, a2ij, a2kl。这在 scf._vhf 文件中有所说明。

    • 所有的双电子积分假设维度是 \((t, i, j, k, l)\)s2ij 表示互换 \(i, j\) 角标结果不变,即 \(g^t_{ij, kl} = g^t_{kl, ij}\)a4ij 表示 \(i, j\) 反对称而 \(k, l\) 对称。a2ij 表示 \(i, j\) 反对称。

    • 举例而言,\((\partial_t \mu \nu | \kappa \lambda)\) 具有二重对称性:\((\partial_t \mu \nu | \kappa \lambda) = (\partial_t \mu \nu | \lambda \kappa)\)。由于是后两个角标对称,因此对称性记为 s2kl

    • 它不代表密度矩阵的对称性。同时,密度矩阵是否对称不会很影响计算效率。

  • jkdescript 张量缩并方式

    • 对于类库伦积分,lk->s1ij 表示 \((\partial_t \mu \nu | \kappa \lambda) R_{\kappa \lambda}\)。由于最终的结果对于 \(\mu, \nu\) 不对称,因此这里是 s1

    • 密度角标只支持 ji, lk, li, jk;结果角标只支持 _s1, _s2kl, ij, kj, il 的组合。

  • dms 密度矩阵,形状必须是 2 维度方阵或 3 维度张量

  • ncomp 双电子积分的大小指标

    • 尽管可以从 intor 关键词推断,但必须要手动指定。

    • 对于 \((\partial_t \mu \nu | \kappa \lambda)\),由于 \(t\)\(x, y, z\) 三个方向,因此该值为 3。

  • atm, bas, env 均是分子自身的参量,一般不需要更改

下面观察一下输出维度。如果张量缩并方式有 2 种,密度矩阵有 5 个,双电子积分大小有 3 个,那么输出的结果也会是 (2, 5, 3, nao, nao) 大小的张量。

[18]:
R_rand = np.random.randn(5, mol.nao, mol.nao)
[19]:
res = _vhf.direct_mapdm("int2e_ip1_sph", "s2kl", ('lk->s1ij', 'jk->s1il'), R_rand, 3, mol._atm, mol._bas, mol._env)
np.array(res).shape
[19]:
(2, 5, 3, 15, 15)
[20]:
np.allclose(np.einsum("tijkl, Blk -> Btij", mol.intor("int2e_ip1"), R_rand), res[0]), \
np.allclose(np.einsum("tijkl, Bjk -> Btil", mol.intor("int2e_ip1"), R_rand), res[1])
[20]:
(True, True)

指定对称性对效率的改变巨大: 若电子积分是二重对称的,那么如果程序中降低对称性,效率会恰好低一倍。往往双电子积分与密度矩阵缩并是次要性能关键步;它对性能的影响不小。因此要谨慎处理。

[21]:
%%timeit -n 5
_vhf.direct_mapdm("int2e_ip1_sph", "s2kl", 'lk->s1ij', R_rand, 3, mol._atm, mol._bas, mol._env)
34.9 ms ± 1.78 ms per loop (mean ± std. dev. of 7 runs, 5 loops each)
[22]:
%%timeit -n 5
_vhf.direct_mapdm("int2e_ip1_sph", "s1", 'lk->s1ij', R_rand, 3, mol._atm, mol._bas, mol._env)
60.9 ms ± 2.79 ms per loop (mean ± std. dev. of 7 runs, 5 loops each)

效率随体系不同而不同: 在小体系下,直接在内存中储存所有双电子积分,并且不考虑对称性地进行积分反而效率更高。但如果体系扩大,使用 _vhf.direct_mapdm 的必要性就出来了。同时,由于我们无法承受 \(O(N^4)\) 大小的内存量,因此有必要时就使用 _vhf.direct_mapdm 或者其他 _vhf 函数。

[23]:
%%timeit -n 5
np.einsum("tuvkl, Bkl -> Btuv", mol.intor("int2e_ip1"), R_rand)
15.9 ms ± 1.66 ms per loop (mean ± std. dev. of 7 runs, 5 loops each)
[24]:
mol_large = gto.Mole()
mol_large.atom = """
N  0.  0.  0.
H  1.5 0.  0.2
H  0.1 1.2 0.
H  0.  0.  1.
"""
mol_large.basis = "cc-pVTZ"
mol_large.verbose = 0
mol_large.build()
R_rand_large = np.random.randn(5, mol_large.nao, mol_large.nao)
[25]:
%%timeit -n 2
np.einsum("tuvkl, Bkl -> Btuv", mol_large.intor("int2e_ip1"), R_rand_large)
659 ms ± 9.3 ms per loop (mean ± std. dev. of 7 runs, 2 loops each)
[26]:
%%timeit -n 2
_vhf.direct_mapdm("int2e_ip1_sph", "s2kl", ('lk->s1ij', 'jk->s1il'), R_rand_large, 3, mol_large._atm, mol_large._bas, mol_large._env)
487 ms ± 9.11 ms per loop (mean ± std. dev. of 7 runs, 2 loops each)

能量的一阶梯度

在写可以实用化的一阶梯度程序时,需要考虑到的最重要因素之一,是内存的大小。

内存大小不能超过平方级别,甚至不允许是 \((n_\mathrm{atom}, n_\mathrm{AO}, n_\mathrm{AO})\) 大小。因此,处理时需要尽可能将原子分离开,更不能出现双电子积分。

pyxdh 实现方式

pyxdh 的实现方式是:

\[\frac{\partial E_\mathrm{tot}}{\partial A_t} = h_{\mu \nu}^{A_t} D_{\mu \nu} + \frac{1}{2} (\mu \nu | \kappa \lambda)^{A_t} D_{\mu \nu} D_{\kappa \lambda} - \frac{1}{4} (\mu \kappa | \nu \lambda)^{A_t} D_{\mu \nu} D_{\kappa \lambda} - 2 F_{ij} S_{ij}^{A_t} + \frac{\partial E_\mathrm{nuc}}{\partial A_t}\]
[27]:
so = gradh.so
D = gradh.D
[28]:
(
    +        np.einsum("Auv, uv -> A", gradh.H_1_ao, D)
    + 0.5  * np.einsum("Auvkl, uv, kl -> A", gradh.eri1_ao, D, D)
    - 0.25 * np.einsum("Aukvl, uv, kl -> A", gradh.eri1_ao, D, D)
    - 2    * np.einsum("ij, Aij -> A", gradh.F_0_mo[so, so], gradh.S_1_mo[:, so, so])
    + mf_grad.grad_nuc().flatten()
).reshape(mol.natm, 3)
/home/a/miniconda3/lib/python3.8/site-packages/pyxdh/DerivOnce/deriv_once_scf.py:309: UserWarning: eri1_ao: 4-idx tensor ERI should be not used!
  warnings.warn("eri1_ao: 4-idx tensor ERI should be not used!")
[28]:
array([[-0.1408, -0.1166, -0.0278],
       [ 0.0947,  0.0102,  0.0289],
       [ 0.0195,  0.0815,  0.0225],
       [ 0.0266,  0.025 , -0.0236]])

Hamilton Core 贡献项

这个拆分是非常容易的:

[29]:
hcore_deriv = mf_grad.hcore_generator(mol)
[30]:
contrib_hcore = np.zeros((mol.natm, 3))
for A in range(mol.natm):
    contrib_hcore[A] += np.einsum("tuv, uv -> t", hcore_deriv(A), D)
[31]:
np.allclose(contrib_hcore.flatten(), np.einsum("Auv, uv -> A", gradh.H_1_ao, D))
[31]:
True

J 积分

依据对称性,我们注意到

\[\partial_{A_t} E_\mathrm{tot} \leftarrow \frac{1}{2} \partial_{A_t} (\mu \nu | \kappa \lambda) D_{\mu \nu} D_{\kappa \lambda} = 2 (\partial_{A_t} \mu \nu | \kappa \lambda) D_{\mu \nu} D_{\kappa \lambda} = - 2 (\partial_t \mu_A \nu | \kappa \lambda) D_{\mu_A \nu} D_{\kappa \lambda} = 2 J_{\mu_A \nu} [D_{\kappa \lambda}] D_{\mu_A \nu}\]
[32]:
mol_slice = gradh.mol_slice
[33]:
vj = - _vhf.direct_mapdm("int2e_ip1_sph", "s2kl", 'lk->s1ij', D, 3, mol._atm, mol._bas, mol._env)
[34]:
contrib_vj = np.zeros((mol.natm, 3))
for A in range(mol.natm):
    sA = mol_slice(A)
    contrib_vj[A] += 2 * np.einsum("tuv, uv -> t", vj[:, sA, :], D[sA, :])
[35]:
np.allclose(contrib_vj.flatten(), 0.5 * np.einsum("Auvkl, uv, kl -> A", gradh.eri1_ao, D, D))
[35]:
True

K 积分

非常类似地,我们可以得到

\[\partial_{A_t} E_\mathrm{tot} \leftarrow - \frac{1}{4} \partial_{A_t} (\mu \nu | \kappa \lambda) D_{\mu \kappa} D_{\nu \lambda} = - K_{\mu_A \kappa} [D_{\nu \lambda}] D_{\mu_A \kappa}\]
[36]:
vk = - _vhf.direct_mapdm("int2e_ip1_sph", "s2kl", 'jk->s1il', D, 3, mol._atm, mol._bas, mol._env)
[37]:
contrib_vk = np.zeros((mol.natm, 3))
for A in range(mol.natm):
    sA = mol_slice(A)
    contrib_vk[A] += - np.einsum("tuv, uv -> t", vk[:, sA, :], D[sA, :])
[38]:
np.allclose(contrib_vk.flatten(), - 0.25 * np.einsum("Aukvl, uv, kl -> A", gradh.eri1_ao, D, D))
[38]:
True

能量加权部分

\[\partial_{A_t} E_\mathrm{tot} \leftarrow - 2 F_{ij} S_{ij}^{A_t} = - (2 F_{ij} C_{\mu i} C_{\nu j}) \partial_{A_t} (\mu | \nu) = 2 R_{\mu_A \nu} [F_{ij}] (\partial_t \mu_A | \nu)\]
[39]:
dme0 = mf_grad.make_rdm1e(mf_scf.mo_energy, mf_scf.mo_coeff, mf_scf.mo_occ)
s1 = mf_grad.get_ovlp(mol)
[40]:
contrib_dme0 = np.zeros((mol.natm, 3))
for A in range(mol.natm):
    sA = mol_slice(A)
    contrib_dme0[A] += - 2 * np.einsum("tuv, uv -> t", s1[:, sA, :], dme0[sA, :])
[41]:
np.allclose(contrib_dme0.flatten(), - 2 * np.einsum("ij, Aij -> A", gradh.F_0_mo[so, so], gradh.S_1_mo[:, so, so]))
[41]:
True

最终加和

[42]:
contrib_hcore + contrib_vj + contrib_vk + contrib_dme0 + mf_grad.grad_nuc()
[42]:
array([[-0.1408, -0.1166, -0.0278],
       [ 0.0947,  0.0102,  0.0289],
       [ 0.0195,  0.0815,  0.0225],
       [ 0.0266,  0.025 , -0.0236]])

重新学习 MP2 能量笔记

在 pyxdh 中,所有张量全部都储存在内存中,包括原子轨道基组的双电子积分。但在现实问题中,这对内存量与磁盘量上,是不允许的。

所有的 MP2 算法,都多少会使用内存降低方法。我们需要逐个地考虑它们。

初始化

[1]:
import numpy as np
from functools import partial
np.einsum = partial(np.einsum, optimize=["greedy", 1024 ** 3 * 2 / 8])
[2]:
import numpy as np
from pyscf import gto, scf, mp, ao2mo, lib
from pyscf.ao2mo import _ao2mo
from functools import partial
import h5py

np.set_printoptions(4, suppress=True, linewidth=180)
np.einsum = partial(np.einsum, optimize=["greedy", 1024 ** 3 * 2 / 8])
[3]:
mol = gto.Mole()
mol.atom = """
N  0.  0.  0.
H  1.5 0.  0.2
H  0.1 1.2 0.
H  0.  0.  1.
"""
mol.basis = "6-31G"
mol.verbose = 0
mol.build()
[3]:
<pyscf.gto.mole.Mole at 0x7f8865ac2be0>
[4]:
nocc, nmo, nao, nbas = mol.nelec[0], mol.nao, mol.nao, mol.nbas
nvir = nmo - nocc
so, sv = slice(0, nocc), slice(nocc, nmo)

最简单方法回顾

最简单的方法是将原子轨道基的双电子积分完全储存到内存中,并作双电子积分转换。

\[\begin{split}\begin{align} (ia|jb) &= C_{\mu i} C_{\nu a} (\mu \nu | \kappa \lambda) C_{\kappa j} C_{\lambda b} \\ D_{ij}^{ab} &= \varepsilon_i - \varepsilon_a + \varepsilon_j - \varepsilon_b \\ t_{ij}^{ab} &= (ia|jb) / D_{ij}^{ab} \\ T_{ij}^{ab} &= 2 t_{ij}^{ab} - t_{ij}^{ba} \\ E_\mathrm{corr} &= T_{ij}^{ab} t_{ij}^{ab} D_{ij}^{ab} \end{align}\end{split}\]
[5]:
mf_scf = scf.RHF(mol).run()
C, e = mf_scf.mo_coeff, mf_scf.mo_energy
Co, Cv = C[:, so], C[:, sv]
eo, ev = e[so], e[sv]
[6]:
eri0_ao = mol.intor("int2e")
eri0_iajb = np.einsum("ui, va, uvkl, kj, lb -> iajb", Co, Cv, eri0_ao, Co, Cv)
D_iajb = eo[:, None, None, None] - ev[None, :, None, None] + eo[None, None, :, None] - ev[None, None, None, :]
t_iajb = eri0_iajb / D_iajb
T_iajb = 2 * t_iajb - t_iajb.swapaxes(-1, -3)
np.einsum("iajb, iajb, iajb ->", T_iajb, t_iajb, D_iajb)
[6]:
-0.14554742350036615

整个过程最耗时部分,对于当前的问题反而是原子基组双电子积分 \((\mu \nu | \kappa \lambda)\) 的计算。这一步其实是 \(O(N^4)\) 量级的。对于更大的分子,显然是原子轨道到分子轨道基转换的 \(O(N^5)\) 量级更加耗时。因此不能说这里的的效率测评有意义。

[7]:
%%timeit -n 100
eri0_ao = mol.intor("int2e")
10.4 ms ± 1.11 ms per loop (mean ± std. dev. of 7 runs, 100 loops each)
[8]:
%%timeit -n 100
eri0_iajb = np.einsum("ui, va, uvkl, kj, lb -> iajb", Co, Cv, eri0_ao, Co, Cv)
D_iajb = eo[:, None, None, None] - ev[None, :, None, None] + eo[None, None, :, None] - ev[None, None, None, :]
t_iajb = eri0_iajb / D_iajb
T_iajb = 2 * t_iajb - t_iajb.swapaxes(-1, -3)
np.einsum("iajb, iajb, iajb ->", T_iajb, t_iajb, D_iajb)
1.11 ms ± 232 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)

通过 PySCF 默认路径的结果如下,可以验证上面结果的正确性。

[9]:
mf_mp2 = mp.MP2(mf_scf).run()
mf_mp2.e_corr
[9]:
-0.1455474235003661

事实上,上面的双电子转换效率并不快。出于双电子积分本身的对称性、与 MP2 激发系数自身的对称性,PySCF 的默认程序 (incore 转换) 本身会更快一些。

[10]:
%%timeit -n 100
mf_mp2 = mp.MP2(mf_scf).run()
3.02 ms ± 821 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)

Incore 转换

PySCF 默认使用 incore 转换。对于小分子体系而言,实际上原子基组的双电子积分 \((\mu \nu | \kappa \lambda)\) 是被储存下来的:

[11]:
mf_scf._eri.shape
[11]:
(7260,)

这个数值大小其实是对称性简化之后的双电子积分大小 (我们暂时规定了 \(n_\mathrm{AO} = n_\mathrm{MO}\))

\[\frac{1}{2} \left[ \frac{n_\mathrm{AO} (n_\mathrm{AO} + 1)}{2} \left( \frac{n_\mathrm{AO} (n_\mathrm{AO} + 1)}{2} + 1 \right) \right]\]
[12]:
(nmo * (nmo + 1) // 2 * (nmo * (nmo + 1) // 2 + 1)) // 2
[12]:
7260

因此,这里的 incore 转换实际上要求内存占用至少包括原子基组双电子积分 \(\sim n_\mathrm{AO}^4/8\)。在此基础上,所有 \((ia|jb)\) 积分也全部储存,即额外内存 \(n_\mathrm{occ}^2 n_\mathrm{vir}^2\)。同时,转换过程还需要 \(n_\mathrm{occ} n_\mathrm{vir} n_\mathrm{AO}^2\) 大小。因此可以说是开销不计成本。

PySCF 中对 incore 的处理是非常简单粗暴的。直接提供简化或未简化的积分、以及用于缩并的四个系数矩阵就可以了。

[13]:
np.allclose(ao2mo.general(mf_scf._eri, (Co, Cv, Co, Cv)).reshape((nocc, nvir, nocc, nvir)), eri0_iajb)
[13]:
True
[14]:
np.allclose(ao2mo.general(eri0_ao, (Co, Cv, Co, Cv)).reshape((nocc, nvir, nocc, nvir)), eri0_iajb)
[14]:
True

进行过转换的积分会用于给出 MP2 激发系数 \(t_{ij}^{ab}\)。但与 pyxdh 不同地是,pyxdh 使用维度 \((i, a, j, b)\) 储存激发系数,而 PySCF 使用维度 \((i, j, a, b)\)

[15]:
np.allclose(mf_mp2.t2.swapaxes(-2, -3), t_iajb)
[15]:
True

Outcore 转换

在 PySCF 中,outcore 转换必须要求 \((ia|jb)\) 大小的张量可以储存在内存中,因此需要比 \(n_\mathrm{occ}^2 n_\mathrm{vir}^2\) 再大一些。它的实现可以非常简单,也可以非常复杂。

但如果只求取 MP2 的能量,那么我们不一定真的需要这么大的张量。

简单粗暴的 outcore

如果替换 ao2mo.general 的第一个参量为 gto.Mole 实例,那么会自动地使用 outcore 算法。

[16]:
np.allclose(ao2mo.general(mol, (Co, Cv, Co, Cv)).reshape((nocc, nvir, nocc, nvir)), eri0_iajb)
[16]:
True
[17]:
%%timeit -n 100
ao2mo.general(eri0_ao, (Co, Cv, Co, Cv))
1.21 ms ± 489 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
[18]:
%%timeit -n 10
ao2mo.general(mol, (Co, Cv, Co, Cv))
40.7 ms ± 4.35 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)

实际的 outcore (1):壳层与原子对原子轨道的分割

之后几小节的讨论对象是 mp.mp2._ao2mo_ovov。首先,我们要学习或回顾原子轨道和轨道壳层的分割。

不论任何量化程序,原子轨道的数量总是恒定的。它会用 \(\mu, \nu, \cdots\) 角标表示。但在实际的积分中,不是任何原子轨道的分割都可以带入计算。在 PySCF 中,积分的最小单元是壳层 (shell)。

譬如,一个 \(s\) 壳层对应 1 根原子轨道,一个 \(p\) 壳层对应 3 根原子轨道。对于 \(d\) 壳层,根据球谐 (sph) 或笛卡尔 (cart) 的不同,会分别取 5 或 6 根原子轨道。现在一般的基组都使用球谐轨道;笛卡尔基组已经很少使用了 (特殊的 6-31G 系列基组会使用)。

这里顺便原子的分割。尽管 MP2 能量不需要用到,但核坐标梯度的程序会经常使用到。原子轨道之所以称为“原子轨道”,是因为它是以特定原子为中心的三维 Gaussian 函数。因此,原子轨道是可以对应到特定原子的。

现在我们考虑程序的问题。壳层的分割可以用下面的函数表示:

[19]:
ao_loc = mol.ao_loc_nr()
print(ao_loc)
print(len(ao_loc))
[ 0  1  2  3  6  9 10 11 12 13 14 15]
12

我们知道,氨分子的 6-31G 基组有 15 根原子轨道。上面的结果意味着该分子有 12 个壳层。这 12 个壳层的起点体现在上面的列表了。

第 3, 4 壳层 (起点分别为 3, 6) 不同于其他壳层;这一个壳层中包含 3 个原子轨道,因此可以推断为 p 轨道。

我们这里回顾原子对原子轨道与轨道壳层的分割。PySCF 的原子分割是:

[20]:
mol.aoslice_by_atom()
[20]:
array([[ 0,  5,  0,  9],
       [ 5,  7,  9, 11],
       [ 7,  9, 11, 13],
       [ 9, 11, 13, 15]])

上面的每一行代表每一个原子;第一个原子的壳层为 0-4,原子轨道为 0-8;第二个原子的壳层为 5-6,原子轨道 9-10;以此类推。

现在遇到这样的现实的问题:假设氨分子非常大,我们的内存无法储存这种级别的分子。以什么标准对积分过程进行分割?

在 outcore 过程中,第一步是先计算占据轨道的积分:

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

第二部才是计算最终的分子轨道积分:

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

由于第一步缩并过程需要完整的 \(\mu, \lambda\),因此这两个原子轨道角标不能进行分割。待分割的角标是 \(\nu, \kappa\)

以多小的单位进行分割?在 PySCF 中,推荐的大小是 4 根原子轨道一组进行分割。由于电子积分的最小单位是壳层,因此如果基组包含 \(d\) 轨道 (5 根原子轨道),因此一般来说实际消耗会比 4 根轨道还要再大一些。

之所以不使用更大的分割单位,我想是因为小一些的分割可以保证利用好原子轨道 ERI 积分的对称性;但不使用更小的分割,是为了减小积分函数的调用次数,避免不必要的调用时间。

PySCF 中的辅助函数 ao2mo.outcore.balance_partition 可以用于处理壳层的分割:

[21]:
sh_ranges = ao2mo.outcore.balance_partition(ao_loc, 4)
dmax = max(x[2] for x in sh_ranges)
print(sh_ranges)
print(dmax)
[(0, 3, 3), (3, 4, 3), (4, 6, 4), (6, 10, 4), (10, 11, 1)]
4

上述参量 sh_ranges 表示被分割的壳层。譬如以 (4, 6, 4) 为例,表示了第 2 个分割包含壳层 4 与壳层 5,该分割包含了 4 根轨道。

实际的 outcore (2):分割的原子轨道积分

随后的任务是分步地处理下述计算:

\[(i \nu_s | \kappa_s j) = (\mu \nu_s | \kappa_s \lambda) C_{\mu i} C_{\lambda j}\]

其中,下标 \(s\) 表示分割。随后将 \((i \nu_s | \kappa_s j)\) 以维度 \((i, j, \nu_s, \kappa_s)\) 的形式储存到外部硬盘,并对该张量赋予名称 \(s\)

由于分割出来的原子轨道大小不超过壳层数,因此如果不考虑 \(d, f\) 等高角动量轨道,那么内存消耗大约是 \(16 n_\mathrm{AO}^2\)

一个额外的工作是,为了避免内存空间的重复分配耗时,我们先预置一块内存 buf_eri,专门用于储存临时积分 \((\mu \nu_s | \kappa_s \lambda)\)

[22]:
buf_eri = np.empty((nao, dmax, dmax, nao))

循环过程中,我们所需要使用的壳层分割和原子轨道分割列表分别用 list_shell_slicelist_ao_slice 储存:

[23]:
list_shell_slice, list_ao_slice = [], []
for ip, (ish0, ish1, _) in enumerate(sh_ranges):
    for jsh0, jsh1, nj in sh_ranges[:ip+1]:
        list_shell_slice.append((ish0, ish1, jsh0, jsh1))
        i0, i1 = ao_loc[ish0], ao_loc[ish1]
        j0, j1 = ao_loc[jsh0], ao_loc[jsh1]
        list_ao_slice.append((i0, i1, j0, j1))
[24]:
for slice_shell, slice_ao in zip(list_shell_slice, list_ao_slice):
    print(slice_shell, slice_ao)
(0, 3, 0, 3) (0, 3, 0, 3)
(3, 4, 0, 3) (3, 6, 0, 3)
(3, 4, 3, 4) (3, 6, 3, 6)
(4, 6, 0, 3) (6, 10, 0, 3)
(4, 6, 3, 4) (6, 10, 3, 6)
(4, 6, 4, 6) (6, 10, 6, 10)
(6, 10, 0, 3) (10, 14, 0, 3)
(6, 10, 3, 4) (10, 14, 3, 6)
(6, 10, 4, 6) (10, 14, 6, 10)
(6, 10, 6, 10) (10, 14, 10, 14)
(10, 11, 0, 3) (14, 15, 0, 3)
(10, 11, 3, 4) (14, 15, 3, 6)
(10, 11, 4, 6) (14, 15, 6, 10)
(10, 11, 6, 10) (14, 15, 10, 14)
(10, 11, 10, 11) (14, 15, 14, 15)

第一步完整的积分过程可以用下面的程序表示:

[25]:
ftmp = lib.H5TmpFile()
count = 0
for (ish0, ish1, jsh0, jsh1), (i0, i1, j0, j1) in zip(list_shell_slice, list_ao_slice):
    # slice       mu          nu             kappa          lambda
    shls_slice = (0, nbas) + (ish0, ish1) + (jsh0, jsh1) + (0, nbas)
    # calculate  ( mu nu | kappa lambda ).  note that kappa is sliced, so aosym is none, i.e. ( mu nu | kappa lambda ) != ( mu nu | lambda kappa )
    eri = mol.intor("int2e", shls_slice=shls_slice, aosym="s1", out=buf_eri)
    # reshape to tensor (mu, nu, kappa, lambda) from one-dim array
    eri.shape = (nao, (i1-i0), (j1-j0), nao)
    # ( mu nu | kappa lambda ) -> ( i nu | kappa j )
    tensor_ijvk = np.einsum("uvkl, ui, lj -> ijvk", eri, Co, Co)
    # dump result to h5py file
    ftmp.create_dataset(str(count), data=tensor_ijvk)
    count += 1
ftmp.keys()
[25]:
<KeysViewHDF5 ['0', '1', '10', '11', '12', '13', '14', '2', '3', '4', '5', '6', '7', '8', '9']>

现在我们验证其中一个积分。我们能看到第 4 个分割是 \(\nu_4\) 包含原子轨道 6-9,\(\kappa_4\) 包含原子轨道 3-5。

[26]:
list_ao_slice[4]
[26]:
(6, 10, 3, 6)

不妨直接地验证一下

\[(i \nu_4 | \kappa_4 j) = (\mu \nu_4 | \kappa_4 \lambda) C_{\mu i} C_{\lambda j}\]
[27]:
np.allclose(np.einsum("uvkl, ui, kj -> ijvl", mol.intor("int2e"), Co, Co)[:, :, 6:10, 3:6], ftmp["4"][()])
[27]:
True

实际的 outcore (3):分子轨道的 ERI 积分导出

最终的激发系数导出方式是

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

上面的步骤中,由于缩并时角标 \(\nu, \kappa\) 的存在,因此这两个变量必须是连续的。可以通过内存操作被分割的角标是 \(i, j\)

上一步中,分割的大小被限制到 4 个原子轨道,是比较激进的限制方式;但这里,程序倾向于使用宽松的限制方式,因此 \(i, j\) 的分割越小越好。这是因为上一步的分割限制涉及到因对称性所导致的浪费;如果分割太大,那么浪费也会越多。但这一步中,分割太小会导致大量硬盘 I/O,从而限制效率。

在 PySCF 的实现中,假设内存有至少 \(8 n_\mathrm{occ} n_\mathrm{AO}^2\)。这要求了其中一个角标 \(j\) 在内存中是连续储存的。不连续的部分就是 \(i\),且最小的分割是 \(n_\mathrm{occblk} = 4\)

在演示的实例中,我们将分割的大小稍微缩小到 3,这是因为氨分子一共就 5 个占据轨道。因此,对于氨分子,两次分割分别是

[28]:
occblk = 3
list_occ_slice = []
for i in range(0, nocc, occblk):
    num_blk = min(occblk, nocc-i)
    list_occ_slice.append((i, i + num_blk, num_blk))
list_occ_slice
[28]:
[(0, 3, 3), (3, 5, 2)]

在实现积分转换之前,我们要能先把 \((i_s \nu | \kappa j)\) 储存到内存。为此,我们声明一块内存,用于储存这部分转换到一半的积分:(维度定为 \((i_s, j, \nu, \kappa)\))

[29]:
blk_eri = np.empty((occblk, nocc, nao, nao))

我们需要写一个函数,以加载这部分积分。这一步是 I/O 关键步骤,因为尽管我们要求的内存量是 \(n_\mathrm{occblk} n_\mathrm{occ} n_\mathrm{AO}^2\),但要求单次分割的硬盘 I/O 量是 \(n_\mathrm{occ}^2 n_\mathrm{AO}^2\),因此总共要求的硬盘 I/O 量是 \(n_\mathrm{occ}^3 n_\mathrm{AO}^2 / n_\mathrm{occblk}\)。如果 \(n_\mathrm{occblk}\) 设置得太小,那么在 I/O 上就变相地变成五次方时间消耗,非常划不来。

[30]:
def load(i0, i1, eri):
    for idx, (v0, v1, k0, k1) in enumerate(list_ao_slice):
        eri[:i1-i0, :, v0:v1, k0:k1] = ftmp[str(idx)][i0:i1]
        if v0 != k0:
            dat = ftmp[str(idx)][:, i0:i1]
            eri[:i1-i0, :, k0:k1, v0:v1] = dat.transpose((1, 0, 3, 2))

我们验证一下这个程序的正确性。如果现在取第一个分割,即对 \(i\) 取 0-2 号轨道,那么 i0 = 0, i1 = 3。我们需要事先准备好转换到一半的 eri \((i_s \nu | \kappa j)\) 的空间 (维度 \((i_s, j, \nu, \kappa)\))。这个函数会原地对 eri 内部的值直接改动,不返回结果。

[31]:
eri = np.ndarray((3, nocc, nao, nao), buffer=blk_eri)
load(0, 3, eri)
np.allclose(np.einsum("uvkl, ui, kj -> ijvl", mol.intor("int2e"), Co, Co)[0:3], eri)
[31]:
True

随后,我们就可以考虑积分的过程了。尽管 \((i_s \nu | \kappa j)\) 的维度是 \((i_s, j, \nu, \kappa)\),但最终生成的 \((ia|jb)\) 的维度是 \((i, a, j, b)\)。其过程如下。

\[(i_s a|jb) = (i_s \nu | \kappa j) C_{\nu a} C_{\kappa b}\]
[32]:
feri = lib.H5TmpFile()
h5_oovv = feri.create_dataset("ovov", (nocc, nvir, nocc, nvir), "f8")
for i0, i1, inum in list_occ_slice:
    # Not using `blk_eri` directly, since i1-i0 = inum could be different in iterations
    eri = np.ndarray((inum, nocc, nao, nao), buffer=blk_eri)
    # load (i_s nu | kappa j) to `eri`
    load(i0, i1, eri)
    # calculate (i_s a | j b) tensor contraction
    eri_mo = np.einsum("ijvl, va, lb -> iajb", eri, Cv, Cv)
    # dump to h5py file (hard-disk)
    h5_oovv[i0:i1] = eri_mo

最后,我们验证一下计算得到的 \((ia|jb)\) h5_oovv 是否确实是之前我们给出过的 eri_iajb

[33]:
np.allclose(eri0_iajb, h5_oovv)
[33]:
True

浮点计算关键步骤:_ao2mo.nr_e2 函数

刚才一直强调硬盘 I/O 的消耗,但整个程序最耗时的步骤是浮点计算的张量缩并:

# calculate (i_s a | j b) tensor contraction
eri_mo = np.einsum("ijvl, va, lb -> iajb", eri, Cv, Cv)

在 PySCF 中,它并非使用 Python 程序进行处理,而是用 C 程序进行计算。

相同的工作可以使用 np.einsumlib.einsum 完成,但对于这个特定的任务,_ao2mo.nr_e2 的处理效率非常高。文档的最后就讨论这个函数。

我们现在假设遇到的问题是 \(n_\mathrm{occ} = 20\), \(n_\mathrm{vir}=100\), \(n_\mathrm{MO} = n_\mathrm{AO} = 120\)。不考虑对称性,我们随机一个 eriCv 矩阵,作为当前模型问题的矩阵。

[34]:
nao = nmo = 120
nocc, nvir = 20, 100
[35]:
eri = np.random.randn(nocc, nocc, nao, nao)
Cv = np.random.randn(nao, nvir)

我们首先可以验证 _ao2mo.nr_e2 函数与 np.einsum 的作用效果相同。

[36]:
np.allclose(
    _ao2mo.nr_e2(eri.reshape(nocc**2, nao**2), Cv, (0, nvir, 0, nvir), "s1", "s1").reshape(nocc, nocc, nvir, nvir),
    np.einsum("ijvl, va, lb -> ijab", eri, Cv, Cv)
)
[36]:
True

但从耗时上,_ao2mo.nr_e2 的消耗远远比 np.einsum 低:

[37]:
%%timeit -n 10
_ao2mo.nr_e2(eri.reshape(nocc**2, nao**2), Cv, (0, nvir, 0, nvir), "s1", "s1").reshape(nocc, nocc, nvir, nvir)
22.4 ms ± 4 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)
[38]:
%%timeit -n 10
np.einsum("ijvl, va, lb -> ijab", eri, Cv, Cv)
47.1 ms ± 1.46 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)

在对程序没有更深了解的情况下,只能说 _ao2mo.nr_e2 是一个优秀的特化程序;可以的情况下就尽量使用。

MP2 核坐标梯度的重新推导

这一篇文档中,我们要讨论如何从比较简单的 MP2 核坐标梯度,反推出可以相对来说节省内存的算法。最后我们再总结通过 PySCF 所改编的实施方法。

由于角标开始复杂化,我们这里使用支持希腊字母、并且实现方式与 numpy.einsum 相近的 opt_einsum.contract 作为张量求和引擎。

初始化

[1]:
from pyscf import gto, scf, mp, grad, hessian, ao2mo, lib
import numpy as np
from functools import partial
from pyscf.scf import cphf
from pyscf.ao2mo import _ao2mo
import warnings
import opt_einsum

from pyxdh.DerivOnce import GradMP2

np.set_printoptions(4, suppress=True, linewidth=180)
warnings.filterwarnings("ignore")
einsum = opt_einsum.contract
[2]:
mol = gto.Mole()
mol.atom = """
N  0.  0.  0.
H  1.5 0.  0.2
H  0.1 1.2 0.
H  0.  0.  1.
"""
mol.basis = "6-31G"
mol.verbose = 0
mol.build()
[2]:
<pyscf.gto.mole.Mole at 0x7f53d062d640>
[3]:
mf_scf = scf.RHF(mol).run()
mf_mp2 = mp.MP2(mol).run()
mf_grad = grad.mp2.Gradients(mf_mp2).run()
mf_grad.de
[3]:
array([[-0.1109, -0.0858,  0.0086],
       [ 0.0768,  0.0067,  0.023 ],
       [ 0.0133,  0.059 ,  0.0179],
       [ 0.0209,  0.0201, -0.0494]])
[4]:
gradh = GradMP2({"scf_eng": mf_scf})
gradh.E_1
[4]:
array([[-0.1109, -0.0858,  0.0086],
       [ 0.0768,  0.0067,  0.023 ],
       [ 0.0133,  0.059 ,  0.0179],
       [ 0.0209,  0.0201, -0.0494]])
[5]:
nocc, nmo, nao = mol.nelec[0], mol.nao, mol.nao
natm, nbas = mol.natm, mol.nbas
nvir = nmo - nocc
so, sv, sa = slice(0, nocc), slice(nocc, nmo), slice(0, nmo)
[6]:
C, e = mf_scf.mo_coeff, mf_scf.mo_energy
Co, Cv = C[:, so], C[:, sv]
eo, ev = e[so], e[sv]
t2 = mf_mp2.t2
D = mf_scf.make_rdm1()

MP2 核坐标梯度

pyxdh 的做法

\[\begin{split}\begin{align} \partial_{A_t} E_\mathrm{MP2, c} &= D_{pq}^\mathrm{MP2} B_{pq}^{A_t} + W_{pq}^\mathrm{MP2} [\mathrm{I}] S_{pq}^{A_t} + 2 T_{ij}^{ab} (ia|jb)^{A_t} \\ \partial_{A_t} E_\mathrm{HF, tot} &= h_{\mu \nu}^{A_t} D_{\mu \nu} + \frac{1}{2} (\mu \nu | \kappa \lambda)^{A_t} D_{\mu \nu} D_{\kappa \lambda} - \frac{1}{4} (\mu \kappa | \nu \lambda)^{A_t} D_{\mu \nu} D_{\kappa \lambda} - 2 F_{ij} S_{ij}^{A_t} + \partial_{A_t} E_\mathrm{nuc} \end{align}\end{split}\]
[7]:
(   # MP2 Contribution Derivative
    + einsum("pq, Apq -> A", gradh.D_r, gradh.B_1)
    + einsum("pq, Apq -> A", gradh.W_I, gradh.S_1_mo)
    + 2 * einsum("iajb, Aiajb -> A", gradh.T_iajb, gradh.eri1_mo[:, so, sv, so, sv])
).reshape(-1, 3)
[7]:
array([[ 0.0298,  0.0308,  0.0364],
       [-0.0179, -0.0035, -0.0059],
       [-0.0062, -0.0225, -0.0046],
       [-0.0057, -0.0049, -0.0259]])
[8]:
(   # Total Derivative
    + einsum("pq, Apq -> A", gradh.D_r, gradh.B_1)
    + einsum("pq, Apq -> A", gradh.W_I, gradh.S_1_mo)
    + 2 * einsum("iajb, Aiajb -> A", gradh.T_iajb, gradh.eri1_mo[:, so, sv, so, sv])
    + einsum("pq, Apq -> A", gradh.D, gradh.H_1_ao)
    + 0.5 * einsum("Auvkl, uv, kl -> A", gradh.eri1_ao, D, D)
    - 0.25 * einsum("Aukvl, uv, kl -> A", gradh.eri1_ao, D, D)
    - 2 * einsum("ij, Aij -> A", gradh.F_0_mo[so, so], gradh.S_1_mo[:, so, so])
    + mf_grad.grad_nuc().flatten()
).reshape(-1, 3)
[8]:
array([[-0.1109, -0.0858,  0.0086],
       [ 0.0768,  0.0067,  0.023 ],
       [ 0.0133,  0.059 ,  0.0179],
       [ 0.0209,  0.0201, -0.0494]])

对于当前的问题,我们需要作至少两部分考虑:对梯度的导数的分离、与避免大于 \(n_\mathrm{occ}^2 n_\mathrm{AO}^2\) 大小的内存储存。如果需要硬盘空间,应避免大于 \(n_\mathrm{occ} n_\mathrm{AO}^3\)

之所以不提出更高的要求,也是因为目前 PySCF 的代码就是如此实现的。在内存中没有 \(t_{ij}^{ab}\) 的情况下,程序无法处理 MP2 的核坐标梯度。一个额外允许的条件是,\(n_\mathrm{AO}^3\) 的内存量是可以接受的。尽管说对于小分子大基组的情况,这种三次方的内存实际上划不来;但当情况到苯分子的 aug-cc-pVQZ 时,\(n_\mathrm{occ}^2 n_\mathrm{AO}^2 \simeq n_\mathrm{AO}^3\)。我们姑且接受这种做法。

一般程序的实现过程

实际上,一般的程序都不是用下述方式生成 MP2 相关能所贡献的梯度:

\[\partial_{A_t} E_\mathrm{MP2, c} = D_{pq}^\mathrm{MP2} B_{pq}^{A_t} + W_{pq}^\mathrm{MP2} [\mathrm{I}] S_{pq}^{A_t} + 2 T_{ij}^{ab} (ia|jb)^{A_t}\]

相信这是因为电子积分通常都是以原子轨道形式存在。程序无法一次性地处理张量,因此必须要作某种分割;最直观的分割方式是对性质 (原子坐标) \(A_t\) 作分割。

但这种做法实际上并不合适。举例来说,在生成 \((\partial_t \mu | \nu)\) 时,程序的 API 只提供了生成维度 \((t, \mu_{s_1}, \nu_{s_2})\) 的电子积分。其中的原子轨道 \(\mu_{s_1}, \nu_{s_2}\) 是可以被分割的;但 \(t\) 并不能分割。这或许是出于积分效率上的考量。

因此,对 \(A_t\) 直接的分割并不合适。当然,对于核坐标梯度,对原子本身 \(A\) 的分割是可以接受的;这会在后面具体实现时遇到。

退而求其次的方法,是对原子轨道本身作分割。这就要求处理性质的时候,必须要写成原子轨道张量的分割之间的乘积。尽管公式推导时,使用分子轨道角标会非常便利;但在编写实际程序时,必须要考虑将思路转换一下,用原子轨道来实现大部分功能。

因此,我们还是必须要回到原子轨道矩阵的运算。我们的目标是回到 Aikens TCA (24):

\[\partial_{A_t} E_\mathrm{MP2, c} = D_{\mu \nu}^\mathrm{MP2} F_{\mu \nu}^{A_t} + W_{\mu \nu}^\mathrm{MP2} S_{\mu \nu}^{A_t} + \Gamma_{\mu \nu \kappa \lambda}^\mathrm{MP2} (\mu \nu | \kappa \lambda)^{A_t}\]

记号不同

这里使用 \(\Gamma_{\mu \nu \kappa \lambda}^\mathrm{MP2}\) 表示 Non-separable 部分的二阶密度,不包含 Separable 部分。这种定义方式是为了能将定义比较方便地拓展到 DFT 代码中。因此,这里与 Aikens TCA (24) 式存在差别。那边的公式第一项是 \(D_{\mu \nu}^\mathrm{MP2} h_{\mu \nu}^{A_t}\)

这里与分子轨道的角标有很多的不同。

  • 首先,这里没有出现 \(B_{pq}^{A_t}\),转而使用了 \(h_{\mu \nu}^{A_t}\)。对于一阶梯度而言,这是很重要的;因为 \(h_{\mu \nu}^{A_t}\) 是可以直接求得积分,而 \(B_{pq}^{A_t}\) 则是需要经过复杂积分求取的。

  • 作为代价,\(W_{\mu \nu}^\mathrm{MP2}\) 就不是简单的 \(W_{pq}^\mathrm{MP2} [\mathrm{I}]\) 的原子轨道表示了。

  • \(\Gamma_{\mu \nu \kappa \lambda}^\mathrm{MP2}\)\(2 T_{ij}^{ab}\) 有关。尽管用原子轨道看起来会把问题弄得更复杂,张量大小更多;但实际上对 FLOP 没有很大的影响 (FLOP 的增加不在 \(O(N^5)\) 的算法上)。

我们总结一下原子轨道基的计算过程。随后再对此作展开描述。

  • D_r_ao

\[D_{\mu \nu}^\mathrm{MP2} = C_{\mu p} D_{pq}^\mathrm{MP2} C_{\nu q}\]
[9]:
D_r = gradh.D_r
D_r_ao = einsum("μp, pq, νq -> μν", C, D_r, C)
  • W_I

\[\begin{split}\begin{aligned} W_{ij}^\mathrm{MP2} [\mathrm{I}] &= - 2 T_{ik}^{ab} (ja|kb) \\ W_{ab}^\mathrm{MP2} [\mathrm{I}] &= - 2 T_{ij}^{ac} (ib|jc) \\ W_{ai}^\mathrm{MP2} [\mathrm{I}] &= - 4 T_{jk}^{ab} (ij|bk) \\ W_{ia}^\mathrm{MP2} [\mathrm{I}] &= 0 \end{aligned}\end{split}\]
  • W_II

\[W_{pq}^\mathrm{MP2} [\mathrm{II}] = - D_{pq}^\mathrm{MP2} \varepsilon_q\]
  • W_III

\[W_{ij}^\mathrm{MP2} [\mathrm{III}] = - \frac{1}{2} A_{ij, pq} D_{pq}^\mathrm{MP2}\]
  • W, W_ao

\[\begin{split}\begin{align} W_{pq}^\mathrm{MP2} &= W_{pq}^\mathrm{MP2} [\mathrm{I}] + W_{pq}^\mathrm{MP2} [\mathrm{II}] + W_{pq}^\mathrm{MP2} [\mathrm{III}] \\ W_{\mu \nu}^\mathrm{MP2} &= C_{\mu p} W_{pq}^\mathrm{MP2} C_{\nu q} \end{align}\end{split}\]
[10]:
Ax0_Core = gradh.Ax0_Core
W_I = gradh.W_I
W_II = - einsum("pq, q -> pq", D_r, e)
W_III = np.zeros((nmo, nmo))
W_III[so, so] = - 0.5 * Ax0_Core(so, so, sa, sa)(D_r)
W = W_I + W_II + W_III
W_ao = einsum("μp, pq, νq -> μν", C, W, C)
  • D2_r_ao

\[\Gamma_{\mu \nu \kappa \lambda}^\mathrm{MP2} = 2 T_{ij}^{ab} C_{\mu i} C_{\nu a} C_{\kappa j} C_{\lambda b}\]
[11]:
D2_r_ao = 2 * einsum("iajb, μi, νa, κj, λb -> μνκλ", gradh.T_iajb, Co, Cv, Co, Cv)

最终的梯度结算:

\[\partial_{A_t} E_\mathrm{MP2, c} = D_{\mu \nu}^\mathrm{MP2} h_{\mu \nu}^{A_t} + W_{\mu \nu}^\mathrm{MP2} S_{\mu \nu}^{A_t} + \Gamma_{\mu \nu \kappa \lambda}^\mathrm{MP2} (\mu \nu | \kappa \lambda)^{A_t}\]
[12]:
(   # MP2 Contribution Derivative
    + einsum("μv, Aμv -> A", D_r_ao, gradh.F_1_ao)
    + einsum("μv, Aμv -> A", W_ao, gradh.S_1_ao)
    + einsum("μvkl, Aμvkl -> A", D2_r_ao, gradh.eri1_ao)
).reshape(-1, 3)
[12]:
array([[ 0.0298,  0.0308,  0.0364],
       [-0.0179, -0.0035, -0.0059],
       [-0.0062, -0.0225, -0.0046],
       [-0.0057, -0.0049, -0.0259]])

各张量的生成

\(D_{pq}^\mathrm{MP2, oo-vv}\) 的生成

我们首先会考虑比较简单的张量乘积:

\[\begin{split}\begin{aligned} D_{ij}^\text{MP2} &= - 2 T_{ik}^{ab} t_{jk}^{ab} \\ D_{ab}^\text{MP2} &= 2 T_{ij}^{ac} t_{ij}^{bc} \end{aligned}\end{split}\]

这个张量乘积可以很容易地实现。

[13]:
# Code block 1
D_r_oovv = np.zeros((nmo, nmo))
D_r_oovv[so, so] = - 2 * einsum("ikab, jkab -> ij", 2 * t2 - t2.swapaxes(-1, -2), t2)
D_r_oovv[sv, sv] = 2 * einsum("ijac, ijbc -> ab", 2 * t2 - t2.swapaxes(-1, -2), t2)
np.allclose(gradh.D_r[so, so], D_r_oovv[so, so]), np.allclose(gradh.D_r[sv, sv], D_r_oovv[sv, sv])
[13]:
(True, True)

上述过程可以很容易地通过拆分角标直接实现。譬如下述代码简单地对 \(i\) 做拆分,就很容易地在比较小的内存限制下工作,额外内存消耗 \(n_\mathrm{occ} n_\mathrm{vir}^2\)

[14]:
# Code block 2
D_r_oovv = np.zeros((nmo, nmo))
for i in range(nocc):
    D_r_oovv[so, so] += - 2 * einsum("iab, jab -> ij", 2 * t2[:, i] - t2[:, i].swapaxes(-1, -2), t2[:, i])
    D_r_oovv[sv, sv] += 2 * einsum("jac, jbc -> ab", 2 * t2[i] - t2[i].swapaxes(-1, -2), t2[i])
np.allclose(gradh.D_r[so, so], D_r_oovv[so, so]), np.allclose(gradh.D_r[sv, sv], D_r_oovv[sv, sv])
[14]:
(True, True)

但需要注意,说到底上面并不是经过优化了的代码。特别是针对这类问题,einsum 函数在转置 (Code block 1) 或在重复遍历 (Code block 2) 上,会花费大量的时间。我们在文档最后会特别提到这两个函数的优化问题。

这份文档的主要目标暂时不是作程序的最优化,而是首先对内存占用进行优化,指明一条可以程序实现的通路;后面的程序优化问题就希望是非常细节的优化了。

\(\Gamma_{\mu \nu \kappa \lambda}^\mathrm{MP2}\) 的生成:初步方案

MP2 梯度没有简单的代码。所有剩余的代码都要经过 \(\Gamma_{\mu \nu \kappa \lambda}^\mathrm{MP2}\) 的生成。

\[\Gamma_{\mu \nu \kappa \lambda}^\mathrm{MP2} = 2 T_{ij}^{ab} C_{\mu i} C_{\nu a} C_{\kappa j} C_{\lambda b}\]

我们首先不考虑内存的消耗 (因为储存这个二阶密度矩阵需要 \(n_\mathrm{AO}^4\) 的内存,显然这是不能接受的),看看通过 PySCF 代码改编而来的生成方式。

\[\begin{split}\begin{align} \mathtt{D2t1}_{i j \nu \lambda} &= t_{ij}^{ab} C_{\nu a} C_{\lambda b} \\ \mathtt{D2t2}_{i \nu \lambda j} &= 4 \times \mathtt{D2t1}_{i j \nu \lambda} - 2 \times \mathtt{D2t1}_{i j \lambda \nu} \\ \mathtt{D2t3}_{\mu \nu \lambda j} &= C_{\mu i} \mathtt{D2t2}_{i \nu \lambda j} \\ \mathtt{D2t4}_{\mu \nu \lambda \kappa} &= \mathtt{D2t3}_{\mu \nu \lambda j} C_{\kappa j} \end{align}\end{split}\]

我们能注意到,\(\Gamma_{\mu \nu \kappa \lambda}^\mathrm{MP2} = \mathtt{D2t4}_{\mu \nu \lambda \kappa}\)

[15]:
# D2t1 = einsum("ijab, νa, λb -> ijνλ", t2, Cv, Cv)
D2t1 = _ao2mo.nr_e2(
    t2.reshape(nocc**2, nvir**2), Cv.T,
    (0, nao, 0, nao), "s1", "s1").reshape((nocc, nocc, nao, nao))
# D2t2 = 4 * einsum("ijνλ -> iνλj", D2t1) - 2 * einsum("ijλν -> iνλj", D2t1)
D2t2 = 4 * D2t1.transpose((0, 2, 3, 1)) - 2 * D2t1.transpose((0, 3, 2, 1))
D2t3 = einsum("μi, iνλj -> μνλj", Co, D2t2)
D2t4 = einsum("μνλj, κj -> μνλκ", D2t3, Co)
[16]:
np.allclose(D2t4.swapaxes(-1, -2), D2_r_ao)
[16]:
True

其中,D2t1D2t2 的生成对内存的消耗是我们可以接受的;在 PySCF 中,D2t2part_dm2 变量表示。这两步若不使用 einsum,效率会比较高。但 D2t3D2t4 两步的消耗分别是 \(n_\mathrm{occ} n_\mathrm{AO}^3\)\(n_\mathrm{AO}^4\),我们无法承受。在任何实际的程序中,这两步都必须要进行分割。

在 PySCF 中,被分割的对象是 \(\mu\) 角标,从而将 D2t4 的内存消耗降到 \(n_\mathrm{blk} n_\mathrm{AO}^3\);但代价是无法生成完整的 D2t4 张量,必须要将其立即应用于中间矩阵或最终梯度性质的计算中。

二阶约化密度对梯度的贡献

我们先考察下述计算过程:

\[\partial_{A_t} E_\mathrm{MP2, c} \leftarrow \Gamma_{\mu \nu \kappa \lambda}^\mathrm{MP2} (\mu \nu | \kappa \lambda)^{A_t}\]
[17]:
einsum("μvkl, Aμvkl -> A", D2_r_ao, gradh.eri1_ao).reshape(-1, 3)
[17]:
array([[ 0.0182,  0.0146,  0.0167],
       [-0.0142, -0.0026, -0.0048],
       [-0.003 , -0.0113, -0.0028],
       [-0.001 , -0.0007, -0.009 ]])

推导的目标是将 \(\mu\) 的角标成功分离。最好可以一定程度上利用对称性。

首先,我们要注意到 \(\Gamma_{\mu \nu \kappa \lambda}^\mathrm{MP2} = \Gamma_{\kappa \lambda \mu \nu}^\mathrm{MP2}\)。这个性质与 \(T_{ij}^{ab} = T_{ji}^{ba}\) 的对称性有关。

[18]:
np.allclose(D2_r_ao, D2_r_ao.transpose((2, 3, 0, 1)))
[18]:
True

随后,我们希望将 \((\mu \nu | \kappa \lambda)^{A_t}\) 作展开:

\[\partial_{A_t} E_\mathrm{MP2, c} \leftarrow \Gamma_{\mu \nu \kappa \lambda}^\mathrm{MP2} (\mu \nu | \kappa \lambda)^{A_t} = \Gamma_{\mu \nu \kappa \lambda}^\mathrm{MP2} \big[ (\partial_{A_t} \mu \nu | \kappa \lambda) + (\mu \partial_{A_t} \nu | \kappa \lambda) + (\mu \nu | \partial_{A_t} \kappa \lambda) + (\mu \nu | \kappa \partial_{A_t} \lambda) \big]\]

我们的分割对象是 \(\mu\) 原子轨道,因此将所有出现 \(\partial_{A_t}\) 的记号全都转换到 \(\mu\) 的轨道上。利用下面的角标对换:

\[\begin{split}\begin{align} \Gamma_{\mu \nu \kappa \lambda}^\mathrm{MP2} (\mu \partial_{A_t} \nu | \kappa \lambda) \rightarrow \Gamma_{\nu \mu \kappa \lambda}^\mathrm{MP2}(\partial_{A_t} \mu \nu | \kappa \lambda) \quad & (\mu, \nu) \rightarrow (\nu, \mu) \\ \Gamma_{\mu \nu \kappa \lambda}^\mathrm{MP2} (\mu \nu | \partial_{A_t} \kappa \lambda) \rightarrow \Gamma_{\kappa \lambda \mu \nu}^\mathrm{MP2}(\partial_{A_t} \mu \nu | \kappa \lambda) \quad & (\kappa \lambda, \mu \nu) \rightarrow (\mu \nu, \kappa \lambda) \\ \Gamma_{\mu \nu \kappa \lambda}^\mathrm{MP2} (\mu \nu | \kappa \partial_{A_t} \lambda) \rightarrow \Gamma_{\lambda \kappa \nu \mu}^\mathrm{MP2}(\partial_{A_t} \mu \nu | \kappa \lambda) \quad & (\lambda \kappa, \mu \nu) \rightarrow (\mu \nu, \kappa \lambda) \\ \end{align}\end{split}\]

利用到 \(\Gamma_{\mu \nu \kappa \lambda}^\mathrm{MP2} = \Gamma_{\kappa \lambda \mu \nu}^\mathrm{MP2}\) 的性质,下述关系成立 (在对 \(\mu \nu \kappa \lambda\) 求和的情况下):

\[\begin{split}\begin{align} \Gamma_{\mu \nu \kappa \lambda}^\mathrm{MP2} (\mu \nu | \partial_{A_t} \kappa \lambda) &= \Gamma_{\kappa \lambda \mu \nu}^\mathrm{MP2}(\partial_{A_t} \mu \nu | \kappa \lambda) = \Gamma_{\mu \nu \kappa \lambda}^\mathrm{MP2}(\partial_{A_t} \mu \nu | \kappa \lambda) \\ \Gamma_{\mu \nu \kappa \lambda}^\mathrm{MP2} (\mu \nu | \kappa \partial_{A_t} \lambda) &= \Gamma_{\lambda \kappa \nu \mu}^\mathrm{MP2}(\partial_{A_t} \mu \nu | \kappa \lambda) = \Gamma_{\nu \mu \lambda \kappa}^\mathrm{MP2}(\partial_{A_t} \mu \nu | \kappa \lambda) \end{align}\end{split}\]

这次利用到 \((\partial_{A_t} \mu \nu | \kappa \lambda) = (\partial_{A_t} \mu \nu | \lambda \kappa)\) 的性质,因此 (在对 \(\mu \nu \kappa \lambda\) 求和的情况下):

\[\Gamma_{\mu \nu \kappa \lambda}^\mathrm{MP2} (\mu \nu | \kappa \partial_{A_t} \lambda) = \Gamma_{\nu \mu \lambda \kappa}^\mathrm{MP2}(\partial_{A_t} \mu \nu | \kappa \lambda) = \Gamma_{\nu \mu \kappa \lambda}^\mathrm{MP2}(\partial_{A_t} \mu \nu | \kappa \lambda)\]

因此,最终的表达式是

\[\partial_{A_t} E_\mathrm{MP2, c} \leftarrow \Gamma_{\mu \nu \kappa \lambda}^\mathrm{MP2} (\mu \nu | \kappa \lambda)^{A_t} = 2 \big( \Gamma_{\mu \nu \kappa \lambda}^\mathrm{MP2} + \Gamma_{\nu \mu \kappa \lambda}^\mathrm{MP2} \big) (\partial_{A_t} \mu \nu | \kappa \lambda)\]

这是可以通过对 \(\mu\) 进行分割,进而求和得到的结果。这里恰好是因为必须要对核坐标分割 (得到 \(\mu_A\)),我们就不进行更细致的分割了。实际程序中,要进行更小单位的分割,但过程是类似的。

回顾到 D2t2 是可以完整放在内存中的量,因此推导从 D2t2 开始:

\[\Gamma_{\mu \nu \kappa \lambda}^\mathrm{MP2} = C_{\mu i} \mathtt{D2t2}_{i \nu \lambda j} C_{\kappa j}\]
\[\begin{split}\begin{align} \partial_{A_t} E_\mathrm{MP2, c} &\leftarrow 2 \big( \Gamma_{\mu \nu \kappa \lambda}^\mathrm{MP2} + \Gamma_{\nu \mu \kappa \lambda}^\mathrm{MP2} \big) (\partial_{A_t} \mu \nu | \kappa \lambda) \\ &= - 2 \big( \Gamma_{\mu \nu_A \kappa \lambda}^\mathrm{MP2} + \Gamma_{\nu \mu_A \kappa \lambda}^\mathrm{MP2} \big) (\partial_t \mu_A \nu | \kappa \lambda) \\ &= - 2 \big( C_{\mu_A i} \mathtt{D2t2}_{i \nu \lambda j} C_{\kappa j} + C_{\nu i} \mathtt{D2t2}_{i \mu_A \lambda j} C_{\kappa j} \big) (\partial_t \mu_A \nu | \kappa \lambda) \end{align}\end{split}\]

我们会在程序中,声明

\[\begin{split}\begin{align} \mathtt{D2t5}_{\mu_A \nu \kappa \lambda} &= C_{\mu_A i} \mathtt{D2t2}_{i \nu \lambda j} C_{\kappa j} + C_{\nu i} \mathtt{D2t2}_{i \mu_A \lambda j} C_{\kappa j} \\ \mathtt{eri\_ip1t1}_{t \mu_A \nu \kappa \lambda} &= (\partial_t \mu_A \nu | \kappa \lambda) \end{align}\end{split}\]
[19]:
de_contrib_D2_r = np.zeros((natm, 3))
for A in range(natm):
    Ash0, Ash1, A0, A1 = mol.aoslice_by_atom()[A]
    # Evaluate D2t5
    D2t5 = einsum("μi, iνλj -> μνλj", Co[A0:A1], D2t2)
    D2t5 += einsum("νi, iμλj -> μνλj", Co, D2t2[:, A0:A1])
    D2t5 = einsum("μνλj, κj -> μνλκ", D2t5, Co)
    # Evaluate eri_ip1t1
    shls_slice = (Ash0, Ash1, 0, nbas, 0, nbas, 0, nbas)
    eri_ip1t1 = mol.intor("int2e_ip1", shls_slice=shls_slice)
    # Count contribution to derivative
    de_contrib_D2_r[A] = - 2 * einsum("μνλκ, tμνλκ -> t", D2t5, eri_ip1t1)
de_contrib_D2_r
[19]:
array([[ 0.0182,  0.0146,  0.0167],
       [-0.0142, -0.0026, -0.0048],
       [-0.003 , -0.0113, -0.0028],
       [-0.001 , -0.0007, -0.009 ]])

最后指出,在 PySCF 中,会对 D2t5\((\kappa, \lambda)\) 角标作对称化,使得积分 eri_ip1t1 可以利用到 s2kl 的对称性,从而将积分时间降低一半。

整个过程中,最耗时的步骤是 D2t5 的生成,需要 \(n_\mathrm{AO}^4 n_\mathrm{occ}\);需要的内存空间是 \(n_\mathrm{blk} n_\mathrm{AO}^3\)

\(W_{pq}^\mathrm{MP2} [\mathrm{I}]\) 的生成与重要中间矩阵 \(I_{\mu \nu}\)

对于这个问题,尽管可以像 \(D_{ij}^\mathrm{MP2}\)\(D_{ab}^\mathrm{MP2}\) 一样直接对分子轨道作张量缩并 (事实上这样也确实更快);但这里依照 PySCF 的流程,先导出其中一个重要的中间矩阵,称为 Imat \(I_{\mu \nu}\)。这个中间矩阵在后面会经常用到。这里的 \(I_{\mu \nu}\) 不代表恒等矩阵;我们用 \(\delta_{\mu \nu}\) 表示恒等矩阵。

我们先用最简单的代码给出 \(I_{\mu \nu}\) 的定义。

\[\begin{split}\begin{align} I_{pi} &= 2 T_{ik}^{ab} (pa|kb) \\ I_{pa} &= 2 T_{ik}^{ab} (ip|kb) \\ I_{\mu \nu} &= S_{\mu \eta} C_{\eta p} I_{pq} C_{\nu q} \end{align}\end{split}\]
[20]:
Imat_mo = np.zeros((nmo, nmo))
Imat_mo[:, so] = 2 * einsum("iakb, pakb -> pi", gradh.T_iajb, gradh.eri0_mo[sa, sv, so, sv])
Imat_mo[:, sv] = 2 * einsum("iakb, ipkb -> pa", gradh.T_iajb, gradh.eri0_mo[so, sa, so, sv])
Imat = gradh.S_0_ao @ C @ Imat_mo @ C.T

实际上分子轨道的 Imat_mo 已经能得到与 \(W_{pq}^\mathrm{MP2} [\mathrm{I}]\) 有关的张量了。

\[W_{ij}^\mathrm{MP2} [\mathrm{I}] = - I_{ji}, \; W_{ab}^\mathrm{MP2} [\mathrm{I}] = - I_{ab}, \; W_{ai}^\mathrm{MP2} [\mathrm{I}] = - 2 * I_{ia}\]
[21]:
np.allclose(- Imat_mo[so, so].T, gradh.W_I[so, so]), \
np.allclose(- Imat_mo[sv, sv].T, gradh.W_I[sv, sv]), \
np.allclose(- 2 * Imat_mo[so, sv].T, gradh.W_I[sv, so])
[21]:
(True, True, True)

随后我们就考察 Imat \(I_{\mu \nu}\) 的生成。先讨论生成目的。尽管 \(T_{ik}^{ab} (ja|kb)\) 的直接缩并的耗时会更低,但我们以后碰到的缩并不止有这种形式;还包括 Lagrangian 量的 \(T_{ik}^{cb} (ac|kb)\) 等等形式。因此,处理这类型的张量缩并总计算量估计为 \(n_\mathrm{AO}^5\),而非 \(n_\mathrm{occ} n_\mathrm{AO}^4\)。我们认为这种情况下,在原子轨道预先处理会比较好;在原子轨道下还可以进行有效的分割。

那么我们就将分子轨道的计算式用原子轨道表示:

\[I_{pi} = 2 T_{ik}^{ab} (pa|kb) = 2 T_{ik}^{ab} C_{\mu p} C_{\xi a} C_{\kappa k} C_{\lambda b} (\mu \xi | \kappa \lambda) = 2 T_{ik}^{ab} C_{\mu p} C_{\xi a} C_{\kappa k} C_{\lambda b} (\xi \mu | \kappa \lambda)\]

注意到 \(\Gamma_{\nu \xi \kappa \lambda}^\mathrm{MP2} = 2 T_{ij}^{ab} C_{\nu i} C_{\xi a} C_{\kappa j} C_{\lambda b}\),我们发现上式几乎可以构成 \(\Gamma_{\nu \xi \kappa \lambda}^\mathrm{MP2}\),但还缺少了 \(C_{\nu i}\)。补上这一项的方式是插入恒等式 \((\mathbf{C}^{-1})_{\nu i} C_{\nu i} = 1\)

\[I_{pi} = 2 T_{ik}^{ab} (pa|kb) = C_{\mu p} \Gamma_{\nu \xi \kappa \lambda}^\mathrm{MP2} (\xi \mu | \kappa \lambda) (\mathbf{C}^{-1})_{\nu i}\]

这里假设了 \(\mathbf{C}\) 作为系数矩阵是满秩的;但不满秩的情况下上式也能成立。回顾到 RHF 的条件之一,即轨道系数正交 \(C_{\nu i} S_{\nu \eta} C_{\eta j} = \delta_{ij}\),那么可以认为 \((\mathbf{C}^{-1})_{\nu i} = S_{\nu \eta} C_{\eta i}\)。从而

\[I_{pi} = 2 T_{ik}^{ab} (pa|kb) = C_{\mu p} \Gamma_{\nu \xi \kappa \lambda}^\mathrm{MP2} (\xi \mu | \kappa \lambda) S_{\nu \eta} C_{\eta i}\]

从而这里就可以定义原子轨道基下的矩阵

\[\begin{split}\begin{align} I_{\mu \nu}^\mathrm{occ} &= (\xi \mu | \kappa \lambda) \Gamma_{\nu \xi \kappa \lambda}^\mathrm{MP2} \\ I_{pi} &= C_{\mu p} I_{\mu \nu}^\mathrm{occ} S_{\nu \eta} C_{\eta i} \end{align}\end{split}\]

基于同样的方法,定义 (注意 \(\xi\)\(\nu\) 角标的顺序)

\[\begin{split}\begin{align} I_{\mu \nu}^\mathrm{vir} &= (\xi \mu | \kappa \lambda) \Gamma_{\xi \nu \kappa \lambda}^\mathrm{MP2} \\ I_{pa} &= C_{\mu p} I_{\mu \nu}^\mathrm{vir} S_{\nu \eta} C_{\eta a} \end{align}\end{split}\]

可以验证,对于占据部分的 \(I_{\mu \nu}^\mathrm{occ}\),非占的轨道下标缩并结果 \(C_{\mu p} I_{\mu \nu}^\mathrm{occ} S_{\nu \eta} C_{\eta a} = 0\);反之亦然。因此,我们规定

\[\begin{split}\begin{align} I_{\mu \nu}^\mathrm{occ} &= I_{\mu \nu}^\mathrm{occ} + I_{\mu \nu}^\mathrm{vir} = (\xi \mu | \kappa \lambda) \big( \Gamma_{\nu \xi \kappa \lambda}^\mathrm{MP2} + \Gamma_{\xi \nu \kappa \lambda}^\mathrm{MP2} \big) \\ I_{pq} &= C_{\mu p} I_{\mu \nu} S_{\nu \eta} C_{\eta q} \end{align}\end{split}\]

生成 \(I_{\mu \nu}\) 的过程可以通过对原子轨道分批完成。所用到的重要中间量是 D2t5。这里要回顾二阶约化密度对梯度贡献的代码部分。

[22]:
Imat = np.zeros((nao, nao))
for A in range(natm):
    Ash0, Ash1, A0, A1 = mol.aoslice_by_atom()[A]
    # Evaluate D2t5
    D2t5 = einsum("μi, iνλj -> μνλj", Co[A0:A1], D2t2)
    D2t5 += einsum("νi, iμλj -> μνλj", Co, D2t2[:, A0:A1])
    D2t5 = einsum("μνλj, κj -> μνλκ", D2t5, Co)
    # Evaluate eri_t1
    shls_slice = (Ash0, Ash1, 0, nbas, 0, nbas, 0, nbas)
    eri_t1 = mol.intor("int2e", shls_slice=shls_slice)
    # Count contribution to derivative
    Imat += einsum("ξμκλ, ξνκλ -> μν", eri_t1, D2t5)
[23]:
Imat_mo = einsum("μp, μν, νη, ηq -> pq", C, Imat, mol.intor("int1e_ovlp"), C)
np.allclose(- Imat_mo[so, so].T, gradh.W_I[so, so]), \
np.allclose(- Imat_mo[sv, sv].T, gradh.W_I[sv, sv]), \
np.allclose(- 2 * Imat_mo[so, sv].T, gradh.W_I[sv, so])
[23]:
(True, True, True)

Lagrangian 量 \(L_{ai}\)

有了 \(I_{pq}\) 之后,Lagrangian 量的求取就非常显然了。

\[\begin{split}\begin{align} L_{ai} &= A_{ai, pq} D_{pq}^\mathrm{MP2, oo-vv} - 4 T_{jk}^{ab} (ij|bk) + 4 T_{ij}^{bc} (ab|jc) \\ &= A_{ai, pq} D_{pq}^\mathrm{MP2, oo-vv} - 4 T_{jk}^{ab} (ji|kb) + 4 T_{ij}^{bc} (ab|jc) \\ &= A_{ai, pq} D_{pq}^\mathrm{MP2, oo-vv} - 2 I_{ia} + 2 I_{ai} \end{align}\end{split}\]
[24]:
np.allclose(gradh.Ax0_Core(sv, so, sa, sa)(D_r_oovv) - 2 * Imat_mo[so, sv].T + 2 * Imat_mo[sv, so], gradh.L)
[24]:
True

我们在这份文档中,不讨论 Ax0_Core\(A_{ai, pq}\) 的实现。这会放在 DFT 代码实现中考虑。

MP2 弛豫密度 \(D_{ai}^\mathrm{MP2}\)

CP-HF 方程的求取在给出 \(L_{ai}\) 之后,就不是困难事了。

\[- (\varepsilon_a - \varepsilon_i) D_{ai}^\mathrm{MP2} - A_{ai, bj} D_{bj}^\mathrm{MP2} = L_{ai}\]

其计算复杂度是 \(O(T n_\mathrm{AO}^4)\),即开销非常大的四次方算法。内存复杂度由 Ax0_Core 控制。

[25]:
D_r_vo = cphf.solve(gradh.Ax0_Core(sv, so, sv, so), e, mf_scf.mo_occ, gradh.L)[0]

自此,弛豫密度部分就求取完毕了。这一项对于求取后续的各种梯度量非常重要。

[26]:
D_r = D_r_oovv.copy()
D_r[sv, so] = D_r_vo

Hamiltonian Core 对梯度的贡献

\[\partial_{A_t} E_\mathrm{MP2, c} \leftarrow D_{\mu \nu}^\mathrm{MP2} F_{\mu \nu}^{A_t}\]

其中

\[D_{\mu \nu}^\mathrm{MP2} = C_{\mu p} D_{pq}^\mathrm{MP2} C_{\nu q}\]
[27]:
D_r_ao = einsum("μp, pq, νq -> μν", C, D_r, C)

其中的 \(F_{\mu \nu}^{A_t}\) 我们暂不讨论其生成方式。我们这里就直接使用 PySCF 的结果。

[28]:
mf_scf_hess = hessian.RHF(mf_scf)
F_1_ao = mf_scf_hess.make_h1(C, mf_scf.mo_occ)
[29]:
de_contrib_hamilt = einsum("μν, Atμν -> At", D_r_ao, F_1_ao)
de_contrib_hamilt
[29]:
array([[ 0.0256,  0.03  ,  0.0335],
       [-0.0145, -0.0026, -0.0045],
       [-0.005 , -0.02  , -0.0041],
       [-0.0062, -0.0074, -0.0249]])

完整的 \(W_{\mu \nu}^\mathrm{MP2}\) 对梯度的贡献

由于我们已经完成了复杂的 \(W_{pq}^\mathrm{MP2} [\mathrm{I}]\) 的生成;其余步骤在给定 Ax0_Core 函数的情况下是显然容易实现的。我们假定 W_ao \(W_{\mu \nu}^\mathrm{MP2}\) 已经生成了。

稍微麻烦一些的问题是重叠矩阵的使用。PySCF 中不直接给出 \(S_{\mu \nu}^{A_t}\),而是计算 \((\partial_t \mu | \nu)\)。因此,

\[\partial_{A_t} E_\mathrm{MP2, c} \leftarrow - \big( W_{\mu_A \nu}^\mathrm{MP2} + W_{\nu \mu_A}^\mathrm{MP2} \big) (\partial_t \mu_A | \nu)\]
[30]:
de_contrib_W_ao = np.zeros((natm, 3))
int1e_ipovlp = mol.intor("int1e_ipovlp")
for A in range(natm):
    _, _, A0, A1 = mol.aoslice_by_atom()[A]
    de_contrib_W_ao[A] -= einsum("μν, tμν -> t", W_ao[A0:A1], int1e_ipovlp[:, A0:A1])
    de_contrib_W_ao[A] -= einsum("νμ, tμν -> t", W_ao[:, A0:A1], int1e_ipovlp[:, A0:A1])
de_contrib_W_ao
[30]:
array([[-0.0139, -0.0139, -0.0137],
       [ 0.0107,  0.0018,  0.0034],
       [ 0.0018,  0.0089,  0.0023],
       [ 0.0015,  0.0033,  0.008 ]])

MP2 二阶梯度实现总结

[31]:
%reset -f
[32]:
from pyscf import gto, scf, mp, grad, hessian, ao2mo
import numpy as np
from pyscf.scf import cphf
from pyscf.ao2mo import _ao2mo
import opt_einsum

from pyxdh.DerivOnce import GradMP2  # only to use it's Ax0_Core utility here

np.set_printoptions(4, suppress=True, linewidth=180)
einsum = opt_einsum.contract
[33]:
# Preparation 1: pyscf objects
mol = gto.Mole()
mol.atom = """
N  0.  0.  0.
H  1.5 0.  0.2
H  0.1 1.2 0.
H  0.  0.  1.
"""
mol.basis = "6-31G"
mol.verbose = 0
mol.build()

mf_scf = scf.RHF(mol).run()
mf_mp2 = mp.MP2(mol).run()
mf_grad = grad.mp2.Gradients(mf_mp2)
mf_scf_hess = hessian.RHF(mf_scf)
gradh = GradMP2({"scf_eng": mf_scf})
[34]:
# Preparation 2: dimension declaration
nocc, nmo, nao = mol.nelec[0], mol.nao, mol.nao
natm, nbas = mol.natm, mol.nbas
nvir = nmo - nocc
so, sv, sa = slice(0, nocc), slice(nocc, nmo), slice(0, nmo)
[35]:
# Preparation 3: important intermediates
C, e, D = mf_scf.mo_coeff, mf_scf.mo_energy, mf_scf.make_rdm1()
mo_occ = mf_scf.mo_occ
Co, Cv = C[:, so], C[:, sv]
eo, ev = e[so], e[sv]
t2 = mf_mp2.t2
Ax0_Core = gradh.Ax0_Core
[36]:
# Step 1: RDM1 (occ-occ block, vir-vir block)
D_r_oovv = np.zeros((nmo, nmo))
for i in range(nocc):
    D_r_oovv[so, so] += - 2 * einsum("iab, jab -> ij", 2 * t2[:, i] - t2[:, i].swapaxes(-1, -2), t2[:, i])
    D_r_oovv[sv, sv] += 2 * einsum("jac, jbc -> ab", 2 * t2[i] - t2[i].swapaxes(-1, -2), t2[i])
[37]:
# Step 2: RDM2 by atomic slices, Add to gradient contribution
# Step 3: Imat

# D2t1 = einsum("ijab, νa, λb -> ijνλ", t2, Cv, Cv)
D2t1 = _ao2mo.nr_e2(
    t2.reshape(nocc**2, nvir**2), Cv.T,
    (0, nao, 0, nao), "s1", "s1").reshape((nocc, nocc, nao, nao))
# D2t2 = 4 * einsum("ijνλ -> iνλj", D2t1) - 2 * einsum("ijλν -> iνλj", D2t1)
D2t2 = 4 * D2t1.transpose((0, 2, 3, 1)) - 2 * D2t1.transpose((0, 3, 2, 1))
# Allocate gradient contribution
de_contrib_D2_r = np.zeros((natm, 3))
# Allocate Imat
Imat = np.zeros((nao, nao))
for A in range(natm):
    Ash0, Ash1, A0, A1 = mol.aoslice_by_atom()[A]
    # Evaluate D2t5
    D2t5 = einsum("μi, iνλj -> μνλj", Co[A0:A1], D2t2)
    D2t5 += einsum("νi, iμλj -> μνλj", Co, D2t2[:, A0:A1])
    D2t5 = einsum("μνλj, κj -> μνλκ", D2t5, Co)
    # Evaluate eri_ip1t1
    shls_slice = (Ash0, Ash1, 0, nbas, 0, nbas, 0, nbas)
    eri_ip1t1 = mol.intor("int2e_ip1", shls_slice=shls_slice)
    # Count contribution to derivative
    de_contrib_D2_r[A] = - 2 * einsum("μνλκ, tμνλκ -> t", D2t5, eri_ip1t1)
    # Evaluate eri_t1
    shls_slice = (Ash0, Ash1, 0, nbas, 0, nbas, 0, nbas)
    eri_t1 = mol.intor("int2e", shls_slice=shls_slice)
    # Count contribution to derivative
    Imat += einsum("ξμκλ, ξνκλ -> μν", eri_t1, D2t5)
Imat_mo = einsum("μp, μν, νη, ηq -> pq", C, Imat, mol.intor("int1e_ovlp"), C)
[38]:
# Step 4: Lagrangian
L = Ax0_Core(sv, so, sa, sa)(D_r_oovv) - 2 * Imat_mo[so, sv].T + 2 * Imat_mo[sv, so]
[39]:
# Step 5: full RDM1 and CP-HF
D_r_vo = cphf.solve(gradh.Ax0_Core(sv, so, sv, so), e, mf_scf.mo_occ, gradh.L)[0]
D_r = D_r_oovv.copy()
D_r[sv, so] = D_r_vo
D_r_ao = einsum("μp, pq, νq -> μν", C, D_r, C)
[40]:
# Step 6: full W matrix
W_I = np.zeros((nmo, nmo))
W_I[so, so] = - Imat_mo[so, so].T
W_I[sv, sv] = - Imat_mo[sv, sv].T
W_I[sv, so] = - 2 * Imat_mo[so, sv].T
W_II = - einsum("pq, q -> pq", D_r, e)
W_III = np.zeros((nmo, nmo))
W_III[so, so] = - 0.5 * Ax0_Core(so, so, sa, sa)(D_r)
W = W_I + W_II + W_III
W_ao = einsum("μp, pq, νq -> μν", C, W, C)
[41]:
# Step 7: gradient contribution of first derivative of fock matrix
F_1_ao = mf_scf_hess.make_h1(C, mf_scf.mo_occ)
de_contrib_hamilt = einsum("μν, Atμν -> At", D_r_ao, F_1_ao)
[42]:
# Step 8: gradient contribution of W matrix
de_contrib_W_ao = np.zeros((natm, 3))
int1e_ipovlp = mol.intor("int1e_ipovlp")
for A in range(natm):
    _, _, A0, A1 = mol.aoslice_by_atom()[A]
    de_contrib_W_ao[A] -= einsum("μν, tμν -> t", W_ao[A0:A1], int1e_ipovlp[:, A0:A1])
    de_contrib_W_ao[A] -= einsum("νμ, tμν -> t", W_ao[:, A0:A1], int1e_ipovlp[:, A0:A1])
[43]:
# Step 9: sum up gradients
de_contrib_D2_r + de_contrib_hamilt + de_contrib_W_ao
[43]:
array([[ 0.0298,  0.0308,  0.0364],
       [-0.0179, -0.0035, -0.0059],
       [-0.0062, -0.0225, -0.0046],
       [-0.0057, -0.0049, -0.0259]])

程序的优化问题:以 \(D_{ij}^\mathrm{MP2}\) 为例

我们现在的目标是较大的分子,因此在作测试时,需要把体系扩大但不至于太大。我们令占据轨道数是 20,虚轨道数是 150。

[44]:
NO, NV = 20, 150
T2 = np.random.randn(NO, NO, NV, NV)

现在如果用比较简单的两段代码 (用 V_1V_2 分别表示) 完成下述工作:

\[V_{ij} = - 2 (2 t_{ik}^{ab} - t_{ik}^{ba}) t_{jk}^{ab}\]
[45]:
V_1 = - 2 * einsum("ikab, jkab -> ij", 2 * T2 - T2.swapaxes(-1, -2), T2)
V_2 = np.zeros((NO, NO))
for k in range(NO):
    V_2 -= 2 * einsum("iab, jab -> ij", 2 * T2[:, k] - T2[:, k].swapaxes(-1, -2), T2[:, k])
np.allclose(V_1, V_2)
[45]:
True

我们或许会认为,把所有的工作交给 einsum 完成会比较高效;但实际情况反而是有时显式地声明 for 循环效率更高。

[46]:
%%timeit -n 5
V_1 = - 2 * einsum("ikab, jkab -> ij", 2 * T2 - T2.swapaxes(-1, -2), T2)
75.7 ms ± 13.7 ms per loop (mean ± std. dev. of 7 runs, 5 loops each)
[47]:
%%timeit -n 5
V_2 = np.zeros((NO, NO))
for k in range(NO):
    V_2 -= 2 * einsum("iab, jab -> ij", 2 * T2[:, k] - T2[:, k].swapaxes(-1, -2), T2[:, k])
49.5 ms ± 7.48 ms per loop (mean ± std. dev. of 7 runs, 5 loops each)

当使用 profile 功能后,或许会发现其中的 reshape 相当耗时。实际上 einsum 或 tensordot 所要求的 reshape 是非常恐怖的。以 MOO_1 为例,在 50 次运行时的 %prun 下给出的结果是

 ncalls  tottime  percall  cumtime  percall filename:lineno(function)
    150    1.996    0.013    1.996    0.013 {method 'reshape' of 'numpy.ndarray' objects}
      1    1.364    1.364    3.994    3.994 <ipython-input-275-26d8e19bfd5b>:1(func)
150/100    0.619    0.004    2.619    0.026 {built-in method numpy.core._multiarray_umath.implement_array_function}

按理最耗时的部分是张量乘积的 0.619 s,但实则是转置的 1.996 s。

如果我们手动作 reshape,反而效率会提升很多。

\[\begin{split}\begin{align} A_{i, kab} &= 2 t_{ik}^{ab} - t_{ik}^{ba} \\ B_{kab, j} &= t_{jk}^{ab} \\ V_{ij} &= - 2 A_{i, kab} B_{j, kab} \end{align}\end{split}\]
[48]:
%%timeit -n 5
A = (2 * T2 - T2.swapaxes(-1, -2)).reshape(NO, -1)
B = T2.reshape(NO, -1).swapaxes(0, 1)
V_3 = - 2 * A.dot(B)
42.6 ms ± 6.11 ms per loop (mean ± std. dev. of 7 runs, 5 loops each)

这看起来作了很多手动的转置,效率非常低;但实际上是大大地提升了效率。但我们不能满足于此。对该过程重复 50 次,作 profile 得到的结果是

ncalls  tottime  percall  cumtime  percall filename:lineno(function)
     1    1.388    1.388    1.946    1.946 <ipython-input-284-9d7cb9c8a884>:1(func)
    50    0.557    0.011    0.557    0.011 {method 'dot' of 'numpy.ndarray' objects}

其中匿名函数 1 的耗时最长,有 1.388 秒。这一步应当归属于 \(2 t_{ik}^{ab} - t_{ik}^{ba}\) 的相减或标量乘的过程。为此,我们可以考虑下述思路。

\[\begin{split}\begin{align} A_{i, kab} &= t_{ik}^{ab} \\ B_{i, kab} &= t_{ik}^{ba} \\ V_{ij} &= - 4 A_{i, kab} A_{j, kab} + 2 A_{i, kab} B_{j, kab} \end{align}\end{split}\]
[49]:
%%timeit -n 5
A = T2.reshape(NO, -1)
B = T2.swapaxes(-1, -2).reshape(NO, -1)
V_4 = - 4 * A.dot(A.T) + 2 * B.dot(A.T)
39.3 ms ± 11.1 ms per loop (mean ± std. dev. of 7 runs, 5 loops each)

该过程重复 50 次得到的 profile 结果是

ncalls  tottime  percall  cumtime  percall filename:lineno(function)
   100    0.795    0.008    0.795    0.008 {method 'reshape' of 'numpy.ndarray' objects}
   100    0.738    0.007    0.738    0.007 {method 'dot' of 'numpy.ndarray' objects}
     1    0.012    0.012    1.545    1.545 <ipython-input-304-dde8e7fb18fe>:1(func)

似乎 reshape 又一次进入了函数,并且有较大的耗时 0.795 s;但实际上这个耗时包含在 MOD_3 的 1.388 s 中。该过程额外的损耗是进行了两次 dot 计算。对于当前的体系而言,有可能进行加减法运算的消耗反而比两次 dot 的消耗更大。因此,现在还无法判断两种算法孰优孰劣。目前看来,已经很难再减少 reshape 的耗时了;这部分的转置可能无法避免。

如果现在希望避免太大的内存消耗,我们可以取其中的 \(k\) 角标作显式 for 循环:

[50]:
%%timeit -n 5
V_5 = np.zeros((NO, NO))
for k in range(NO):
    A = T2[:, k].reshape(NO, -1).copy()
    B = T2[:, k].swapaxes(-1, -2).reshape(NO, -1)
    V_5 += - 4 * A.dot(A.T) + 2 * B.dot(A.T)
44.9 ms ± 24.2 ms per loop (mean ± std. dev. of 7 runs, 5 loops each)

这就是以一定的时间换空间了。

最后指出,一般情况下 reshape 需要尽最大可能避免;因为 reshape 是单线程函数,但其它 numpy 函数往往是多线程的。

索引