Skip to content

Conversation

@hongzhouye
Copy link

@hongzhouye hongzhouye commented Feb 25, 2025

This PR adds the molecular and periodic LNO-CCSD/CCSD(T). The LNO method was originally introduced by Kállay and co-workers:

  • J. Chem. Phys. 139, 094105 (2013) (link)
  • J. Chem. Phys. 135, 104111 (2011) (link)

And this PR is particularly based on a recent extension:

  • J. Chem. Theory Comput. 2024, 20, 20, 8948–8959 (link)

for spin-restricted molecular & periodic LNO-CC and an unpublished work for spin-unrestricted references (@fishjojo, @MoleOrbitalHybridAnalyst).

The PR is still work in progress with the following to-do's:

  • add examples and more unit tests (me and @tberkel)
  • make the C library pyscf-forge/pyscf/lib/lno compatible with regular pyscf (@fishjojo)
  • remove redundant functions in lno/lnoccsd.py for precomputation (perhaps need to slightly refactor pyscf's CCSD code) (@fishjojo )
  • add support for spin-unrestricted references (@fishjojo, @MoleOrbitalHybridAnalyst)

@tberkel
Copy link

tberkel commented Apr 25, 2025

I'm working on adding examples, but frozen seems broken with unrestricted LNO. @fishjojo or @MoleOrbitalHybridAnalyst have you tested frozen orbitals? Skimming the code suggests that you did, but I get a correlation energy of 0 when I freeze orbitals. Example is below. Works fine when frozen = 0.

from pyscf import gto, scf, mp, lo
from pyscf.data import elements
from pyscf import lno

mol = gto.Mole()
mol.atom = '''
    O   -1.485163346097   -0.114724564047    0.000000000000
    H   -1.868415346097    0.762298435953    0.000000000000
    H   -0.533833346097    0.040507435953    0.000000000000
    O    1.416468653903    0.111264435953    0.000000000000
    H    1.746241653903   -0.373945564047   -0.758561000000
    H    1.746241653903   -0.373945564047    0.758561000000
'''
mol.basis = 'ccpvdz'
mol.verbose = 7
mol.spin = 0
mol.build()

mf = scf.UHF(mol).density_fit()
mf.kernel()

frozen = elements.chemcore(mol)
# frozen = 0

mymp = mp.MP2(mf, frozen=frozen)
mymp.kernel(with_t2=False)

thresh = [1e-4] * 2

nocca, noccb = mymp.get_nocc()
moidxa, moidxb = mymp.get_frozen_mask()
mo_coeff = mf.mo_coeff[0][:, moidxa], mf.mo_coeff[1][:, moidxb]
orbocca = mo_coeff[0][:, :nocca]
lo_coeff_a = lo.PipekMezey(mf.mol, orbocca).kernel()
orboccb = mo_coeff[1][:, :noccb]
lo_coeff_b = lo.PipekMezey(mf.mol, orboccb).kernel()
lo_coeff = [lo_coeff_a, lo_coeff_b]

frag_lolist_a = [[[i], []] for i in range(nocca)]
frag_lolist_b = [[[], [i]] for i in range(noccb)]
frag_lolist = frag_lolist_a + frag_lolist_b

mlcc = lno.ULNOCCSD(mf, lo_coeff, frag_lolist, frozen=frozen,
                    lno_thresh=thresh)
mlcc.kernel()
# E(LNOMP2) = -152.062490646908  E_corr = 0
# E(LNOCCSD) = -152.062490646908  E_corr = 0

@tberkel
Copy link

tberkel commented May 6, 2025

I fixed the ULNO frozen orbitals problem (help from @hczhai) and cleaned up the unittest for ULNO.

@tberkel
Copy link

tberkel commented May 6, 2025

Another question for @fishjojo and/or @MoleOrbitalHybridAnalyst - I notice that the results for ULNO and (R)LNO are different for the same thresholds, even when the reference doesn't break spin symmetry. Did you observe the same? I guess the issue is that the orbital-specific DMs treat up and down differently (e.g., commonly a different number of up and down orbitals are included in the active space). I wonder if there's a way to spin average, so that the results agree in the limit of no spin symmetry breaking.

@fishjojo
Copy link
Collaborator

fishjojo commented May 6, 2025 via email

@hongzhouye
Copy link
Author

@fishjojo @tberkel Could it be related to this line? I had updated the local MP2 DM definition at some point to better align with the canonical MP2 DM definition, which is twice the value of what the earlier version produced. I’m okay with defaulting to either convention.

@tberkel
Copy link

tberkel commented Nov 17, 2025

I think this is ready to merge and meets the bar for forge. We have some more small updates, but they'll be easier to add in future PRs once this is merged.

orbocc = mf.mo_coeff[:,frozen:np.count_nonzero(mf.mo_occ)]
mlo = lo.PipekMezey(mol, orbocc)
lo_coeff = mlo.kernel()
while True: # always performing jacobi sweep to avoid trapping in local minimum/saddle point
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you implement this as a for loop with a maxiter? It should behave for now but this is scary to me.

Comment on lines +79 to +80
lo_coeff1 = mlo.stability_jacobi()[1]
if lo_coeff1 is lo_coeff:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The discarded first return argument literally should evaluate to the same value as the conditional. It's more readable and "sane" if you just use that, since who knows how stability_jacobi may be refactored in the future?

Comment on lines +78 to +80
while True: # always performing jacobi sweep to avoid trapping in local minimum/saddle point
lo_coeff1_s = mlo.stability_jacobi()[1]
if lo_coeff1_s is lo_coeff_s:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same comments as in test_lnoccsd.py

ex = ex.real
ess = ed*0.5 + ex
eos = ed*0.5
return lib.tag_array(ess+eos, spin_comp=np.array((ess, eos)))
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Aren't we trying to move away from lib.tag_array?

Comment on lines +422 to +449
# def mo_splitter(self, kind='mask'):
# r''' Return index arrays that split MOs into
# - frozen occupieds
# - active occupieds
# - active virtuals
# - frozen virtuals
#
# Args:
# kind (str):
# 'mask' : return masks each of length nmo
# 'index' : return index arrays
# 'idx' : same as 'index'
# '''
# maskact = self.get_frozen_mask()
# maskocc = self.mo_occ > 1e-10
# return mo_splitter(maskact, maskocc, kind=kind)
#
# def split_mo_coeff(self):
# r''' Return the four components of MOs specified in :func:`mo_splitter`
# '''
# mo = self.mo_coeff
# masks = self.mo_splitter()
# return [mo[:,m] for m in masks]
#
# def split_mo_energy(self):
# moe = self.mo_energy
# masks = self.mo_splitter()
# return [moe[m] for m in masks]
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cleanup on aisle 3

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's probably a good idea to have unittest coverage for a file like this, in which many functions which are different variations on a single theme are implemented. That way, the next time numpy or scipy changes something and breaks all of these functions simultaneously, you immediately get a checklist of all the places you need to update whatever it is.

del cls.cell, cls.kmf, cls.frozen
del cls.scell, cls.smf

# def test_lno_pm_by_thresh(self):
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use @unittest.skip ('reason') if you intend to enable this test in the future; or delete it if not.

Comment on lines +209 to +225
# def contract1(LbraR, LbraI, LketR, LketI):
# out_shape = (LbraR.shape[1], LketR.shape[1])
# out = np.zeros(out_shape, dtype=REAL)
# for q1,q2 in enumerate(k2sdf.qconserv):
# i0,i1 = k2sdf.get_auxslice(q1)
# j0,j1 = k2sdf.get_auxslice(q2)
# zdotNNtoR(LbraR[i0:i1].T, LbraI[i0:i1].T, LketR[j0:j1], LketI[j0:j1], 1, out, 1)
# return out
#
# eris.oooo[:] = contract1(LooR, LooI, LooR, LooI).reshape(nocc,nocc,nocc,nocc)
# eris.ovoo[:] = contract1(LovR, LovI, LooR, LooI).reshape(nocc,nvir,nocc,nocc)
# eris.oovv[:] = lib.unpack_tril(
# contract1(LooR, LooI, LvvR, LvvI)).reshape(nocc,nocc,nvir,nvir)
# eris.ovvo[:] = contract1(LovR, LovI, LvoR, LvoI).reshape(nocc,nvir,nvir,nocc)
# eris.ovov[:] = contract1(LovR, LovI, LovR, LovI).reshape(nocc,nvir,nocc,nvir)
# eris.ovvv[:] = contract1(LovR, LovI, LvvR, LvvI).reshape(nocc,nvir,nvir_pair)
# eris.vvvv[:] = contract1(LvvR, LvvI, LvvR, LvvI).reshape(nvir_pair,nvir_pair)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cleanup?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same comment as for the OBC version

Copy link
Collaborator

@MatthewRHermes MatthewRHermes left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we get a quick and dirty example input script or two?


@property
def e_corr_pt2_scs(self):
e_corr = 0.333*self.e_corr_pt2_ss + 1.2*self.e_corr_pt2_os
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants