MatthewRHermes / mrh

MRH's research code
Other
18 stars 30 forks source link

Question about `gen_g_hop` and testing `pdft_feff` #37

Closed matthew-hennefarth closed 1 year ago

matthew-hennefarth commented 1 year ago

See how this works to use github to ask questions. So in general, I have written out the kernel functions for generating $\Delta\cdot \mathbf{F}$ which have elements of $$\left[\Delta \cdot \mathbf{F}\right]_{pq} = \int \vec{\rho}^t\Delta\mathbf{f}^\mathrm{ot}\frac{\partial \vec{\rho}}{\partial\gamma{pq}}$$ and for the 4-body term as well here. It uses some generalization of the veff code which I extracted/reused. I was able to test that the lazy_kernel and kernel functions agree using the same method as the veff code (I used the ao->mo for the 2-electron terms). What I want to do though is test that the $\Delta\cdot \mathbf{F}$ elements agree when you numerically differentiate the $V{pq}$ and $v{pqrs}$ elements and then contract with a density.

For testing pdft_veff, I generally understand what is happening here. The gen_g_hop gets the gradient, gradient update, hessian_update, and hessian_diag elements, right? Does g_all, in the orbital sector (ie g_all[:ngorb]) contain the $$F{xy} - F{yx}$$ where $F_{xy}$ is the generalized Fock matrix elements given the eris and get_hcore() in the current environment?

Regardless, you get the x0 term which is the step in some direction. You then scale that term progressively smaller and checking to see if you get numerical convergence of the difference between the analytical and numerical derivative (summary of these lines). I am a bit confused on what np.dot(g_all, x1) represents here mathematically (which is conflated with what exactly g_all is).

With that being said, how should I go about testing the pdft_feff terms numerically? I understand that you wrote the EotOrbitalHessianOperator and test this, but is there a simpler way of testing without having to copy a bunch of code from the file? You also discuss needing to use a "dressed gen_g_hop" object (here).

Lastly, I am a bit confused on what exactly paaa_only does for the _ERIS (see these lines). If I understand correctly, you are adding the active space contribution of the HF potential to the HF potential core term? I know this is important since similar terms appear in the gen_g_hop code and the EotOrbitalHessianOperator

Any help would be appreciated, and let me know if you would rather me ask these questions via a different method of communication.

MatthewRHermes commented 1 year ago

For testing pdft_veff, I generally understand what is happening here. The gen_g_hop gets the gradient, gradient update, hessian_update, and hessian_diag elements, right? Does g_all, in the orbital sector (ie g_all[:ngorb]) contain the Fxy−Fyx where Fxy is the generalized Fock matrix elements given the eris and get_hcore() in the current environment?

Yes. Furthermore, the remaining elements (g_all[ngorb:]) are gradients of the energy with respect to CI vector relaxations towards individual determinants:

$$\langle i|\hat{H}-E_0|0\rangle + \mathrm{h.c.}$$

Regardless, you get the x0 term which is the step in some direction. You then scale that term progressively smaller and checking to see if you get numerical convergence of the difference between the analytical and numerical derivative (summary of these lines). I am a bit confused on what np.dot(g_all, x1) represents here mathematically (which is conflated with what exactly g_all is).

The product of a gradient with a step vector is an approximation to the energy difference after that step, which becomes exact in the limit of small step size with a definite constant of proportionality:

$$ \Delta f = f(1)\delta + \frac{1}{2}f(2)\delta^2 + \cdots $$

$$ \lim_{\delta\to 0} \frac{\Delta f - f(1)\delta}{\Delta f} = \frac{1}{2} \frac{f(2)}{f(1)}\delta $$

In other words, there is an asymptotic plateau where if you halve the step size, you should also halve the relative error of the linear approximation. np.dot (g_all, x1) is $f(1)\delta$ and seminum (x1) is $\Delta f$. The version of this for second derivatives is

$$ \Delta (f') = f(2)\delta + \frac{1}{2}f(3)\delta^2 + \cdots $$

$$ \lim_{\delta\to 0} \frac{\Delta (f') - f(2)\delta}{\Delta (f')} = \frac{1}{2} \frac{f(3)}{f(2)}\delta $$

but now the left and right-hand sides of the expression are both vectors. I compare them by evaluating the norm of the difference vector here.

With that being said, how should I go about testing the pdft_feff terms numerically? I understand that you wrote the EotOrbitalHessianOperator and test this, but is there a simpler way of testing without having to copy a bunch of code from the file? You also discuss needing to use a "dressed gen_g_hop" object (here).

I'm sure there is a simpler way to implement because my implementation is incredible spaghetti. But regarding the "dressed gen_g_hop" comment, that is a reminder that what you call the "explicit" contribution to the Hessian is mappable to a CASSCF gradient with a modified Hamiltonian. That's what that entire conditional block of the initializer is about: if incl_d2rho==False, then only the terms in the Hessian-vector product which involve $\mathbf{f}^\mathrm{ot}$ are computed.

Lastly, I am a bit confused on what exactly paaa_only does for the _ERIS (see these lines). If I understand correctly, you are adding the active space contribution of the HF potential to the HF potential core term? I know this is important since similar terms appear in the gen_g_hop code and the EotOrbitalHessianOperator

vhf_c by default is

$$ V{pq} = v{pqii}Dii - \frac{1}{2}v{piiq}Dii = \frac{1}{2}v{pqii}D{ii} = v{pqii} = \int v{\Pi} \rho{\mathrm{core}} \frac{\partial\rho}{\partial D_{pq}} $$

If paaa_only, I hijack it to add

$$ V{ai} \mathrel{+}= v{aiuv}D{uv} - \frac{1}{2}v{avui} D{uv} = \frac{1}{2}v{aiuv}D{uv} = \int v{\Pi} \rho{\mathrm{active}} \frac{\partial\rho}{\partial D{ai}} $$

$$ V{aa} \mathrel{+}= v{aauv}D{uv} \cdots = \int v{\Pi} \rho{\mathrm{active}} \frac{\partial\rho}{\partial D{aa}} $$

$$ V{ii} \mathrel{+}= v{iiuv}D{uv+} \cdots = \int v{\Pi} \rho{\mathrm{active}} \frac{\partial\rho}{\partial D{ii}} $$

$$ V{ia} \mathrel{+}= v{iauv}D{uv} \cdots = \int v{\Pi} \rho{\mathrm{active}} \frac{\partial\rho}{\partial D{ia}} $$

since these lead to contributions to the orbital gradient that, by default in PySCF, are computed using explicit two-electron interaction elements of the appropriate index patterns (i.e., of type ppaa and papa), which we have not computed if paaa_only. I think this would actually be a quicker algorithm for doing CASSCF orbital gradients themselves, if CASSCF didn't already need ppaa and papa ERIs anyway for orbital Hessians. But anyway the whole thing is designed so that the gradient that pops out of the CASSCF gen_g_hop code is correct regardless of whether or not paaa_only. But if you want Hessians, you'll need paaa_only=False because you'll need ppaa and papa.

P.S. A bunch of factors of 1/2 are missing in the above five lines; don't take them too seriously. I'm just trying to show the orbital index patterns.

matthew-hennefarth commented 1 year ago

Okay, I think I understand a bit better, especially since I got it to sort of agree. What I have then is that $$\frac{\partial( V{pq}D{pq} + \frac{1}{2}v{pqrs}d{pqrs})}{\kappa{xy}} = F{xy} - F{yx}$$ where $$F{xy} = V{px}D{py} + v{pqrx}d{pqry} + [\rho\cdot \mathbf{F}]_{px}D_{py} + [\rho\cdot \mathbf{F}]_{pqrx}d_{pqry}$$ The "explicit" and "implicit" terms. What I can do then, is compute analytically this derivative by doing

feff1, feff2 = mc.get_pdft_feff(mc.mo_coeff, mc.ci, paaa_only=True)
veff1, veff2 = mc.get_pdft_veff(mc.mo_coeff, mc.ci, incl_coul=False, paaa_only=True)
with lib.temporary_env(fcasscf, get_hcore=lambda:  feff1):
    g_feff, _, _, hdiag_feff = newton_casscf.gen_g_hop(fcasscf, mc.mo_coeff, mc.ci, feff2)
with lib.temporary_env(fcasscf, get_hcore=lambda: veff1):
    g_veff, _, _, hdiag_veff = newton_casscf.gen_g_hop(fcasscf, mc.mo_coeff, mc.ci, veff2)

I do it this way since I can't really add feff2 and veff2 since they are _ERIS objects (and addition is not well defined). I then set g_all and hdiag_all as

g_all = g_feff + g_veff
hdiag_all = hdiag_feff + hdiag_veff

Though I am now unsure if I can just add hdiag_feff and hdiag_veff. Regardless, when I do this, I can then numerically differentiate $$V{pq}D{pq} + \frac{1}{2}v{pqrs}d{pqrs} $$ by evaluating at the reference MO/CI and MO/CI + $\delta$, I get good agreement only when there are no core orbitals (ncore=0, or just for H2). When I go to LiH, I no longer get agreement (which has ncore=1).

I am doing it this way since feff1 and feff2 pop-out as already being partially contracted (ie they are your Hessian-vector products) and can't really be interrogated in any other way (at least that I can think of). You can see my progress here for testing.

matthew-hennefarth commented 1 year ago

Though I am now unsure if I can just add hdiag_feff and hdiag_veff.

Just checked, and they agree with adding each term in the _ERIS object. Still not sure why the numerical evaluation doesn't decrease by 1/2 when there are core orbitals.

MatthewRHermes commented 1 year ago

On Friday we compared LiH (4e,3o) to LiH (2e,2o) and found that the differentiation fails for the latter but apparently not for the former. What happens if you take the LiH (2e,2o) wave function(s) and project it/them into the the LiH (4e,3o) Hilbert space? The cistring module has functions for doing things like this, even though I'm pretty sure it's equivalent to just pad the CI vector with zeros:

import numpy as np
from pyscf.fci import cistring
from pyscf.fci.direct_spin1 import _unpack_nelec

ncore_add = 1 # core orbitals to put in the active space
nvirt_add = 0 # virtual orbitals to put in the active space
ci_small = mc.ci # It'll get annoying if you have more than one CI vector here

# throat-clearing
norb_small = mc.ncas
norb_large = norb_small + ncore_add + nvirt_add
neleca_small, nelecb_small = _unpack_nelec (mc.nelecas)
neleca_large = neleca_small + ncore_add
nelecb_large = nelecb_small + ncore_add
ndeta_large = cistring.num_strings (norb_large, neleca_large)
ndetb_large = cistring.num_strings (norb_large, nelecb_large)
ci_large = np.zeros ((ndeta_large, ndetb_large), dtype=ci_small.dtype)

# Find the addresses of all determinants with fully-occupied core electrons
# I don't trust the behavior of this function with a range of length 0
addrs_a_core = cistring.sub_addrs (norb_large, neleca_large, range (ncore_add), ncore_add) if ncore_add else []
addrs_b_core = cistring.sub_addrs (norb_large, nelecb_large, range (ncore_add), ncore_add) if ncore_add else []

# Find the addresses of all determinants with fully-unoccupied virtual electrons
nvirt_range = range (ncore_add+norb_small,norb_large)
addrs_a_virt = cistring.sub_addrs (norb_large, neleca_large, nvirt_range, 0) if nvirt_add else []
addrs_b_virt = cistring.sub_addrs (norb_large, nelecb_large, nvirt_range, 0) if nvirt_add else []

# The intersection of these two address spaces is the address space of the smaller active space in the larger one.
if ncore_add and nvirt_add:
    addrs_a = np.intersect1d (addrs_a_core, addrs_a_virt)
    addrs_b = np.intersect1d (addrs_b_core, addrs_b_virt)
elif ncore_add:
    addrs_a, addrs_b = addrs_a_core, addrs_b_core
elif nvirt_add:
    addrs_a, addrs_b = addrs_a_virt, addrs_b_virt
else:
    raise RuntimeError ("whuddyadoin")

ci_large[np.ix_(addrs_a,addrs_b)] = ci_small[:,:]

Then all the f's and v's ought to be identical term-by-term.

MatthewRHermes commented 1 year ago

Having looked at this myself, it looks like energy_core might be the problem somehow:

/home/herme068/anaconda3/envs/pyscf/lib/python3.7/site-packages/pyscf/dft/libxc.py:772: UserWarning: Since PySCF-2.3, B3LYP (and B3P86) are changed to the VWN-RPA variant, corresponding to the original definition by Stephens et al. (issue 1480) and the same as the B3LYP functional in Gaussian. To restore the VWN5 definition, you can put the setting "B3LYP_WITH_VWN5 = True" in pyscf_conf.py
  warnings.warn('Since PySCF-2.3, B3LYP (and B3P86) are changed to the VWN-RPA variant, '
Full Tests for pdft_feff
----------------------------------------------------------------------
mol = LiH state = Singlet fnal = ftLDA,VWN3 ncas = 2
eff1: -4.4845491423571815
aaaa (ncas=2): [[[[ 0.19993498 -0.03594766]
   [-0.03594766  0.05524505]]

  [[-0.03594766  0.05524505]
   [ 0.05524505 -0.02883232]]]

 [[[-0.03594766  0.05524505]
   [ 0.05524505 -0.02883232]]

  [[ 0.05524505 -0.02883232]
   [-0.02883232  0.06511764]]]]
veff2.vhf_c (ncas=2): [[0.00374441 0.00157538]
 [0.00157538 0.00132654]]
feff2.vhf_c (ncas=2): [[0.05546656 0.02278294]
 [0.02278294 0.01373895]]
veff2.energy_core (ncas=2): 0.06549210880331385
feff2.energy_core (ncas=2): 1.1027722696928537
energy_core (ncas=2): 1.1682643784961675
MOs: 6, ncore: 1, ncas: 2, nelecas: (1, 1)
[0.5        0.40704163]
[0.5        0.32052317]
[0.5        0.14229989]
[0.5        0.02539863]
[ 0.5        14.22024932]
[0.5        1.25982536]
[0.5       1.0209301]
[0.5        0.98685039]
[0.5        0.98661162]
[0.5        0.99174401]
[0.5        0.99555796]
[0.5        0.99767472]
[0.5        0.99880896]
[0.5        0.99939692]
[0.5        0.99969651]
[0.5        0.99984776]
[0.5        0.99992375]
[0.5        0.99996185]
[0.5        0.99998093]
----------------------------------------------------------------------
mol = LiH state = Singlet fnal = ftLDA,VWN3 ncas = 3
eff1: -4.4845491423571815
aaaa (ncas=2): [[[[ 0.19993498 -0.03594766]
   [-0.03594766  0.05524505]]

  [[-0.03594766  0.05524505]
   [ 0.05524505 -0.02883232]]]

 [[[-0.03594766  0.05524505]
   [ 0.05524505 -0.02883232]]

  [[ 0.05524505 -0.02883232]
   [-0.02883232  0.06511764]]]]
veff2.vhf_c (ncas=2): [[0.00374441 0.00157538]
 [0.00157538 0.00132654]]
feff2.vhf_c (ncas=2): [[0.05546656 0.02278294]
 [0.02278294 0.01373895]]
veff2.energy_core (ncas=2): 0.12722510880593546
feff2.energy_core (ncas=2): 2.1499647355000224
energy_core (ncas=2): 2.277189844305958
MOs: 6, ncore: 0, ncas: 3, nelecas: (2, 2)
[0.5        5.09490952]
[0.5        1.50077433]
[0.5       1.0446315]
[0.5        0.83870983]
[0.5        0.73245941]
[0.5        0.64315851]
[0.5        0.58123824]
[0.5        0.54346756]
[0.5       0.5223499]
[0.5        0.51135307]
[0.5        0.50575672]
[0.5        0.50290776]
[0.5        0.50146325]
[0.5        0.50073403]
[0.5        0.50036766]
.
----------------------------------------------------------------------
Ran 1 test in 1.542s

OK

For the ncas=3 calculation, I did math on the ppaa array in the test script to generate the values that compare to the ncas=2 vhf_c and energy_core attributes. It worked perfectly for vhf_c, but not for energy_core. Note that it's not exactly a factor of 1/2 off, so even if I'm getting a convention wrong, there's still something else awry.

ETA: fixed the conv_tab printout

MatthewRHermes commented 1 year ago

~Wait, no, when did the LiH (4e,3o) stop converging??????? argggghhh!~

ETA: fixed

matthew-hennefarth commented 1 year ago

I think it could be the fact that energy_core is added each time we accumulate.

Take a look at these line

  self.vhf_c += mo_coeff.conjugate().T @ ot.get_eff_1body(ao,
                                                       weight, vrho_c, non0tab=non0tab, shls_slice=shls_slice,
                                                       ao_loc=ao_loc,
                                                       hermi=1) @ mo_coeff
  self.energy_core = np.trace(self.vhf_c[:ncore, :ncore]) / 2

Each time we call the _accumulate we append to self.vhf_c which is fine. But then self.energy_core gets updated again. I think this is fine when self.paaa_only=False, but if self.paaa_only=True then the self.vhf_c also adds in vhf_a.

if self.paaa_only:
    # 1/2 v_aiuv D_ii D_uv = v^ai_uv D_uv -> F_ai, F_ia
    # needs to be in here since it would otherwise be calculated using
    # ppaa and papa. This is harmless to the CI problem because the
    # elements in the active space and core-core sector are ignored
    # below.
    eff_rho_a = _contract_eff_rho(eff_Pi, rho_a)
    vhf_a = get_eff_1body(ot, ao, weight, eff_rho_a, non0tab=non0tab,
                          shls_slice=shls_slice, ao_loc=ao_loc, hermi=1)
    vhf_a = mo_coeff.conjugate().T @ vhf_a @ mo_coeff
    vhf_a[ncore:nocc, :] = vhf_a[:, ncore:nocc] = 0.0
    self.vhf_c += vhf_a

Then the next time we accumulate, we are not just accumulating over vhf_c, but also vhf_c + vhf_a? My logic here could be a bit off, but it might be something in the details of that. Perhaps it is better to just calculate energy_core differently?

matthew-hennefarth commented 1 year ago

I Got it!! Edit: And i lost it trying to merge a git. fuck.

Edit 2: Got it again. I think. Going to commit this and link for you to take a look.

matthew-hennefarth commented 1 year ago

Okay, here is the necessary commit info and lines. I fixed the self.energy_core with the factor of 1/2 as you did.

Additionally, I think I might have been missing the mo_coeff kwarg in the _dms.casdm1s_to_dm1s() function which needs to have the new MO coeffs for the core orbitals.

MatthewRHermes commented 1 year ago

LGTM!