Repository: netket/netket

Issue:
Errors when using 'exact' representation of density matrices
A worrisome email I received:

>Since the 3.0 update some observables in my calculations switched signs (the MC estimates).
>
>When calculating expectation values of an operator that is not symmetric in a steady state solution I get a different sign when calculating
>```
>rho = nk.exact.steady_state(lind)
>tr(rho@op.to_dense())
>0.15
>ss.estimate(op)
>-0.15
>```
>
>When investigating this, I didn’t find any fault in the MC nk code, but one possible explanation could be that operator.to_dense() returns a transposed matrix or is this your convention that operators are applied to the left?

He also showed me a case that I further reduced a bit. here is my explanation:

In nette, for reasons unbeknown to me, we have the convention of labelling the Hilbert space from down to up, from low to high. So the local Hilbert space of Pauli matrices is [down, up], as testified by
```
>>> nk.hilbert.Spin(0.5).all_states()
array([[-1.],
       [ 1.]])
 
```

However, if I now create the sigma plus operator I get:
```
>>> nk.operator.spin.sigmap(nk.hilbert.Spin(0.5), 0).operators
[array([[0., 1.],
       [0., 0.]])]
```

And clearly in this conventiont this is the sigma_minus operator.
The same issue happens with non symmetric matrices like sigma_y.

The error is on my part, as I had taken that code from some older code of mine which I guess assumed opposite ordering. Interestingly enough, boson operators are correct...

This is consistent with the fact that I long have seen that sigma_y had the opposite sign in some calculations compared to my old article, but after investigating I thought the error was on my old article...

Still, while the fix is clear, I have to double check the Impact on the ordering of the lindblad jump operators because I think I might have implemented a transposition there otherwise I don't understand how it can work...

---

Note: THIS WILL BREAK CODES


Discussion:
I think also sigma_z is wrong.
Also Ising and Heisenberg are wrong.
They use the transposed sigma_y, but the wrong sigma_z.

So they behave fine until you have to measure some observable like sigma_plus/minus on them.

The culprit seems to be the definition that NetKet has used since the dawn of times,
```python
sx = np.array([[0, 1], [1, 0]]);
sy = np.array([[0, -1j], [1j, 0]]);
sz = np.array([[1, 0], [0, -1]]); (edited) 
```

is... wrong, as it does not respect the Pauli algebra:

From Pauli algebra we know that Sx@Sy = 1j Sz

try multiplying sx@sy above, it does not give the correct result.

We must switch to 

```
 sz = np.array([[-1, 0], [0, 1]]);
```

Mmmm.. 
no, it seems that I am exaggerating.
The historical definition of the Pauli matrices is correct, and the only bug is me transposing them in operator.spin.

It's simply that we are using an opposite convention for the states, where [-1] corresponds to the up state which is... weird and confusing
Exactly, we are using a computational basis which is not corresponding to the eigenstates of sigma_z, but rather -sigma_z. This is maybe confusing, but I think that defining raising and lowering operators consistently will not lead to wrong results... 
I noticed the issue a [while ago](https://github.com/netket/netket/issues/469#issuecomment-692725941), so in my codes I just flip the states at the end if I want to compute anything out of the states. This, indeed, only happens with spins and not bosons as the +1 state normally corresponds to (1,0) and the -1 state normally corresponds to (0,1), but in netket it's the other way around.

Also, by the definitions you give (@PhilipVinc) of the pauli matrices, they are correct and the commutation relations are correct. Maybe a solution would be to leave things as they are, but when someone wants to sample the steady state or the ground state, or create a batch of samples from a system with spins, there should be a warning explaining the issue (maybe some future warning saying that in a future version of netket things will be flipped for spins) and some util, to help the user flip things around for the spin part of the system.
A solution might be to just redefine ```Spin``` in a way that is sorted the other way around... it's not a big issue I think. The important is just that to avoid bugs ```sigmap``` and ```sigmam``` are correctly and consistently defined 
Hi, I brought up the initial issue with the expectation values. 
A redefinition of the sigma_plus and minus changes the signs of both calculations, so maybe there is a mixing of conventions? I don't use sigma_x,y,z. 

```python
>> rho = nk.exact.steady_state(lind)
>> tr(rho@op.to_dense())
-0.15
>> ss.estimate(op)
0.15
```

`tr(rho@op.to_dense().T)` gives the same result as nk, but this changes things... 
What is op?
Unfortunate naming on my part - op is an operator representing an observable (an asymmetric one, e.g. Sigma_y).

I attached a minimal demo script based on the dissipative ising1d example, showing what exactly I mean.
[testOperators.py.zip](https://github.com/netket/netket/files/6288081/testOperators.py.zip)
The output I get:  
[testOperators_output.txt](https://github.com/netket/netket/files/6288107/testOperators_output.txt)
Can you reproduce this? Takes a minute to run. 

Here 
```
print(f'RBM result for the observable: {ss.estimate(obs)}')
print(f'Exact result from ED:          {np.trace(rho_0@obs.to_dense())}')
```
Gives the output:
> ...
> RBM result for the observable: 0.063+0.021j ± 0.052 [σ²=1.360, R̂=0.9978]
> Exact result from ED:          (-0.08141487086313647+1.5265566588595902e-16j)
> ...

In the second half of the script I recreated the netket mechanism for calculating rho.expect(operator) to better understand what is going on. 
Using this code and removing the Monte-Carlo part I get the exact same result as the ED result rho_0 but with a different sign.

> ...
> Reproduction of expectation value using rho_0: (0.08141487086313648-1.1102230246251565e-16j)
> Result from exact diagonalization rho_0      : (-0.08141487086313647+1.5265566588595902e-16j)
> ...

But if I transpose all operator matrices, which would change the sign of the observable, the lindblad operator is not correct. 
I hope you can reproduce my results with this and maybe tell me where I messed up? 
I think that the "fix" to this is to re-order ```Spin``` basis states in order to have [1,-1] instead of [-1,1], this is a one-line fix 
But again, I stress that this is not a bug, one can well call the basis states [-3435,+231212], define the usual Pauli matrices, and still have consistent results. (modulo knowing that the quantum numbers used are not the eigenstates of Sigma_z). The issue I think here arises only in the density matrix, to be investigated further ...
Upon more debugging it seems to me that:

 - `nk.exact.steady_state` returns the transpose of the steady_state. I say that by comparing the steady state computed by it with the steady state given by qutip. (I'm sure of that)
 - `nk.MCMixedState.to_matrix()` returns the transpose of the state (I'm 95% sure of this)
 - `nk.MCMixedState.estimate(obs)` works correctly

The fix would be fixing the two methods above...

@jmellak I think your 'exact' code is wrong. I double checked my previous results in [this paper](https://arxiv.org/pdf/1902.10104.pdf), which is the same model you have in your example, and sigma_y must be positive, so what is wrong here is the `exact` part, not the monte-carlo...

This would explain the inconsistency you see.
Thank you for digging into this! 👍 

I just want to mention that the 3.0 version release changed the MC result compared to the development branch v3.0, the exact result stayed the same. Maybe this helps pinpointing.
Upon further inspection, we store operators and super-operators with the same ordering of qutip.
So it was baffling to see that when I look for the steady state of a liouvillian by exact diagonalisation we get the transpose.

But then I found [qutip.superoperator.vec2mat](https://github.com/qutip/qutip/blob/67c46870749b6e5925feed106a493eac9124bcc3/qutip/superoperator.py#L322), and it seems that qutip, every time it reshapes from vector to matrix and viceversa it transposes the matrix.

Which we are not doing. So this explains it... 

I remember investigating a lot this ordering (it boils down to the way you flatten the density matrix) and I was sure I had found the correct way to do this in, but I have to re-ivnestigate this now...

Still I wonder if this affects the Monte Carlo code

Resolving patch:
diff --git a/netket/operator/_local_cost_functions.py b/netket/operator/_local_cost_functions.py
--- a/netket/operator/_local_cost_functions.py
+++ b/netket/operator/_local_cost_functions.py
@@ -211,6 +211,6 @@ def local_value_cost(logpsi, pars, vp, mel, v):
 @partial(define_local_cost_function, static_argnums=0, batch_axes=(None, None, 0, 0, 0))
 def local_value_op_op_cost(logpsi, pars, σp, mel, σ):
 
-    σ_σp = jax.vmap(lambda σ, σp: jnp.hstack((σ, σp)), in_axes=(None, 0))(σ, σp)
+    σ_σp = jax.vmap(lambda σp, σ: jnp.hstack((σp, σ)), in_axes=(0, None))(σp, σ)
     σ_σ = jnp.hstack((σ, σ))
     return jnp.sum(mel * jnp.exp(logpsi(pars, σ_σp) - logpsi(pars, σ_σ)))
diff --git a/netket/operator/_local_liouvillian.py b/netket/operator/_local_liouvillian.py
--- a/netket/operator/_local_liouvillian.py
+++ b/netket/operator/_local_liouvillian.py
@@ -58,7 +58,7 @@ class LocalLiouvillian(AbstractSuperOperator):
 
     .. math ::
 
-        \\mathcal{L} = -i \\hat{H}\\hat{\\rho} +i\\hat{\\rho}\\hat{H}^\\dagger + \\sum_i \\hat{L}_i\\hat{\\rho}\\hat{L}_i^\\dagger
+        \\mathcal{L} = -i \\hat{H}_{nh}\\hat{\\rho} +i\\hat{\\rho}\\hat{H}_{nh}^\\dagger + \\sum_i \\hat{L}_i\\hat{\\rho}\\hat{L}_i^\\dagger
 
     """
 
@@ -73,7 +73,6 @@ def __init__(
         self._H = ham
         self._jump_ops = [op.copy(dtype=dtype) for op in jump_ops]  # to accept dicts
         self._Hnh = ham
-        self._Hnh_dag = ham.H
         self._max_dissipator_conn_size = 0
         self._max_conn_size = 0
 
@@ -110,12 +109,12 @@ def _compute_hnh(self):
         max_conn_size = 0
         for L in self._jump_ops:
             Hnh = Hnh - 0.5j * L.conjugate().transpose() @ L
-            max_conn_size += (L.n_operators * L._max_op_size) ** 2
+            dim = L.n_operators * L._max_op_size
+            max_conn_size += dim ** 2 + 2 * dim
 
         self._max_dissipator_conn_size = max_conn_size
 
         self._Hnh = Hnh.collect()
-        self._Hnh_dag = Hnh.H.collect()
 
         max_conn_size = (
             self._max_dissipator_conn_size + Hnh.n_operators * Hnh._max_op_size
@@ -149,16 +148,16 @@ def get_conn(self, x):
         xr, xc = x[0:n_sites], x[n_sites : 2 * n_sites]
         i = 0
 
-        xrp, mel_r = self._Hnh_dag.get_conn(xr)
+        xrp, mel_r = self._Hnh.get_conn(xr)
         self._xrv[i : i + len(mel_r), :] = xrp
         self._xcv[i : i + len(mel_r), :] = xc
-        self._mels[i : i + len(mel_r)] = mel_r * 1j
+        self._mels[i : i + len(mel_r)] = -1j * mel_r
         i = i + len(mel_r)
 
         xcp, mel_c = self._Hnh.get_conn(xc)
         self._xrv[i : i + len(mel_c), :] = xr
         self._xcv[i : i + len(mel_c), :] = xcp
-        self._mels[i : i + len(mel_r)] = mel_c * (-1j)
+        self._mels[i : i + len(mel_r)] = 1j * np.conj(mel_c)
         i = i + len(mel_c)
 
         for L in self._jump_ops:
@@ -171,7 +170,7 @@ def get_conn(self, x):
             for r in range(nr):
                 self._xrv[i : i + nc, :] = L_xrp[r, :]
                 self._xcv[i : i + nc, :] = L_xcp
-                self._mels[i : i + nc] = np.conj(L_mel_r[r]) * L_mel_c
+                self._mels[i : i + nc] = L_mel_r[r] * np.conj(L_mel_c)
                 i = i + nc
 
         return np.copy(self._xprime[0:i, :]), np.copy(self._mels[0:i])
@@ -192,7 +191,7 @@ def get_conn_flattened(self, x, sections, pad=False):
         # Compute all flattened connections of each term
         sections_r = np.empty(batch_size, dtype=np.int64)
         sections_c = np.empty(batch_size, dtype=np.int64)
-        xr_prime, mels_r = self._Hnh_dag.get_conn_flattened(xr, sections_r)
+        xr_prime, mels_r = self._Hnh.get_conn_flattened(xr, sections_r)
         xc_prime, mels_c = self._Hnh.get_conn_flattened(xc, sections_c)
 
         if pad:
@@ -323,7 +322,7 @@ def _get_conn_flattened_kernel(
             n_hr = n_hr_f - n_hr_i
             xs[off : off + n_hr, 0:N] = xr_prime[n_hr_i:n_hr_f, :]
             xs[off : off + n_hr, N : 2 * N] = xc[i, :]
-            mels[off : off + n_hr] = 1j * mels_r[n_hr_i:n_hr_f]
+            mels[off : off + n_hr] = -1j * mels_r[n_hr_i:n_hr_f]
             off += n_hr
             n_hr_i = n_hr_f
 
@@ -331,7 +330,7 @@ def _get_conn_flattened_kernel(
             n_hc = n_hc_f - n_hc_i
             xs[off : off + n_hc, N : 2 * N] = xc_prime[n_hc_i:n_hc_f, :]
             xs[off : off + n_hc, 0:N] = xr[i, :]
-            mels[off : off + n_hc] = -1j * mels_c[n_hc_i:n_hc_f]
+            mels[off : off + n_hc] = 1j * np.conj(mels_c[n_hc_i:n_hc_f])
             off += n_hc
             n_hc_i = n_hc_f
 
@@ -349,8 +348,8 @@ def _get_conn_flattened_kernel(
                 for r in range(n_Lr):
                     xs[off : off + n_Lc, 0:N] = L_xrp[n_Lr_i + r, :]
                     xs[off : off + n_Lc, N : 2 * N] = L_xcp[n_Lc_i:n_Lc_f, :]
-                    mels[off : off + n_Lc] = (
-                        np.conj(L_mel_r[n_Lr_i + r]) * L_mel_c[n_Lc_i:n_Lc_f]
+                    mels[off : off + n_Lc] = L_mel_r[n_Lr_i + r] * np.conj(
+                        L_mel_c[n_Lc_i:n_Lc_f]
                     )
                     off = off + n_Lc
 
@@ -409,7 +408,7 @@ def matvec(rho_vec):
 
                 drho = np.zeros((M, M), dtype=rho.dtype)
 
-                drho += rho @ iHnh + iHnh.conj().T @ rho
+                drho += iHnh @ rho + rho @ iHnh.conj().T
                 for J, J_c in zip(J_ops, J_ops_c):
                     drho += (J @ rho) @ J_c
 
@@ -434,7 +433,7 @@ def matvec(rho_vec):
                 out = np.zeros((M ** 2 + 1), dtype=rho.dtype)
                 drho = out[:-1].reshape((M, M))
 
-                drho += rho @ iHnh + iHnh.conj().T @ rho
+                drho += iHnh @ rho + rho @ iHnh.conj().T
                 for J, J_c in zip(J_ops, J_ops_c):
                     drho += (J @ rho) @ J_c
 
diff --git a/netket/variational/base.py b/netket/variational/base.py
--- a/netket/variational/base.py
+++ b/netket/variational/base.py
@@ -247,12 +247,17 @@ def expect_and_grad(
         self,
         Ô: AbstractOperator,
         mutable: bool = None,
+        is_hermitian: Optional[bool] = None,
     ) -> Tuple[Stats, PyTree]:
         # do the computation in super-operator space
         if self.hilbert == Ô.hilbert:
-            return super().expect_and_grad(Ô, mutable=mutable)
+            return super().expect_and_grad(
+                Ô, mutable=mutable, is_hermitian=is_hermitian
+            )
         elif self.hilbert_physical == Ô.hilbert:
-            return super().expect_and_grad(Ô, mutable=mutable)
+            return super().expect_and_grad(
+                Ô, mutable=mutable, is_hermitian=is_hermitian
+            )
         else:
             return NotImplemented
 


Test patch:
diff --git a/Test/Operator/test_liouvillian.py b/Test/Operator/test_liouvillian.py
--- a/Test/Operator/test_liouvillian.py
+++ b/Test/Operator/test_liouvillian.py
@@ -22,6 +22,8 @@
 from pytest import approx
 import os
 
+from netket.operator import spin
+
 np.set_printoptions(linewidth=180)
 
 # 1D Lattice
@@ -30,21 +32,15 @@
 # Hilbert space of spins on the graph
 hi = nk.hilbert.Spin(s=0.5) ** L
 
-# Defining the Ising hamiltonian (with sign problem here)
-# Using local operators
-sx = [[0, 1], [1, 0]]
-sy = [[0, -1j], [1j, 0]]
-sz = [[1, 0], [0, -1]]
-
-sigmam = [[0, 0], [1, 0]]
-
-ha = nk.operator.LocalOperator(hi)
+ha = nk.operator.LocalOperator(hi, dtype=complex)
 j_ops = []
 
 for i in range(L):
-    ha += nk.operator.LocalOperator(hi, sx, [i])
-    ha += nk.operator.LocalOperator(hi, np.kron(sz, sz), [i, (i + 1) % L])
-    j_ops.append(nk.operator.LocalOperator(hi, sigmam, [i]))
+    ha += spin.sigmax(hi, i)
+    ha += spin.sigmay(hi, i)
+    ha += spin.sigmaz(hi, i) @ spin.sigmaz(hi, (i + 1) % L)
+    j_ops.append(spin.sigmam(hi, i))
+    j_ops.append(1j * spin.sigmam(hi, i))
 
 
 #  Create the lindbladian with
@@ -62,12 +58,16 @@ def test_lindblad_form():
         hnh_mat -= 0.5j * j_mat.H * j_mat
 
     # Compute the left and right product with identity
-    lind_mat = -1j * sparse.kron(idmat, hnh_mat) + 1j * sparse.kron(hnh_mat.H, idmat)
+    lind_mat = -1j * sparse.kron(hnh_mat, idmat) + 1j * sparse.kron(
+        idmat, hnh_mat.conj()
+    )
     # add jump operators
     for j_op in j_ops:
         j_mat = j_op.to_sparse()
-        lind_mat += sparse.kron(j_mat.conj(), j_mat)
+        lind_mat += sparse.kron(j_mat, j_mat.conj())
 
+    print(lind_mat.shape)
+    print(lind.to_dense().shape)
     np.testing.assert_allclose(lind_mat.todense(), lind.to_dense())
 
 
@@ -78,7 +78,7 @@ def test_liouvillian_no_dissipators():
     idmat = sparse.eye(2 ** L)
     h_mat = ha.to_sparse()
 
-    lind_mat = -1j * sparse.kron(idmat, h_mat) + 1j * sparse.kron(h_mat, idmat)
+    lind_mat = -1j * sparse.kron(h_mat, idmat) + 1j * sparse.kron(idmat, h_mat.conj())
 
     np.testing.assert_allclose(lind.to_dense(), lind_mat.todense())
 
@@ -119,18 +119,3 @@ def test_linear_operator():
     assert res_op2[-1] - dm.reshape((hi.n_states, hi.n_states)).trace() == approx(
         0.0, rel=1e-8, abs=1e-8
     )
-
-
-# Construct the operators for Sx, Sy and Sz
-obs_sx = nk.operator.LocalOperator(hi)
-obs_sy = nk.operator.LocalOperator(hi, dtype=complex)
-obs_sz = nk.operator.LocalOperator(hi)
-for i in range(L):
-    obs_sx += nk.operator.LocalOperator(hi, sx, [i])
-    obs_sy += nk.operator.LocalOperator(hi, sy, [i])
-    obs_sz += nk.operator.LocalOperator(hi, sz, [i])
-
-
-sxmat = obs_sx.to_dense()
-symat = obs_sy.to_dense()
-szmat = obs_sz.to_dense()