Skip to content

Commit 1ec5d4f

Browse files
committed
mcscf: add optional GASSCF pseudo-natural orbital and CI rotation
1 parent 201d0cb commit 1ec5d4f

4 files changed

Lines changed: 404 additions & 15 deletions

File tree

‎pyscf/mcscf/fci_gas.py‎

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -934,6 +934,96 @@ def contract_ss(self, fcivec, norb, nelec):
934934
with self.make_space(norb, nelec, compress_links=False) as gas:
935935
return _GasSpinPlan(gas).contract(fcivec)
936936

937+
def transform_ci_within_gas(self, fcivec, norb, nelec, u):
938+
"""Return CI coefficients for ``mo_new = mo_old @ u`` within each GAS.
939+
940+
``u`` must be real, orthogonal and block diagonal in the GAS partition.
941+
Adjacent Givens rotations act on local string axes of each legal GAS
942+
block; no full-CAS embedding or dense determinant rotation is formed.
943+
Reflections and orbital permutations are included. The input is not
944+
modified. This explicit method is separate from the unrestricted
945+
``transform_ci_for_orbital_rotation`` hook probed by native Newton.
946+
"""
947+
948+
gas_orbs, _, _ = self._space_spec(norb, nelec)
949+
u = _as_c_double(u, (int(norb), int(norb)))
950+
if not numpy.all(numpy.isfinite(u)) or not numpy.allclose(
951+
u.T @ u, numpy.eye(norb), atol=1e-10, rtol=0):
952+
raise ValueError("GAS orbital rotation must be finite and orthogonal")
953+
allowed = numpy.zeros(u.shape, dtype=bool)
954+
factors = []
955+
offset = 0
956+
for size in gas_orbs:
957+
sl = slice(offset, offset + size)
958+
allowed[sl, sl] = True
959+
work = u[sl, sl].copy()
960+
rotations = []
961+
# L_m ... L_1 U = D, so the passive CI transform applies the
962+
# exterior representations of L_1, ..., L_m, then diagonal D.
963+
# Adjacent orbital pairs have no intervening fermionic sign.
964+
for col in range(size - 1):
965+
for q in range(size - 1, col, -1):
966+
p = q - 1
967+
a, b = work[p, col], work[q, col]
968+
if b == 0:
969+
continue
970+
norm = numpy.hypot(a, b)
971+
c, s = a / norm, b / norm
972+
row_p, row_q = work[p].copy(), work[q].copy()
973+
work[p] = c * row_p + s * row_q
974+
work[q] = -s * row_p + c * row_q
975+
rotations.append((p, q, c, s))
976+
factors.append((rotations, numpy.where(work.diagonal() < 0, -1., 1.)))
977+
offset += size
978+
if numpy.any(numpy.abs(u[~allowed]) > 1e-12):
979+
raise ValueError("orbital rotation must not mix different GAS subspaces")
980+
981+
source = _as_c_double(fcivec)
982+
with self.make_space(norb, nelec, compress_links=False) as gas:
983+
if source.size != gas.ndet:
984+
raise ValueError("CI vector size does not match GAS determinant count")
985+
result = source.reshape(-1).copy()
986+
ngas = len(gas_orbs)
987+
local = {}
988+
for block in gas.block_descriptors():
989+
occupations = [int(gas._gas.sector_occ[sid * ngas + g])
990+
for sid in (block['sa'], block['sb'])
991+
for g in range(ngas)]
992+
shape = tuple(cistring.num_strings(gas_orbs[g % ngas], n)
993+
for g, n in enumerate(occupations))
994+
begin = block['offset']
995+
size = block['na'] * block['nb']
996+
tensor = result[begin:begin + size].reshape(shape)
997+
# C GAS sector strides have the last subspace varying fastest:
998+
# (alpha GAS axes..., beta GAS axes...).
999+
for axis, n in enumerate(occupations):
1000+
g = axis % ngas
1001+
key = (g, n)
1002+
if key not in local:
1003+
strings = cistring.make_strings(range(gas_orbs[g]), n)
1004+
rotations, phases = factors[g]
1005+
pairs = {}
1006+
for p, q, _, _ in rotations:
1007+
if (p, q) not in pairs:
1008+
left = numpy.flatnonzero(
1009+
((strings >> p) & 1) & ~((strings >> q) & 1))
1010+
right = numpy.searchsorted(
1011+
strings, strings[left] ^ ((1 << p) | (1 << q)))
1012+
pairs[p, q] = (left, right)
1013+
signs = numpy.ones(strings.size)
1014+
for p in numpy.flatnonzero(phases < 0):
1015+
signs *= 1 - 2 * ((strings >> p) & 1)
1016+
local[key] = pairs, signs
1017+
pairs, signs = local[key]
1018+
view = numpy.moveaxis(tensor, axis, 0)
1019+
for p, q, c, s in factors[g][0]:
1020+
left, right = pairs[p, q]
1021+
old_left, old_right = view[left].copy(), view[right].copy()
1022+
view[left] = c * old_left + s * old_right
1023+
view[right] = -s * old_left + c * old_right
1024+
view *= signs.reshape((-1,) + (1,) * (view.ndim - 1))
1025+
return result.reshape(source.shape)
1026+
9371027
def transform_ci_for_orbital_rotation(self, fcivec, norb, nelec, u):
9381028
raise NotImplementedError(
9391029
"arbitrary active-orbital rotations do not preserve a GAS space")

‎pyscf/mcscf/gasscf.py‎

Lines changed: 102 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,11 @@
2727
``gas_restr`` and ``gas_restr_type`` are normalized by the same GAS helper
2828
routines. This module supports ordinary state averaging, including
2929
zero-weight roots, GAS-safe canonicalization of inactive/external orbitals,
30+
optional within-GAS pseudo-natural orbitals with synchronized CI rotation,
3031
density fitting through PySCF's ``mcscf.df`` machinery, energy-only scanners
3132
and the GASCI-native spin-penalty Hamiltonian. It does not implement
3233
state-average-mix, state-specific excited-state wrappers,
33-
active-space natural-orbital rotations, analytic gradients/NACs or the
34+
unrestricted active-space natural-orbital rotations, analytic gradients/NACs or the
3435
legacy two-step CASSCF driver.
3536
"""
3637

@@ -756,6 +757,10 @@ class GASSCF(newton_casscf.CASSCF):
756757
returns occupations as one array per subspace. These methods leave
757758
the computational orbitals and CI vectors unchanged.
758759
760+
To rotate computational orbitals and CI together, explicitly call
761+
``mc.canonicalize_(gas_pseudo_natorb=True)``. The variant without the
762+
trailing underscore returns the transformed results without writeback.
763+
759764
Analysis orbitals can be exported with PySCF's Molden writer::
760765
761766
from pyscf.tools import molden
@@ -1255,30 +1260,112 @@ def casci(self, mo_coeff=None, ci0=None, eris=None,
12551260

12561261
def canonicalize(self, mo_coeff=None, ci=None, eris=None, sort=False,
12571262
gas_natorb=False, gasdm1=None, verbose=None,
1258-
cas_natorb=None, **kwargs):
1259-
"""Canonicalize core/external orbitals without rotating GAS subspaces.
1260-
1261-
Native CASSCF calls this method with its ``natorb`` flag as the fifth
1262-
positional argument. In GASSCF, any automatic active-space natural
1263-
orbital rotation would generally mix GAS subspaces and invalidate the
1264-
restricted determinant space, so active-space natural orbitals are
1265-
explicitly guarded. The active GAS block is otherwise kept fixed;
1266-
only inactive and external orbitals are canonicalized by the inherited
1267-
PySCF machinery.
1263+
cas_natorb=None, *, gas_pseudo_natorb=False, **kwargs):
1264+
"""Return ``(mo_coeff, ci, mo_energy)`` without writing to this object.
1265+
1266+
By default only core/external orbitals are canonicalized. Opt in with
1267+
``gas_pseudo_natorb=True`` to diagonalize the density separately within
1268+
each GAS subspace and transform every CI root into the new basis,
1269+
including zero-weight roots. SA uses the weighted density unless
1270+
``gasdm1`` is supplied in the input active-orbital basis.
1271+
1272+
Active occupations are ordered from largest to smallest within each
1273+
unfrozen symmetry block of a GAS subspace. Frozen orbitals and orbital
1274+
symmetry/``extrasym`` labels are respected. ``sort`` retains its native
1275+
meaning for core/external orbital energies. ``mo_energy`` contains
1276+
Fock diagonal elements, not pseudo-natural occupations.
1277+
1278+
These are pseudo-natural orbitals: density between GAS subspaces need
1279+
not vanish. Rotations across GAS subspaces remain unsupported, as do
1280+
``gas_natorb=True`` and ``cas_natorb=True``. Use ``canonicalize_`` to
1281+
write the returned orbitals, CI and orbital energies to this object.
12681282
"""
12691283

12701284
if gas_natorb or cas_natorb:
12711285
_unsupported("GAS natural-orbital rotation")
1272-
return gasci.GASCI.canonicalize(
1273-
self, mo_coeff, ci, eris, sort=sort, gas_natorb=False,
1286+
if not gas_pseudo_natorb:
1287+
return gasci.GASCI.canonicalize(
1288+
self, mo_coeff, ci, eris, sort=sort, gas_natorb=False,
1289+
gasdm1=gasdm1, verbose=verbose, **kwargs)
1290+
1291+
mo_coeff = self.mo_coeff if mo_coeff is None else mo_coeff
1292+
ci = self.ci if ci is None else ci
1293+
if mo_coeff is None or ci is None:
1294+
raise ValueError("pseudo-natural canonicalization requires orbitals and CI")
1295+
if gasdm1 is None:
1296+
gasdm1 = self.make_gasdm1(
1297+
ci=ci, state=None if self._has_state_weights() else 0)
1298+
gasdm1 = numpy.asarray(gasdm1)
1299+
if (gasdm1.shape != (self.ncas, self.ncas)
1300+
or numpy.iscomplexobj(gasdm1)
1301+
or not numpy.all(numpy.isfinite(gasdm1))):
1302+
raise ValueError("gasdm1 must be a finite real (ncas, ncas) matrix")
1303+
1304+
nmo = mo_coeff.shape[1]
1305+
frozen = numpy.zeros(nmo, dtype=bool)
1306+
if isinstance(self.frozen, (int, numpy.integer)):
1307+
frozen[:self.frozen] = True
1308+
elif self.frozen is not None:
1309+
frozen[self.frozen] = True
1310+
orbsym = getattr(mo_coeff, 'orbsym', numpy.zeros(nmo, dtype=int))
1311+
extrasym = getattr(self, 'extrasym', None)
1312+
if extrasym is None:
1313+
extrasym = numpy.zeros(nmo, dtype=int)
1314+
rotation = numpy.eye(self.ncas)
1315+
offset = 0
1316+
for size in self.gas_orbs:
1317+
groups = {}
1318+
for p in range(offset, offset + size):
1319+
i = self.ncore + p
1320+
if not frozen[i]:
1321+
groups.setdefault((orbsym[i], extrasym[i]), []).append(p)
1322+
for indices in groups.values():
1323+
block = numpy.ix_(indices, indices)
1324+
_, vectors = self._natural_eigensystem(gasdm1[block], sort=True)
1325+
rotation[block] = vectors
1326+
offset += size
1327+
1328+
# Transform CI explicitly, without exposing a general rotation hook to
1329+
# Newton. In particular, never solve GASCI again in the rotated basis.
1330+
def transform(vector):
1331+
return self.fcisolver.transform_ci_within_gas(
1332+
vector, self.ncas, self.nelecas, rotation)
1333+
1334+
if isinstance(ci, (list, tuple)):
1335+
ci_new = type(ci)(transform(vector) for vector in ci)
1336+
elif numpy.ndim(ci) == 2 and self.fcisolver.nroots > 1:
1337+
ci_new = numpy.asarray([transform(vector) for vector in ci])
1338+
else:
1339+
ci_new = transform(ci)
1340+
# The native routine may reorder orbital labels when sort=True;
1341+
# own the metadata as well as the MO array to keep this call nonmutating.
1342+
mo_input = mo_coeff.copy()
1343+
if getattr(mo_coeff, 'orbsym', None) is not None:
1344+
mo_input = lib.tag_array(mo_input, orbsym=numpy.array(orbsym, copy=True))
1345+
# Both calls use the original basis, so a supplied ERIS remains valid.
1346+
fock = self.get_fock(mo_coeff, ci, eris, gasdm1, verbose)
1347+
mo_new, _, mo_energy = gasci.GASCI.canonicalize(
1348+
self, mo_input, ci, eris, sort=sort, gas_natorb=False,
12741349
gasdm1=gasdm1, verbose=verbose, **kwargs)
1350+
active = slice(self.ncore, self.ncore + self.ncas)
1351+
mo_new[:, active] = mo_coeff[:, active] @ rotation
1352+
mo_energy[active] = numpy.einsum(
1353+
'pi,pi->i', mo_new[:, active], fock @ mo_new[:, active])
1354+
return mo_new, ci_new, mo_energy
12751355

12761356
def canonicalize_(self, mo_coeff=None, ci=None, eris=None, sort=False,
12771357
gas_natorb=False, gasdm1=None, verbose=None,
1278-
cas_natorb=None, **kwargs):
1358+
cas_natorb=None, *, gas_pseudo_natorb=False, **kwargs):
1359+
"""Write the canonicalized MO, CI and orbital energies to this object.
1360+
1361+
Returns the same three values as :meth:`canonicalize`; the method
1362+
without the trailing underscore leaves these result attributes intact.
1363+
"""
1364+
12791365
mo_coeff, ci, mo_energy = self.canonicalize(
12801366
mo_coeff, ci, eris, sort=sort, gas_natorb=gas_natorb,
1281-
gasdm1=gasdm1, verbose=verbose, cas_natorb=cas_natorb, **kwargs)
1367+
gasdm1=gasdm1, verbose=verbose, cas_natorb=cas_natorb,
1368+
gas_pseudo_natorb=gas_pseudo_natorb, **kwargs)
12821369
self.mo_coeff = mo_coeff
12831370
self.ci = ci
12841371
self.mo_energy = mo_energy

‎pyscf/mcscf/test/test_gasci.py‎

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,72 @@ def test_gas_fci_vector_converters(self):
275275
numpy.testing.assert_array_equal(projected_twice, projected)
276276
self.assertEqual(numpy.count_nonzero(projected), gas.ndet)
277277

278+
def test_ci_rotation_within_gas_matches_full_fci(self):
279+
rng = numpy.random.default_rng(211)
280+
norb, nelec = 5, (3, 2)
281+
gas_orbs = (3, 2)
282+
bounds = ((2, 4), (5, 5))
283+
_, blocks = addons_gas.normalize_gas_spec(
284+
gas_orbs, nelec, bounds, 'cumulative-occ')
285+
supergroups = numpy.unique(blocks[:, :2] + blocks[:, 2:], axis=0)
286+
cases = [
287+
(gas_orbs, nelec, bounds, 'cumulative-occ'),
288+
(gas_orbs, nelec, supergroups, 'supergroup'),
289+
(gas_orbs, nelec, blocks[::2], 'spin-supergroup'),
290+
((5,), nelec, ((3, 2),), 'spin-supergroup'),
291+
(gas_orbs, (0, 0), ((0, 0, 0, 0),), 'spin-supergroup'),
292+
(gas_orbs, (5, 5), ((3, 2, 3, 2),), 'spin-supergroup'),
293+
]
294+
for sizes, electrons, restriction, kind in cases:
295+
solver = fci_gas.FCISolver(
296+
gas_orbs=sizes, gas_restr=restriction, gas_restr_type=kind)
297+
u = numpy.zeros((norb, norb))
298+
offset = 0
299+
for size in sizes:
300+
q = numpy.linalg.qr(rng.normal(size=(size, size)))[0]
301+
# Explicitly include determinant -1, not just proper rotations.
302+
if numpy.linalg.det(q) > 0:
303+
q[:, 0] *= -1
304+
u[offset:offset+size, offset:offset+size] = q
305+
offset += size
306+
permutation = numpy.eye(norb)
307+
permutation[:, [0, 1]] = permutation[:, [1, 0]]
308+
with solver.make_space(norb, electrons) as gas:
309+
ci = rng.normal(size=gas.ndet)
310+
ci /= numpy.linalg.norm(ci)
311+
before = ci.copy()
312+
full = fci_gas.gas2fci(ci, gas)
313+
for rotation in (u, permutation):
314+
with self.subTest(kind=kind, nelec=electrons, sizes=sizes):
315+
expected = fci_addons.transform_ci_for_orbital_rotation(
316+
full, norb, electrons, rotation)
317+
with mock.patch.object(fci_gas, 'gas2fci',
318+
side_effect=AssertionError('no CAS embedding')):
319+
actual = solver.transform_ci_within_gas(
320+
ci, norb, electrons, rotation)
321+
numpy.testing.assert_allclose(
322+
fci_gas.gas2fci(actual, gas), expected, atol=2e-12, rtol=0)
323+
recovered = solver.transform_ci_within_gas(
324+
actual, norb, electrons, rotation.T)
325+
numpy.testing.assert_allclose(recovered, ci, atol=2e-12, rtol=0)
326+
self.assertAlmostEqual(numpy.linalg.norm(actual), 1., places=12)
327+
numpy.testing.assert_array_equal(ci, before)
328+
329+
def test_ci_rotation_within_gas_rejects_invalid_transforms(self):
330+
solver = fci_gas.FCISolver(gas_orbs=(2, 2),
331+
gas_restr=((1, 1, 1, 1),))
332+
ci = numpy.ones(16) / 4
333+
with self.assertRaisesRegex(ValueError, 'orthogonal'):
334+
solver.transform_ci_within_gas(ci, 4, (2, 2), numpy.eye(4) * 2)
335+
mixed = numpy.eye(4)
336+
mixed[:, [1, 2]] = mixed[:, [2, 1]]
337+
with self.assertRaisesRegex(ValueError, 'mix different GAS'):
338+
solver.transform_ci_within_gas(ci, 4, (2, 2), mixed)
339+
with self.assertRaisesRegex(TypeError, 'real-valued'):
340+
solver.transform_ci_within_gas(ci, 4, (2, 2), numpy.eye(4, dtype=complex))
341+
with self.assertRaisesRegex(ValueError, 'CI vector size'):
342+
solver.transform_ci_within_gas(ci[:-1], 4, (2, 2), numpy.eye(4))
343+
278344
def test_restricted_hamiltonian_projection(self):
279345
cases = (
280346
{

0 commit comments

Comments
 (0)