How this package relates to ffsim
The concepts in this guide are only available in the Python API.
ffsim and this package divide the work rather than overlap, along a single line:
ffsim owns | qiskit-fermions owns |
|---|---|
The high-level ansatz operators (UCJOpSpinBalanced, UCCSDOpRestrictedReal and their siblings), built from coupled-cluster amplitudes or a parameter vector | The fermionic circuit (FermionicCircuit) those operators become, and its transpilation into a qubit circuit under any fermion-to-qubit encoding |
| Fast simulation in a compact fixed-particle-number space | The mapper framework and the operator data structures the circuit is built from |
The practical consequence runs both ways. The ansatz math lives in ffsim, so UCJ and UCC take an ffsim operator directly as their input. Conversely, ffsim’s own Qiskit gates are specific to the Jordan-Wigner transformation, so lowering one of its ansatz operators through a different encoding is what bringing it here buys you.
Simulation is where the two meet, and this guide explains how. ffsim is this package’s simulation backend, reached through its own protocols rather than a wrapper: the SKQD guide calls ffsim.apply_unitary(), ffsim.linear_operator(), and ffsim.sample_state_vector() directly on FermionicCircuitobjects and FermionOperatorobjects, with no conversion step.
Plugging into ffsim’s simulation interface
ffsim is a high-performance simulator for fermionic quantum circuits that exploits particle number and spin-Z conservation to represent state vectors more compactly than a generic -dimensional qubit statevector. It defines protocols that any object can implement to participate in its simulation machinery (see qiskit_fermions.protocols for this package’s own protocols, which follow the same design):
ffsim.SupportsApplyUnitary, by the method_apply_unitary_(vec, norb, nelec, copy), applying the object as a unitary to a fixed-particle-number state vectorffsim.SupportsLinearOperator, by the method_linear_operator_(norb, nelec), returning ascipy.sparse.linalg.LinearOperatorview of the object on that same sectorffsim.SupportsTrace, by the method_trace_(norb, nelec), returning the object’s trace on that sector.
This package implements that interface: every fermionic gate in qiskit_fermions.circuit.library provides _apply_unitary_, and FermionOperator provides _linear_operator_ and _trace_. That is what makes this package’s operators and circuits compatible with ffsim, so its tools work on them natively, with no conversion step. ffsim.apply_unitary() can simulate a FermionicCircuit end to end, ffsim.linear_operator() can diagonalize a FermionOperator, and sampling utilities like ffsim.sample_state_vector() (used in the SKQD guide to turn a simulated statevector into measurement counts) work without modification.
What _linear_operator_ returns is an ordinary SciPy LinearOperator, so scipy.sparse.linalg.eigsh() and friends work on it too.
>>> import ffsim
>>> import numpy as np
>>>
>>> from qiskit_fermions.circuit import FermionicCircuit
>>> from qiskit_fermions.circuit.library import Evolution
>>> from qiskit_fermions.operators import FermionOperator, ann, cre
>>>
>>> norb, nelec = 2, (1, 1)
>>> hamiltonian = FermionOperator.from_terms([
... ([cre(0), ann(1)], 0.5),
... ([cre(1), ann(0)], 0.5),
... ])
>>>
>>> circuit = FermionicCircuit(2 * norb)
>>> circuit.append(Evolution(2 * norb, hamiltonian, time=1.0), circuit.modes)
>>>
>>> reference = ffsim.hartree_fock_state(norb, nelec)
>>> state = ffsim.apply_unitary(reference, circuit, norb=norb, nelec=nelec) # native ffsim callCoupling through ffsim’s protocols is what buys this: its actively developed ecosystem of simulation and sampling utilities applies to this package’s circuits and operators unchanged, and users already working with ffsim can mix in this package’s gates and operators without learning another simulation API.
ffsim is an optional dependency
ffsim is declared as an optional extra (pip install "qiskit-fermions[ffsim]", or transitively by [all]), guarded at runtime by HAS_FFSIM (as was shown above). Installing it unlocks simulation: ffsim.SupportsLinearOperator and ffsim.SupportsTrace are implemented by converting this package’s FermionOperator into an ffsim.FermionOperator, so without ffsim they raise MissingOptionalLibraryError. Everything that does not simulate (building operators, mapping them, and transpiling the resulting circuits) needs none of it.
ffsim transitively depends on PySCF, which does not support Windows, so on Windows the extra resolves to nothing (a silent no-op through a sys_platform marker) rather than an install failure. Windows users who want to simulate can do so through the Windows Subsystem for Linux in the meantime.
Fermionic simulation lives in a fixed particle-number sector
Both _apply_unitary_ and _linear_operator_ take an nelec argument and represent the state vector over the fixed-particle-number determinant basis for that (norb, nelec) sector, mirroring ffsim’s (and, transitively, PySCF’s) FCI space setup, rather than the full -dimensional space a general qubit simulator would use. This is a much smaller space (its size is a product of binomial coefficients rather than a power of two), but it comes with a hard restriction. Only operators and gates that preserve particle number (and, in the spinful case, each spin species’ particle number individually) can be represented in it. An operator whose action would move amplitude to a different particle number has nowhere to put it.
ffsim resolves this by rejecting such an operator outright: converting one to a linear operator raises a ValueError naming which conservation law fails. That is the right default, because applying for a Hamiltonian with a non-particle-conserving term would otherwise turn a would-be unitary into a non-unitary, physically-meaningless map.
Evolutionsurfaces that rejection from the operator it evolves.OrbitalRotationchecks that its (embedded) rotation matrix is block-diagonal across the alpha/beta split in the spinful case, and raises aValueErrorif it mixes spin sectors.
>>> from qiskit_fermions.circuit.library import Evolution
>>>
>>> non_conserving = FermionOperator.from_terms([([cre(0), cre(1)], 1.0)]) # creates 2 particles
>>> gate = Evolution(2 * norb, non_conserving, time=1.0)
>>>
>>> try:
... gate._apply_unitary_(reference, norb, nelec, copy=True)
... except ValueError as exc:
... print("rejected:", exc)
rejected: The given FermionOperator could not be converted to a LinearOperator because it does not conserve particle number and the z component of spin. Conserves particle number: False Conserves spin z: FalseNon-particle-preserving simulation is therefore out of reach in fermionic space, by construction of the fixed-sector representation, and that is the price of the compact FCI space which makes fermionic simulation tractable in the first place. If your algorithm needs particle-number-violating operators (for example, a qubit-native error channel, or an operator built for a mapped Hamiltonian that does not conserve particle numbers term by term), transpile to qubits first and simulate the resulting QuantumCircuit with a qubit-level simulator instead. Any transpilation route works for this; the fermionic circuit and transpilation guides go into more detail, but generate_preset_jw_pass_manager() is a reasonable default choice.
The block-spin convention for spinful systems
nelec is typed int | tuple[int, int], and its type, not a separate flag, is what selects one of the supported mode layouts:
- An int selects the spinless interpretation. The
norbmodes are treated directly as spinless orbitals, andnelecis the total particle count. - A pair
(n_alpha, n_beta)selects the spinful interpretation of2 * norbmodes under a fixed block-spin convention. Modes0 .. norbare the alpha (spin-up) orbitals and modesnorb .. 2 * norbare the beta (spin-down) orbitals. Each spin species is conserved independently; an alpha-only term can move an electron between alpha modes but never into a beta mode, and vice versa.
This dispatch happens at every ffsim-protocol entry point in this package (gate constructors, _apply_unitary_placed_ implementations, and the native Rust kernel’s sector compilation), and it is the convention used throughout the LUCJ and SKQD guides (for example, InitializeModes.from_hartree_fock() fills alpha occupations into modes 0 .. n_alpha and beta occupations into modes norb .. norb + n_beta). It is worth contrasting this with the qiskit_fermions.operators module and FermionicCircuit in general, which use generic mode indices with no inherent spin semantics (see the fermionic circuit guide). The block-spin meaning is imposed only when a spinful nelec is supplied to a simulation call, not baked into the operator or circuit representation itself.
>>> from qiskit_fermions.circuit.library import InitializeModes
>>>
>>> norb, nelec = 3, (2, 1)
>>> init = InitializeModes.from_hartree_fock(norb, nelec)
>>> print([bool(occ) for occ in init.occupation]) # alpha modes 0,1; beta mode 3 (= norb + 0)
[True, True, False, True, False, False]Next steps
-
Walk through the SKQD guide to see
ffsim.apply_unitary()andffsim.sample_state_vector()used together to sample circuits built from this package’s gates, and to evaluate a Hamiltonian expectation value throughffsim.linear_operator()(itself backed by the same_linear_operator_protocol method described here). -
Walk through the LUCJ guide for the other half of the story: taking an ffsim ansatz operator through this package’s transpilation pipeline and onto a device coupling map.
-
Read the fermionic circuit guide for the generic, spin-agnostic mode indexing used outside of simulation calls.
-
Read the transpilation guide for how to leave fermionic space
and simulate on qubits, which is required for non-particle-conserving operators.
-
Browse
qiskit_fermions.protocolsfor the full catalog of protocols this package defines, including the conversion protocols (SupportsFermionOperator,SupportsMajoranaOperator) that are unrelated to ffsim. -
Read ffsim’s own guides for the workflows it owns, in particular building a UCJ ansatz and optimizing one variationally. The parameter vectors those guides drive are what
UCJandUCCaccept, through the operators they build.