# Copyright 2022 The NetKet Authors - All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from typing import Union, Optional
import numpy as np
import numba
from numbers import Number
from netket.utils.types import DType
from netket.operator._discrete_operator import DiscreteOperator
from netket.operator._pauli_strings.base import _count_of_locations
from netket.hilbert.abstract_hilbert import AbstractHilbert
from netket.utils.numbers import is_scalar, dtype as _dtype
from netket.utils.optional_deps import import_optional_dependency
from netket.errors import concrete_or_error, NumbaOperatorGetConnDuringTracingError
from netket.experimental.hilbert import SpinOrbitalFermions
from ._fermion_operator_2nd_utils import (
_convert_terms_to_spin_blocks,
_collect_constants,
_canonicalize_input,
_check_hermitian,
_herm_conj,
_is_diag_term,
_make_tuple_tree,
_remove_dict_zeros,
_verify_input,
_reduce_operators,
OperatorDict,
OperatorTermsList,
OperatorWeightsList,
_normal_ordering,
_pair_ordering,
)
class FermionOperator2nd(DiscreteOperator):
r"""
A fermionic operator in :math:`2^{nd}` quantization.
"""
[docs] def __init__(
self,
hilbert: AbstractHilbert,
terms: Union[list[str], list[list[list[int]]]] = None,
weights: Optional[list[Union[float, complex]]] = None,
constant: Union[float, complex] = 0.0,
dtype: DType = None,
):
r"""
Constructs a fermion operator given the single terms (set of
creation/annihilation operators) in second quantization formalism.
This class can be initialized in the following form:
`FermionOperator2nd(hilbert, terms, weights ...)`.
The term contains pairs of `(idx, dagger)`, where `idx ∈ range(hilbert.size)`
(it identifies an orbital) and `dagger` is a True/False flag determining if the
operator is a creation or destruction operator.
A term of the form :math:`\hat{a}_1^\dagger \hat{a}_2` would take the form
`((1,1), (2,0))`, where (1,1) represents :math:`\hat{a}_1^\dagger` and (2,0)
represents :math:`\hat{a}_2`.
To split up per spin, use the creation and annihilation operators to build the
operator.
Args:
hilbert: hilbert of the resulting FermionOperator2nd object
terms: single term operators (see
example below)
weights: corresponding coefficients of the single term operators
(defaults to a list of 1)
constant: constant contribution, corresponding to the
identity operator * constant (default = 0)
Returns:
A FermionOperator2nd object.
Example:
Constructs the fermionic hamiltonian in :math:`2^{nd}` quantization
:math:`(0.5-0.5j)*(a_0^\dagger a_1) + (0.5+0.5j)*(a_2^\dagger a_1)`.
>>> import netket.experimental as nkx
>>> terms, weights = (((0,1),(1,0)),((2,1),(1,0))), (0.5-0.5j,0.5+0.5j)
>>> hi = nkx.hilbert.SpinOrbitalFermions(3)
>>> op = nkx.operator.FermionOperator2nd(hi, terms, weights)
>>> op
FermionOperator2nd(hilbert=SpinOrbitalFermions(n_orbitals=3), n_operators=2, dtype=complex128)
>>> terms = ("0^ 1", "2^ 1")
>>> op = nkx.operator.FermionOperator2nd(hi, terms, weights)
>>> op
FermionOperator2nd(hilbert=SpinOrbitalFermions(n_orbitals=3), n_operators=2, dtype=complex128)
>>> op.hilbert
SpinOrbitalFermions(n_orbitals=3)
>>> op.hilbert.size
3
"""
super().__init__(hilbert)
# bring terms, weights into consistent form, autopromote dtypes if necessary
_operators, _constant, dtype = _canonicalize_input(
terms, weights, constant, dtype
)
_verify_input(hilbert, _operators, raise_error=True)
self._dtype = dtype
# we keep the input, in order to be able to add terms later
self._operators = _operators
self._constant = _constant
self._initialized = False
self._is_hermitian = None # set when requested
self._max_conn_size = None
def _reset_caches(self):
"""
Cleans the internal caches built on the operator.
"""
self._initialized = False
self._is_hermitian = None
self._max_conn_size = None
def _setup(self, force: bool = False):
"""Analyze the operator strings and precompute arrays for get_conn inference"""
if force or not self._initialized:
# remove zeros
self._operators = _reduce_operators(self._operators, self.dtype)
# following lists will be used to compute matrix elements
# they are filled in _add_term
out = _pack_internals(self._operators, self._dtype)
(
self._orb_idxs,
self._daggers,
self._weights,
self._diag_idxs,
self._off_diag_idxs,
self._term_split_idxs,
_collected_constant,
) = out
self._constant += _collected_constant
self._max_conn_size = 0
if not _isclose(self._constant, 0) or len(self._diag_idxs) > 0:
self._max_conn_size += 1
# the following could be reduced further
self._max_conn_size += len(self._off_diag_idxs)
self._initialized = True
[docs] @staticmethod
def from_openfermion(
hilbert: AbstractHilbert,
of_fermion_operator=None, # : "openfermion.ops.FermionOperator" type
*,
n_orbitals: Optional[int] = None,
convert_spin_blocks: bool = False,
) -> "FermionOperator2nd":
r"""
Converts an openfermion FermionOperator into a netket FermionOperator2nd.
The hilbert first argument can be dropped, see __init__ for details and default
value.
Warning: convention of openfermion.hamiltonians is different from ours: instead
of strong spin components as subsequent hilbert state outputs (i.e. the 1/2 spin
components of spin-orbit i are stored in locations (2*i, 2*i+1)), we concatenate
blocks of definite spin (i.e. locations (i, n_orbitals+i)).
Args:
hilbert: (optional) hilbert of the resulting FermionOperator2nd object
of_fermion_operator (openfermion.ops.FermionOperator):
`FermionOperator object <https://quantumai.google/reference/python/openfermion/ops/FermionOperator>`_ .
More information about those objects can be found in
`OpenFermion's documentation <https://quantumai.google/reference/python/openfermion>`_
n_orbitals: (optional) total number of orbitals in the system, default
None means inferring it from the FermionOperator2nd. Argument is
ignored when hilbert is given.
convert_spin_blocks: whether or not we need to convert the FermionOperator
to our convention. Only works if hilbert is provided and if it has
spin != 0
"""
openfermion = import_optional_dependency(
"openfermion", descr="from_openfermion"
)
FermionOperator = openfermion.ops.FermionOperator
if hilbert is None:
raise ValueError(
"The first argument `from_openfermion` must either be an "
"openfermion operator or an Hilbert space, followed by "
"an openfermion operator"
)
if not isinstance(hilbert, AbstractHilbert):
# if first argument is not Hilbert, then shift all arguments by one
hilbert, of_fermion_operator = None, hilbert
if not isinstance(of_fermion_operator, FermionOperator):
raise NotImplementedError()
if convert_spin_blocks and hilbert is None:
raise ValueError("if convert_spin_blocks, the hilbert must be specified")
terms = list(of_fermion_operator.terms.keys())
weights = list(of_fermion_operator.terms.values())
terms, weights, constant = _collect_constants(terms, weights)
if hilbert is not None:
# no warning, just overwrite
n_orbitals = hilbert.n_orbitals
if convert_spin_blocks:
if not hasattr(hilbert, "spin") or hilbert.spin is None:
raise ValueError(
f"cannot convert spin blocks for hilbert space {hilbert} without spin"
)
n_spin = hilbert._n_spin_states
terms = _convert_terms_to_spin_blocks(terms, n_orbitals, n_spin)
if n_orbitals is None:
# we always start counting from 0, so we only determine the maximum location
n_orbitals = _count_of_locations(of_fermion_operator)
if hilbert is None:
hilbert = SpinOrbitalFermions(n_orbitals) # no spin splitup assumed
return FermionOperator2nd(hilbert, terms, weights=weights, constant=constant)
def __repr__(self):
return (
f"FermionOperator2nd(hilbert={self.hilbert}, "
f"n_operators={len(self._operators)}, dtype={self.dtype})"
)
[docs] def reduce(self, order: bool = True, inplace: bool = True):
"""Prunes the operator by removing all terms with zero weights, grouping, and normal ordering (inplace)."""
operators = self._operators
terms, weights = list(operators.keys()), list(operators.values())
if order:
terms, weights = _normal_ordering(terms, weights)
terms, weights, constant = _collect_constants(terms, weights)
operators = dict(zip(terms, weights))
self._operators = _reduce_operators(operators, self.dtype)
self._constant = self._constant + constant
@property
def dtype(self) -> DType:
"""The dtype of the operator's matrix elements ⟨σ|Ô|σ'⟩."""
return self._dtype
@property
def terms(self) -> OperatorTermsList:
"""Returns the list of all terms in the tuple notation."""
return list(self._operators.keys())
@property
def weights(self) -> OperatorWeightsList:
"""Returns the list of the weights correspoding to the operator terms."""
return list(self._operators.values())
@property
def constant(self) -> Number:
"""Returns the operator constant term."""
return self._constant
@property
def operators(self) -> OperatorDict:
"""Returns a dictionary with (term, weight) key-value pairs, with terms in tuple notation. Does not include the constant."""
return self._operators
[docs] def copy(self, *, dtype: Optional[DType] = None):
if dtype is None:
dtype = self.dtype
if not np.can_cast(self.dtype, dtype, casting="same_kind"):
raise ValueError(f"Cannot cast {self.dtype} to {dtype}")
op = FermionOperator2nd(self.hilbert, constant=self._constant, dtype=dtype)
if dtype == self.dtype:
operators_new = self._operators.copy()
else:
operators_new = {
k: np.array(v, dtype=dtype) for k, v in self._operators.items()
}
op._operators = operators_new
return op
def _remove_zeros(self):
"""Reduce the number of operators by removing unnecessary zeros"""
op = FermionOperator2nd(self.hilbert, constant=self._constant, dtype=self.dtype)
op._operators = _remove_dict_zeros(self._operators)
return op
@property
def max_conn_size(self) -> int:
"""The maximum number of non zero ⟨x|O|x'⟩ for every x."""
if self._max_conn_size is None:
self._setup()
return self._max_conn_size
@property
def is_hermitian(self) -> bool:
"""Returns true if this operator is hermitian."""
if self._is_hermitian is None: # only compute when needed, is expensive
terms = list(self._operators.keys())
weights = list(self._operators.values())
self._is_hermitian = _check_hermitian(terms, weights)
return self._is_hermitian
[docs] def operator_string(self) -> str:
"""Return a readable string describing all the operator terms"""
op_string = []
if not _isclose(self._constant, 0.0):
op_string.append(f"{self._constant} []")
for term, weight in self._operators.items():
s = []
for idx, dag in term:
dag_string = "^" if bool(dag) else ""
s.append(f"{int(idx)}{dag_string}")
s = " ".join(s)
s = f"{weight} [{s}]"
op_string.append(s)
return " +\n".join(op_string)
def _get_conn_flattened_closure(self):
self._setup()
_max_conn_size = self.max_conn_size
_orb_idxs = self._orb_idxs
_daggers = self._daggers
_weights = self._weights
_diag_idxs = self._diag_idxs
_off_diag_idxs = self._off_diag_idxs
_term_split_idxs = self._term_split_idxs
_constant = self._constant
fun = self._flattened_kernel
def gccf_fun(x, sections):
return fun(
x,
sections,
_max_conn_size,
_orb_idxs,
_daggers,
_weights,
_diag_idxs,
_off_diag_idxs,
_term_split_idxs,
_constant,
)
return numba.jit(nopython=True)(gccf_fun)
[docs] def get_conn_flattened(self, x, sections, pad=False):
r"""Finds the connected elements of the Operator.
Starting from a given quantum number x, it finds all other quantum numbers x' such
that the matrix element :math:`O(x,x')` is different from zero. In general there
will be several different connected states x' satisfying this
condition, and they are denoted here :math:`x'(k)`, for :math:`k=0,1...N_{\mathrm{connected}}`.
This is a batched version, where x is a matrix of shape (batch_size,hilbert.size).
Args:
x: A matrix of shape (batch_size,hilbert.size) containing
the batch of quantum numbers x.
sections: An array of size (batch_size) useful to unflatten
the output of this function.
See numpy.split for the meaning of sections.
Returns:
matrix: The connected states x', flattened together in a single matrix.
array: An array containing the matrix elements :math:`O(x,x')` associated to each x'.
"""
self._setup()
x = concrete_or_error(
np.asarray,
x,
NumbaOperatorGetConnDuringTracingError,
self,
)
assert (
x.shape[-1] == self.hilbert.size
), "size of hilbert space does not match size of x"
return self._flattened_kernel(
x,
sections,
self.max_conn_size,
self._orb_idxs,
self._daggers,
self._weights,
self._diag_idxs,
self._off_diag_idxs,
self._term_split_idxs,
self._constant,
pad,
)
@staticmethod
@numba.jit(nopython=True)
def _flattened_kernel(
x,
sections,
max_conn,
orb_idxs,
daggers,
weights,
diag_idxs,
off_diag_idxs,
term_split_idxs,
constant,
pad=False,
):
x_prime = np.empty((x.shape[0] * max_conn, x.shape[1]), dtype=x.dtype)
mels = np.zeros((x.shape[0] * max_conn), dtype=weights.dtype)
# do not split at the last one (gives empty array)
term_split_idxs = term_split_idxs[:-1]
orb_idxs_list = np.split(orb_idxs, term_split_idxs)
daggers_list = np.split(daggers, term_split_idxs)
has_constant = not _isclose(constant, 0.0)
# loop over the batch dimension
n_c = 0
for b in range(x.shape[0]):
xb = x[b, :]
# we can already fill up with default values
if pad:
x_prime[b * max_conn : (b + 1) * max_conn, :] = np.copy(xb)
non_zero_diag = False
if has_constant:
non_zero_diag = True
x_prime[n_c, :] = np.copy(xb)
mels[n_c] += constant
# first do the diagonal terms, they all generate just 1 term
for term_idx in diag_idxs:
mel = weights[term_idx]
xt = np.copy(xb)
has_xp = True
for orb_idx, dagger in zip(
orb_idxs_list[term_idx], daggers_list[term_idx]
):
_, mel, op_has_xp = _apply_operator(xt, orb_idx, dagger, mel)
if not op_has_xp:
has_xp = False
continue
if has_xp:
x_prime[n_c, :] = np.copy(xb) # should be untouched
mels[n_c] += mel
non_zero_diag = non_zero_diag or has_xp
# end of the diagonal terms
if non_zero_diag:
n_c += 1
# now do the off-diagonal terms
for term_idx in off_diag_idxs:
mel = weights[term_idx]
xt = np.copy(xb)
has_xp = True
for orb_idx, dagger in zip(
orb_idxs_list[term_idx], daggers_list[term_idx]
):
xt, mel, op_has_xp = _apply_operator(xt, orb_idx, dagger, mel)
if not op_has_xp: # detect zeros
has_xp = False
continue
if has_xp:
x_prime[n_c, :] = np.copy(xt) # should be different
mels[n_c] += mel
n_c += 1
# end of this sample
if pad:
n_c = (b + 1) * max_conn
sections[b] = n_c
if pad:
return x_prime, mels
else:
return x_prime[:n_c], mels[:n_c]
def _op__imatmul__(self, other):
if not isinstance(other, FermionOperator2nd):
return NotImplemented
if not self.hilbert == other.hilbert:
raise ValueError(
"Can only multiply identical hilbert spaces (got A@B, "
f"A={self.hilbert}, B={other.hilbert})"
)
if not np.can_cast(_dtype(other), self.dtype, casting="same_kind"):
raise ValueError(
f"Cannot multiply inplace operator with dtype {type(other)} "
f"to operator with dtype {self.dtype}"
)
new_operators = {}
for t, w in self._operators.items():
for to, wo in other._operators.items():
# if the last operator of t and the first of to are
# equal, we have a ...ĉᵢĉᵢ... which is null.
if t[-1] != to[0]:
new_t = t + to
new_operators[new_t] = new_operators.get(new_t, 0) + w * wo
if not np.isclose(other._constant, 0.0):
for t, w in self._operators.items():
new_operators[t] = w * other._constant
if not np.isclose(self._constant, 0.0):
for t, w in other._operators.items():
new_operators[t] = w * self._constant
self._operators = new_operators
self._constant = self._constant * other._constant
self._reset_caches()
return self
def _op__matmul__(self, other):
if not isinstance(other, FermionOperator2nd):
return NotImplemented
dtype = np.promote_types(self.dtype, other.dtype)
op = self.copy(dtype=dtype)
return op._op__imatmul__(other)
def __radd__(self, other):
return self.__add__(other)
def __add__(self, other):
dtype = np.promote_types(self.dtype, _dtype(other))
op = self.copy(dtype=dtype)
return op.__iadd__(other)
def __iadd__(self, other):
if is_scalar(other):
if not _isclose(other, 0.0):
self._constant += other
return self
if not isinstance(other, FermionOperator2nd):
raise NotImplementedError(
f"In-place addition not implemented for {type(self)} "
f"and {type(other)}"
)
if not self.hilbert == other.hilbert:
raise ValueError(
f"Can only add identical hilbert spaces (got A+B, A={self.hilbert}, "
f"B={other.hilbert})"
)
if not np.can_cast(_dtype(other), self.dtype, casting="same_kind"):
raise ValueError(
f"Cannot add inplace operator with dtype {type(other)} "
f"to operator with dtype {self.dtype}"
)
self_ops = self._operators
for t, w in other._operators.items():
sw = self_ops.get(t, None)
if sw is None and not np.isclose(w, 0):
self_ops[t] = w
elif sw is not None:
w = sw + w
if np.isclose(w, 0):
del self_ops[t]
else:
self_ops[t] = w
self._constant += other._constant
self._reset_caches()
return self
def __sub__(self, other):
return self + (-other)
def __rsub__(self, other):
return other + (-self)
def __neg__(self):
return self.__mul__(np.array(-1, dtype=self.dtype))
def __rmul__(self, scalar):
return self * scalar
def __imul__(self, scalar):
if not is_scalar(scalar):
# we will overload this as matrix multiplication
self._op__imatmul__(scalar)
if not np.can_cast(_dtype(scalar), self.dtype, casting="same_kind"):
raise ValueError(
f"Cannot multiply inplace scalar with dtype {type(scalar)} "
f"to operator with dtype {self.dtype}"
)
scalar = np.array(scalar, dtype=self.dtype).item()
if np.isclose(scalar, 0):
new_operators = {}
else:
new_operators = {o: scalar * v for o, v in self._operators.items()}
self._operators = new_operators
self._constant *= scalar
self._reset_caches()
return self
def __mul__(self, scalar):
if not is_scalar(scalar):
# we will overload this as matrix multiplication
return self._op__matmul__(scalar)
dtype = np.promote_types(self.dtype, _dtype(scalar))
op = self.copy(dtype=dtype)
return op.__imul__(scalar)
[docs] def conjugate(self, *, concrete=False):
r"""Returns the complex conjugate of this operator."""
terms = list(self._operators.keys())
weights = list(self._operators.values())
terms, weights = _herm_conj(terms, weights) # changes also the terms
terms = _make_tuple_tree(terms)
new = FermionOperator2nd(
self.hilbert,
constant=np.conjugate(self._constant),
dtype=self.dtype,
)
new._operators = dict(zip(terms, weights))
return new
[docs] def to_normal_order(self):
"""Reoder the operators to normal order.
Normal ordering corresponds to placing creating operators on the left and annihilation on the right.
Then, it places the highest index on the left and lowest index on the right
In this ordering, we make sure to account for the anti-commutation of operators.
`Normal ordering documentation <https://en.wikipedia.org/wiki/Normal_order#Fermions>`_
"""
terms, weights = _normal_ordering(self.terms, self.weights)
new = FermionOperator2nd(
self.hilbert,
constant=self._constant,
dtype=self.dtype,
)
new._operators = dict(zip(terms, weights))
new.reduce()
return new
[docs] def to_pair_order(self):
"""Reoder the operators to pair order.
Pair ordering corresponds to placing first the highest indices on the right,
and then making sure the creation operators are on the left and annihilation on the right.
In this ordering, we make sure to account for the anti-commutation of operators.
"""
terms, weights = _pair_ordering(self.terms, self.weights)
new = FermionOperator2nd(
self.hilbert,
constant=self._constant,
dtype=self.dtype,
)
new._operators = dict(zip(terms, weights))
new.reduce()
return new
def _pack_internals(operators: OperatorDict, dtype: DType):
"""
Create the internal structures to compute the matrix elements
Processes and adds a single term such that we can compute its matrix elements, in tuple format ((1,1), (2,0))
"""
# properties of single-fermion operators, e.g. "0^"
orb_idxs = []
daggers = []
# properties of multi-body operators, e.g. "0^ 1"
weights = []
# herm_term = []
diag_idxs = []
off_diag_idxs = []
# below connect the second type to the first type (used to split single-fermion lists)
term_split_idxs = []
constants = []
term_counter = 0
single_op_counter = 0
for term, weight in operators.items():
if len(term) == 0:
constants.append(weight)
continue
if not all(len(t) == 2 for t in term):
raise ValueError(f"terms must contain (i, dag) pairs, but received {term}")
# fill some info about the term
weights.append(weight)
is_diag = _is_diag_term(term)
if is_diag:
diag_idxs.append(term_counter)
else:
off_diag_idxs.append(term_counter)
# single-fermion operators
for orb_idx, dagger in term:
# orb_idxs: holds the hilbert index of the orbital
orb_idxs.append(orb_idx)
# daggers: stores whether operator is creator or annihilator
daggers.append(not bool(dagger))
single_op_counter += 1
term_split_idxs.append(single_op_counter)
term_counter += 1
orb_idxs = np.array(orb_idxs, dtype=np.intp)
daggers = np.array(daggers, dtype=bool)
weights = np.array(weights, dtype=dtype)
# term_ends = np.array(term_ends, dtype=bool)
# herm_term = np.array(herm_term, dtype=bool)
diag_idxs = np.array(diag_idxs, dtype=np.intp)
off_diag_idxs = np.array(off_diag_idxs, dtype=np.intp)
term_split_idxs = np.array(term_split_idxs, dtype=np.intp)
constant = sum(constants)
return (
orb_idxs,
daggers,
weights,
diag_idxs,
off_diag_idxs,
term_split_idxs,
constant,
)
@numba.jit(nopython=True)
def _isclose(a, b, cutoff=1e-8): # pragma: no cover
return np.abs(a - b) < cutoff
@numba.jit(nopython=True)
def _is_empty(site): # pragma: no cover
return _isclose(site, 0)
@numba.jit(nopython=True)
def _flip(site): # pragma: no cover
return 1 - site
@numba.jit(nopython=True)
def _apply_operator(xt, orb_idx, dagger, mel): # pragma: no cover
has_xp = True
empty_site = _is_empty(xt[orb_idx])
if dagger:
if not empty_site:
has_xp = False
else:
mel *= (-1) ** np.sum(xt[:orb_idx]) # jordan wigner sign
xt[orb_idx] = _flip(xt[orb_idx])
else:
if empty_site:
has_xp = False
else:
mel *= (-1) ** np.sum(xt[:orb_idx]) # jordan wigner sign
xt[orb_idx] = _flip(xt[orb_idx])
if _isclose(mel, 0):
has_xp = False
return xt, mel, has_xp