Create an Orbital Optimizer

Goal

Build and configure an orbital optimizer using the fluent builder API. Choose between Newton and simple optimizers. Optionally, set estimators, minimizers, gradient calculators, number of shots for sampling, and basin-hopping for global optimization.

Prerequisites

  • A basic understanding of orbital optimization (rotating molecular orbitals to lower the system’s energy)

Overview

Start the builder with:

import qrunch as qc

opt = qc.orbital_optimizer_creator()

Then narrow to a specific algorithm and call .create():

optimizer = (
    qc.orbital_optimizer_creator()
    .newton()  # or .simple()
    # .<configure>(...)
    .create()
)

Available Optimizers

A Newton-style optimizer that uses a gradient calculator. Supports finite-difference or local (RDM-based) gradients.

import qrunch as qc

optimizer = (
    qc.orbital_optimizer_creator()
    .newton()
    .choose_gradient_calculator()
      .local_gradient(                  # analytical (RDM-based) gradients
          estimator=qc.estimator_creator().excitation_gate().create()
      )
    .with_shots(None)                   # None = exact estimator for gradients
    .with_options(                      # optional Newton options
        options=qc.options.NewtonMinimizerOptions(...)
    )
    .create()
)

The Newton-style optimizer with a local gradient calculator is a strong default choice for orbital optimization. It leverages analytical gradients via reduced density matrices (RDMs), leading to efficient and accurate updates. Options can be set using a NewtonMinimizerOptions instance.

A lightweight optimizer that uses an estimator and a classical minimizer.

import qrunch as qc

optimizer = (
    qc.orbital_optimizer_creator()
    .simple()
    .with_estimator(                    # optional: set custom estimator
        estimator=qc.estimator_creator().excitation_gate().create()
    )
    .choose_minimizer()                 # optional: choose custom minimizer
      .scipy(
         name="L-BFGS-B",
         options=qc.options.ScipyMinimizerOptions(...)
      )
    .with_shots(None)                   # None = exact estimator; or an int for finite shots
    .create()
)

See Choose a Minimizer for a guide to choosing a minimizer.

Basin-Hopping (Global Optimization)

The orbital optimization energy landscape can contain local minima. The Newton optimizer supports wrapping the local Newton-CG minimization in a basin-hopping global optimizer from SciPy, which repeatedly perturbs the solution and re-minimizes to escape local minima.

Enable basin-hopping with OrbitalOptimizerBasinHoppingOptions:

import qrunch as qc

basin_hopping_options = qc.options.OrbitalOptimizerBasinHoppingOptions(
    active=True,                        # enable basin-hopping
    number_of_macro_iterations=5,       # basin-hopping steps (default=3)
    temperature=0.01,                   # accept probability for worse solutions
    stepsize=0.05,                      # random displacement size
    number_of_successive_failures=4,    # stop after this many failures
    seed=42,                            # RNG seed for reproducibility
)

optimizer = (
    qc.orbital_optimizer_creator()
    .newton()
    .choose_gradient_calculator()
      .local_gradient(
          estimator=qc.estimator_creator().excitation_gate().create()
      )
    .with_shots(None)
    .with_basin_hopping_options(basin_hopping_options)
    .create()
)

Basin-hopping is inactive by default (active=False). When to enable it:

  • Intermittent optimizer (used at every iteration during an adaptive VQE): Basin-hopping is usually not needed because the intermittent optimizer keeps rotations small and close to the global minimum as gates are added.

  • Final optimizer (used once after the adaptive loop completes): especially when the intermittent optimizer was not used (setting every_nth_iteration larger than the maximum number of iterations and force_full_after_n_non_full_steps to 0).

Intermittent vs. Final Orbital Optimization

When using orbital optimization inside an adaptive VQE (OO-BEAST-VQE or OO-FAST-VQE), two orbital optimizers can be configured independently:

  1. Intermittent orbital optimizer — runs during the adaptive loop, controlled by IntermittentOrbitalOptimizerAlgorithmOptions. You can use loose convergence criteria here, since the gate parameters will shift on the next iteration anyway.

  2. Final orbital optimizer — runs once after the adaptive loop finishes. Use tighter convergence criteria and potentially enable basin-hopping to ensure the global minimum is found.

import qrunch as qc

# Intermittent: fast, loose convergence, no basin-hopping
intermittent_optimizer = (
    qc.orbital_optimizer_creator()
    .newton()
    .choose_gradient_calculator()
      .local_gradient(
          estimator=qc.estimator_creator().excitation_gate().create()
      )
    .with_shots(None)
    .with_options(qc.options.NewtonMinimizerOptions(
        relative_error_tolerance=1e-3,
    ))
    .create()
)

# Final: tight convergence with basin-hopping
final_optimizer = (
    qc.orbital_optimizer_creator()
    .newton()
    .choose_gradient_calculator()
      .local_gradient(
          estimator=qc.estimator_creator().excitation_gate().create()
      )
    .with_shots(None)
    .with_options(qc.options.NewtonMinimizerOptions(
        relative_error_tolerance=1e-6,
    ))
    .with_basin_hopping_options(qc.options.OrbitalOptimizerBasinHoppingOptions(
        active=True,
    ))
    .create()
)

Controlling When Intermittent Optimization Runs

Use IntermittentOrbitalOptimizerAlgorithmOptions to control when and how the intermittent optimizer is invoked:

import qrunch as qc

oo_options = qc.options.IntermittentOrbitalOptimizerAlgorithmOptions(
    every_nth_iteration=1,                    # consider OO every iteration (default)
    gradient_threshold=1.0e99,                # above this: full OO; below: single Newton step
    skip_gradient_threshold=1.0e-16,          # below this: skip OO entirely
    orbital_change_threshold=1.0e99,          # above this orbital change: force full OO
    force_full_after_n_non_full_steps=10,     # force full OO after 10 consecutive non-full steps
    gate_addition_threshold=1.0e-3,           # if gate addition changes energy above this: full OO
)
  • every_nth_iteration: how often to consider an OO step (default 1).

  • gradient_threshold: when the orbital gradient norm is above this value, a full converged orbital optimization is performed. When the gradient is below this value but above skip_gradient_threshold, only a single Newton step is taken instead — this is roughly as cheap as computing the hessian, but it still updates the orbitals so they track the landscape as new gates are added (default 1.0e99, meaning single step is always used).

  • skip_gradient_threshold: when the orbital gradient norm is below this value, the orbital optimization step is skipped entirely (default 1.0e-16). Must be ≤ gradient_threshold.

  • orbital_change_threshold: if the absolute orbital change from the previous OO step exceeds this value, a full optimization is performed regardless of the gradient norm (default 1.0e99).

  • force_full_after_n_non_full_steps: force a full optimization after this many consecutive non-full (single-step or skip) OO decisions (default 10). Set to 0 to disable.

  • gate_addition_threshold: if the absolute energy change due to addition of a gate exceeds this value, a full optimization is performed (default 1.0e-3).

Pass these options via .with_intermittent_orbital_optimizer_options(oo_options) on the VQE calculator builder.

Preset-Based Intermittent Options

For convenience, the VQE calculator builders offer preset-based selection via choose_intermittent_orbital_optimizer_options(). Each preset configures the IntermittentOrbitalOptimizerAlgorithmOptions in a single call while leaving the orbital optimizer itself unchanged.

import qrunch as qc

calculator = (
    qc.calculator_creator()
    .vqe()
    .iterative_with_orbital_optimization()
    .beast()
    .choose_intermittent_orbital_optimizer_options()
    .quick()  # cheapest: single step every iteration
    .create()
)

Available presets:

  • quick() — Always takes a single Newton step. Never runs a full optimization; the cheapest strategy. force_full_after_n_non_full_steps is 0 (disabled), orbital_change_threshold and gate_addition_threshold are set very high so they never trigger a full OO step.

  • balanced() — Takes a single Newton step every iteration but runs the full optimizer after 10 consecutive non-full steps. A full optimization is also triggered when a gate addition causes an energy change above gate_addition_threshold (default 1e-3).

  • accurate() — Runs the full optimizer at (nearly) every iteration. gradient_threshold is set very low (1e-16) so that almost any gradient triggers a full optimization, with force_full_after_n_non_full_steps set to 1 as a safety net. Also triggers full OO when the orbital change exceeds 1e-3.

These presets only control when and how often the intermittent optimizer runs. To change which optimizer is used, combine the preset with with_orbital_optimizer(...) or with_orbital_optimizer_estimator(...).

The presets can also be used directly as class methods on IntermittentOrbitalOptimizerAlgorithmOptions:

import qrunch as qc

oo_options = qc.options.IntermittentOrbitalOptimizerAlgorithmOptions.quick()
# or .balanced(), .accurate()

calculator = (
    qc.calculator_creator()
    .vqe()
    .iterative_with_orbital_optimization()
    .beast()
    .with_intermittent_orbital_optimizer_options(oo_options)
    .create()
)
calculator = (
    qc.calculator_creator()
    .vqe()
    .iterative_with_orbital_optimization()
    .beast()
    .choose_intermittent_orbital_optimizer_options()
    .balanced()
    .with_orbital_optimizer_estimator(my_custom_estimator)
    .create()
)

Next Step