Skip to content

Commit c2cc191

Browse files
committed
fix(mcscf): include spin penalty in GASSCF Newton CI derivatives
1 parent d24ae9b commit c2cc191

3 files changed

Lines changed: 304 additions & 30 deletions

File tree

‎pyscf/mcscf/fci_gas.py‎

Lines changed: 32 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,32 @@ def _spin_penalty_parameters(solver, norb, nelec):
236236
return shift, target, minimum, tuple(float(x) for x in eigenvalues)
237237

238238

239+
def _spin_penalty_action(contract_ss, vector, parameters):
240+
"""Apply the GAS spin penalty, without adding the physical Hamiltonian.
241+
242+
Shared by the fixed-orbital solver and the Newton CI derivatives. The
243+
supplied contraction owns/reuses its spin plan; the input is not modified.
244+
"""
245+
shift, target, minimum, _ = parameters
246+
delta = numpy.asarray(contract_ss(vector)) - target * vector
247+
if target >= minimum + 0.1:
248+
delta = numpy.asarray(contract_ss(delta)) - target * delta
249+
return shift * delta
250+
251+
252+
def _spin_penalty_diagonal(spin_diagonal, parameters):
253+
"""Return the shared Davidson/Newton penalty preconditioner diagonal.
254+
255+
For the quadratic penalty this is (diag(S^2)-target)^2, an inexpensive
256+
approximation, not diag((S^2-target)^2). Operator actions remain exact.
257+
"""
258+
shift, target, minimum, _ = parameters
259+
delta = spin_diagonal - target
260+
if target >= minimum + 0.1:
261+
delta = delta * delta
262+
return shift * delta
263+
264+
239265
class _GasSpinPlan:
240266
"""Reusable block-sparse ``S^2`` contraction plan.
241267
@@ -1173,11 +1199,8 @@ def option(name, default):
11731199
if full_spin_pspace:
11741200
s2 = spin_plan.matrix()
11751201
s2 = s2[numpy.ix_(addresses, addresses)]
1176-
delta_s2 = s2 - spin_target * numpy.eye(gas.ndet)
1177-
if linear_spin_penalty:
1178-
h0 += spin_shift * delta_s2
1179-
else:
1180-
h0 += spin_shift * numpy.dot(delta_s2, delta_s2)
1202+
h0 += _spin_penalty_action(
1203+
s2.dot, numpy.eye(gas.ndet), spin_penalty)
11811204
self.spin_penalty_method = "exact-small-space"
11821205
eigenvalues, eigenvectors = numpy.linalg.eigh(h0)
11831206
e = eigenvalues[:nroots]
@@ -1263,33 +1286,14 @@ def hop(vec):
12631286
h2e, vec, norb, nelec, plan=plan).reshape(-1)
12641287
if spin_penalty is None:
12651288
return result
1266-
ss_vector = numpy.asarray(
1267-
spin_plan.contract(vec)).reshape(-1)
1268-
if linear_spin_penalty:
1269-
ss_vector -= spin_target * vec
1270-
result += spin_shift * ss_vector
1271-
return result
1272-
tmp = ss_vector - spin_target * vec
1273-
correction = numpy.asarray(
1274-
spin_plan.contract(tmp)).reshape(-1)
1275-
correction -= spin_target * tmp
1276-
result += spin_shift * correction
1289+
result += _spin_penalty_action(
1290+
spin_plan.contract, vec, spin_penalty)
12771291
return result
12781292

12791293
preconditioner_diagonal = hdiag
12801294
if spin_penalty is not None:
1281-
spin_diagonal = spin_plan.diagonal_vector()
1282-
delta_diagonal = spin_diagonal - spin_target
1283-
if linear_spin_penalty:
1284-
penalty_diagonal = spin_shift * delta_diagonal
1285-
else:
1286-
# This is the inexpensive diagonal approximation to
1287-
# (S^2-target)^2. The projected trial vectors
1288-
# improve access to the target sector; this term
1289-
# only improves Davidson conditioning.
1290-
penalty_diagonal = (
1291-
spin_shift * delta_diagonal * delta_diagonal)
1292-
preconditioner_diagonal = hdiag + penalty_diagonal
1295+
preconditioner_diagonal = hdiag + _spin_penalty_diagonal(
1296+
spin_plan.diagonal_vector(), spin_penalty)
12931297

12941298
def precond(dx, e, *args):
12951299
denom = preconditioner_diagonal - e

‎pyscf/mcscf/gasscf.py‎

Lines changed: 97 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
import hashlib
4040
import json
4141
import sys
42+
from types import FunctionType
4243

4344
import numpy
4445

@@ -154,12 +155,106 @@ def _gasscf_newton_kernel(casscf, *args, **kwargs):
154155
kwargs["verbose"] = verbose
155156
# Native Newton consumes objectives throughout its complete trajectory.
156157
# Convert only the returned tuple before mc1step assigns/logs public slots.
157-
result = newton_casscf.kernel(casscf, *args, **kwargs)
158+
native_kernel = newton_casscf.kernel
159+
if hasattr(casscf.fcisolver, 'ss_penalty'):
160+
# PySCF kernel -> update_orb_ci -> gen_g_hop uses module globals,
161+
# not casscf.gen_g_hop. Reuse the original function code/defaults in
162+
# a private namespace, changing only the response entry point. Never
163+
# patch the imported module: other CASSCF calculations keep their
164+
# native operators, including during callbacks and nested calls.
165+
namespace = dict(vars(newton_casscf))
166+
namespace['gen_g_hop'] = gen_g_hop
167+
for name in ('update_orb_ci', 'kernel'):
168+
original = getattr(newton_casscf, name)
169+
rebound = FunctionType(original.__code__, namespace, original.__name__,
170+
original.__defaults__, original.__closure__)
171+
rebound.__kwdefaults__ = original.__kwdefaults__
172+
namespace[name] = rebound
173+
native_kernel = namespace['kernel']
174+
result = native_kernel(casscf, *args, **kwargs)
158175
physical, gas_physical = gasci._publish_energy_results(
159176
casscf, result[1], result[2])
160177
return (result[0], physical, gas_physical) + result[3:]
161178

162179

180+
def gen_g_hop(mc, mo, ci0, eris, verbose=None):
181+
"""Add spin-penalty CI derivatives to the native Newton operators.
182+
183+
The spin penalty P has no orbital dependence. Keep contract_2e physical:
184+
native Newton uses that hook for both H and orbital variations of H.
185+
Only the CI gradient, CI-CI Hessian and CI preconditioner need corrections.
186+
For a normalized root c, p = <c|P|c> and r = Pc - pc, these are
187+
2 w r and 2 w [(P-p) v - r(c.v) - c(r.v)], respectively, in the native
188+
Newton normalization convention. The keyframe gradient uses the same P.
189+
"""
190+
gradient, update, hop, hdiag = newton_casscf.gen_g_hop(
191+
mc, mo, ci0, eris, verbose)
192+
solver = mc.fcisolver
193+
parameters = fci_gas._spin_penalty_parameters(
194+
solver, mc.ncas, mc._effective_nelecas())
195+
if parameters is None or parameters[0] == 0:
196+
return gradient, update, hop, hdiag
197+
198+
# The spin plan owns only Python/NumPy data and remains valid after the
199+
# solver drops its cache. No borrowed C pointers escape in these closures.
200+
if solver.cache_plans:
201+
plan = solver._get_spin_plan(mc.ncas, mc.nelecas)
202+
else:
203+
plan = solver.make_spin_plan(mc.ncas, mc.nelecas)
204+
205+
def penalty(vector):
206+
return fci_gas._spin_penalty_action(plan.contract, vector, parameters)
207+
208+
roots = [ci0] if solver.nroots == 1 else ci0
209+
roots = [numpy.asarray(c).ravel() for c in roots]
210+
weights = tuple(getattr(mc, 'weights', (1.,)))
211+
ngorb = gradient.size - sum(c.size for c in roots)
212+
diagonal = fci_gas._spin_penalty_diagonal(
213+
plan.diagonal_vector(), parameters)
214+
gradient = gradient.copy()
215+
hdiag = hdiag.copy()
216+
terms = []
217+
start = ngorb
218+
for c, weight in zip(roots, weights):
219+
block = slice(start, start + c.size)
220+
start += c.size
221+
if weight == 0:
222+
continue
223+
pc = penalty(c)
224+
energy = c.dot(pc)
225+
residual = pc - energy * c
226+
gradient[block] += 2 * weight * residual
227+
hdiag[block] += 2 * weight * (diagonal - energy - 2 * residual * c)
228+
terms.append((block, c, weight, energy, residual))
229+
230+
def penalized_hop(vector):
231+
result = hop(vector)
232+
for block, c, weight, energy, residual in terms:
233+
v = vector[block]
234+
result[block] += 2 * weight * (
235+
penalty(v) - energy * v - residual * c.dot(v)
236+
- c * residual.dot(v))
237+
return result
238+
239+
def penalized_update(u, ci):
240+
result = update(u, ci)
241+
current = [ci] if solver.nroots == 1 else ci
242+
start = ngorb
243+
for c, weight in zip(current, weights):
244+
c = numpy.asarray(c).ravel()
245+
block = slice(start, start + c.size)
246+
start += c.size
247+
if weight == 0:
248+
continue
249+
# Native g_update normalizes each current CI vector as well.
250+
c = c / numpy.linalg.norm(c)
251+
pc = penalty(c)
252+
result[block] += 2 * weight * (pc - c.dot(pc) * c)
253+
return result
254+
255+
return gradient, penalized_update, penalized_hop, hdiag
256+
257+
163258
class _GASFCISolver(fci_gas.FCISolver):
164259
"""GASCI solver shell with GASSCF-owned helper plans.
165260
@@ -1511,7 +1606,7 @@ def state_specific_(self, *args, **kwargs):
15111606

15121607
state_specific = state_specific_
15131608

1514-
gen_g_hop = newton_casscf.gen_g_hop
1609+
gen_g_hop = gen_g_hop
15151610

15161611
def kernel(self, mo_coeff=None, ci0=None, callback=None):
15171612
"""Run full GASSCF orbital optimization with native CIAH.

‎pyscf/mcscf/test/test_gasscf.py‎

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
from pyscf.tools import molden
3737
from pyscf.fci import addons as fci_addons
3838
from pyscf.fci import direct_spin1
39+
from pyscf.fci import spin_op
3940
from pyscf.mcscf import addons
4041
from pyscf.mcscf import addons_gas
4142
from pyscf.mcscf import df as mcdf
@@ -2920,6 +2921,180 @@ def test_fix_spin_rejects_spin_incomplete_gas_and_invalid_target(self):
29202921
with self.assertRaisesRegex(ValueError, "target S"):
29212922
complete.fix_spin_(shift=.2, ss=.5)
29222923

2924+
def _check_spin_penalty_newton_derivatives(self, use_df=False, weights=None):
2925+
mol = gto.M(atom='H 0 0 0; H 0 0 .8; H 0 0 1.8; H 0 0 2.6',
2926+
basis='sto-3g', verbose=0)
2927+
mf = scf.RHF(mol).run()
2928+
mc = gasscf.GASSCF(
2929+
mf, 3, (1, 1), ncore=1, gas_orbs=(1, 2),
2930+
gas_restr=((0, 1), (2, 2)), gas_restr_type='cumulative-occ')
2931+
self.addCleanup(mc.close)
2932+
if use_df:
2933+
mc = mc.density_fit()
2934+
self.addCleanup(mc.close)
2935+
if weights is not None:
2936+
mc = mc.state_average(weights)
2937+
self.addCleanup(mc.close)
2938+
mc.validate_capabilities()
2939+
mo = mf.mo_coeff
2940+
eris = mc.ao2mo(mo)
2941+
h1, core = mc.get_h1gas(mo)
2942+
h2 = mc.get_h2gas(mo)
2943+
absorbed = direct_spin1.absorb_h1e(h1, h2, mc.ncas, mc.nelecas, .5)
2944+
# Independent full-FCI operators projected into a genuine restricted
2945+
# GAS space. Deliberately mix spin sectors: pure-spin eigenvectors can
2946+
# hide a missing penalty in the CI gradient and curvature.
2947+
with mc.fcisolver.make_space(mc.ncas, mc.nelecas) as space:
2948+
eye = numpy.eye(space.ndet)
2949+
def project_action(action, column):
2950+
full = fci_gas.gas2fci(column, space)
2951+
return fci_gas.fci2gas(action(full), space).ravel()
2952+
hmat = numpy.column_stack([
2953+
project_action(lambda c: direct_spin1.contract_2e(
2954+
absorbed, c, mc.ncas, mc.nelecas), c) for c in eye])
2955+
s2 = numpy.column_stack([
2956+
project_action(lambda c: spin_op.contract_ss(
2957+
c, mc.ncas, mc.nelecas), c) for c in eye])
2958+
nroots = mc.fcisolver.nroots
2959+
rng = numpy.random.default_rng(211)
2960+
columns = numpy.linalg.qr(rng.normal(size=(eye.shape[0], nroots)))[0]
2961+
roots = [columns[:, i].copy() for i in range(nroots)]
2962+
directions = []
2963+
for c in roots:
2964+
v = rng.normal(size=c.size)
2965+
v -= c.dot(v) * c
2966+
directions.append(v / numpy.linalg.norm(v))
2967+
ci = roots[0] if weights is None else roots
2968+
w = (1.,) if weights is None else weights
2969+
native = newton_casscf.gen_g_hop(mc, mo, ci, eris)
2970+
ngorb = native[0].size - sum(c.size for c in roots)
2971+
ci_direction = numpy.r_[numpy.zeros(ngorb), numpy.concatenate(directions)]
2972+
orbital_direction = numpy.r_[rng.normal(size=ngorb),
2973+
numpy.zeros(native[0].size-ngorb)]
2974+
u = scipy.linalg.expm(.02 * mc.unpack_uniq_var(orbital_direction[:ngorb]))
2975+
2976+
for target in (0., 2.): # Linear minimum-spin and quadratic penalties.
2977+
mc.fix_spin_(shift=.2, ss=target)
2978+
delta = s2 - target * eye
2979+
penalty = .2 * (delta if target == 0 else delta @ delta)
2980+
operator = hmat + penalty
2981+
def energy(angle):
2982+
vectors = [numpy.cos(angle)*c + numpy.sin(angle)*v
2983+
for c, v in zip(roots, directions)]
2984+
return core + sum(a * c.dot(operator @ c)
2985+
for a, c in zip(w, vectors))
2986+
previous = None
2987+
for cache in (True, False):
2988+
mc.cache_plans = cache
2989+
with self.subTest(df=use_df, weights=weights,
2990+
target=target, cache=cache):
2991+
g, update, hop, diagonal = mc.gen_g_hop(mo, ci, eris)
2992+
# Public contraction remains physical, even with fix_spin.
2993+
numpy.testing.assert_allclose(
2994+
mc.fcisolver.contract_2e(absorbed, roots[0],
2995+
mc.ncas, mc.nelecas),
2996+
hmat @ roots[0], atol=1e-12, rtol=0)
2997+
first = numpy.dot(g, ci_direction)
2998+
second = numpy.dot(ci_direction, hop(ci_direction))
2999+
self.assertGreater(abs(first-native[0].dot(ci_direction)), 1e-3)
3000+
for step in (2e-4, 1e-4):
3001+
plus, minus, center = energy(step), energy(-step), energy(0)
3002+
self.assertAlmostEqual(first, (plus-minus)/(2*step), delta=2e-7)
3003+
self.assertAlmostEqual(second, (plus-2*center+minus)/step**2,
3004+
delta=2e-6)
3005+
# Compare the full tangent CI response with an independent
3006+
# projected full-FCI matrix, not just one quadratic form.
3007+
response = hop(ci_direction)[ngorb:]
3008+
start = 0
3009+
for a, c, v in zip(w, roots, directions):
3010+
block = response[start:start+c.size]
3011+
expected = 2*a*(operator @ v - c.dot(operator @ c)*v)
3012+
numpy.testing.assert_allclose(
3013+
block-c*c.dot(block), expected-c*c.dot(expected),
3014+
atol=1e-11, rtol=0)
3015+
start += c.size
3016+
# P is orbital independent: no extra orbital or mixed block.
3017+
numpy.testing.assert_allclose(g[:ngorb], native[0][:ngorb], atol=0, rtol=0)
3018+
numpy.testing.assert_allclose(hop(orbital_direction),
3019+
native[2](orbital_direction), atol=1e-12)
3020+
numpy.testing.assert_allclose(hop(ci_direction)[:ngorb],
3021+
native[2](ci_direction)[:ngorb], atol=1e-12)
3022+
# Check the keyframe update at changed, unnormalized CI and
3023+
# rotated orbitals. Only the normalized CI penalty changes.
3024+
current = [(1.2+i)*(c+.13*v)
3025+
for i, (c, v) in enumerate(zip(roots, directions))]
3026+
current_arg = current[0] if weights is None else current
3027+
actual = update(u, current_arg)
3028+
expected = native[1](u, current_arg)
3029+
start = ngorb
3030+
for a, c in zip(w, current):
3031+
c = c / numpy.linalg.norm(c)
3032+
expected[start:start+c.size] += 2*a*(penalty @ c-c.dot(penalty @ c)*c)
3033+
start += c.size
3034+
numpy.testing.assert_allclose(actual, expected, atol=1e-11)
3035+
# Quadratic diagonal is a documented preconditioner
3036+
# approximation, shared with Davidson; the HVP is exact.
3037+
pd = .2 * (s2.diagonal()-target)
3038+
if target != 0:
3039+
pd = .2 * (s2.diagonal()-target)**2
3040+
expected = native[3].copy()
3041+
start = ngorb
3042+
for a, c in zip(w, roots):
3043+
ep = c.dot(penalty @ c)
3044+
residual = penalty @ c-ep*c
3045+
expected[start:start+c.size] += 2*a*(pd-ep-2*residual*c)
3046+
start += c.size
3047+
numpy.testing.assert_allclose(diagonal, expected, atol=1e-12)
3048+
if weights is not None and weights[-1] == 0:
3049+
numpy.testing.assert_allclose(g[-roots[-1].size:], 0, atol=0)
3050+
numpy.testing.assert_allclose(hop(ci_direction)[-roots[-1].size:], 0, atol=0)
3051+
values = (g, hop(ci_direction+orbital_direction), diagonal)
3052+
if previous is not None:
3053+
for a, b in zip(values, previous):
3054+
numpy.testing.assert_allclose(a, b, atol=1e-12)
3055+
previous = values
3056+
mc.undo_fix_spin_()
3057+
3058+
def test_spin_penalty_newton_derivatives(self):
3059+
self._check_spin_penalty_newton_derivatives()
3060+
3061+
def test_spin_penalty_sa_newton_derivatives(self):
3062+
for weights in ((.3, .7), (1., 0.)):
3063+
self._check_spin_penalty_newton_derivatives(weights=weights)
3064+
3065+
def test_spin_penalty_df_newton_derivatives(self):
3066+
for weights in (None, (.3, .7), (1., 0.)):
3067+
self._check_spin_penalty_newton_derivatives(use_df=True, weights=weights)
3068+
3069+
def test_spin_penalty_kernel_uses_local_newton_response(self):
3070+
mol = gto.M(atom='H 0 0 0; H 0 0 .8; H 0 0 1.8; H 0 0 2.6',
3071+
basis='sto-3g', verbose=0)
3072+
mf = scf.RHF(mol).run()
3073+
original = tuple(getattr(newton_casscf, name) for name in
3074+
('kernel', 'update_orb_ci', 'gen_g_hop'))
3075+
for weights in (None, (.3, .7), (1., 0.)):
3076+
mc = gasscf.GASSCF(mf, 3, (1, 1), ncore=1, gas_orbs=(1, 2),
3077+
gas_restr=((0, 1), (2, 2)),
3078+
gas_restr_type='cumulative-occ')
3079+
if weights is not None:
3080+
mc = mc.state_average(weights)
3081+
self.addCleanup(mc.close)
3082+
mc.fix_spin_(shift=.2, ss=0.)
3083+
mc.max_cycle_macro = mc.max_cycle_micro = 1
3084+
seen = []
3085+
def callback(env):
3086+
# Native module functions stay unchanged even while the
3087+
# calculation is running, not merely restored on return.
3088+
for name, function in zip(('kernel', 'update_orb_ci', 'gen_g_hop'), original):
3089+
self.assertIs(getattr(newton_casscf, name), function)
3090+
self.assertAlmostEqual(env['e_tot'], mc.spin_energy_report()['objective'], 10)
3091+
seen.append(True)
3092+
with mock.patch.object(gasscf, 'gen_g_hop', wraps=gasscf.gen_g_hop) as response:
3093+
result = mc.kernel(callback=callback)
3094+
self.assertGreater(response.call_count, 0)
3095+
self.assertTrue(seen)
3096+
self.assertAlmostEqual(result[0], mc.spin_energy_report()['physical'], 10)
3097+
29233098
def _check_physical_energy_result(self, mc, result):
29243099
report = mc.spin_energy_report()
29253100
roots = mc.ci if isinstance(mc.ci, (list, tuple)) else [mc.ci]

0 commit comments

Comments
 (0)