Juliaで数値計算 with ビット演算子【S=1/2 Heisenbergモデル】

<a href="https://qiita.com/okachan6666/items/52705d8aa8d79c271a15">前回の記事</a>で$S=1/2$のHeisenbergモデルの行列を構成して固有値を求める話を書きました。
今回はビット演算子を用いて、直接行列を構成しないで、ある状態$\ket{\psi}$に対して$H\ket{\psi}$を計算する方法を紹介します。
# ビットシフト演算について
まずはビットシフト演算子について説明します。
例えば10進法で5は2進法では$101_{(2)}$となります。例えば各桁を右に1だけずらすと$10_{(2)}$が得られます。これをビットシフトと呼びます（本当は論理シフトとか算術シフトとか言いますが、ここでは深入りしません）。この計算をJuliaでは次のように行うことができます。

```Julia
a = 5
@show b = a >> 1
#b = a >> 1 = 2
 ```

AND演算に関しては`&`を使うことでできます。
例えば5を2進法で表示したときに、2の位が1なのかどうかを判別するには以下のようにします。

```Julia
a = 5
b = a >> 1 & 1
```
当然この結果としては0です。

次にビット反転について説明しましょう。ビットを反転させる（0を1に、あるいは1を0に）するには、排他的論理和`⊻`を使います。
同じ例を用いて、$5=101_{(2)}$の2の位の0を1に変えてみましょう。`⊻`は`\veebar`で入力できます。
`1 << 1`は先ほどのビットシフトの逆で左に1桁だけずらします。
```Julia
a = 5
@show c = a ⊻ (1 << 1)
#c = a ⊻ 1 << 1 = 7
```

さて、以上の道具を使ってスピン系の計算をしてみましょう。
# Heisenbergモデルのハミルトニアンと基底
$S=1/2$のHeisenbergモデルのハミルトニアンは以下のように書けるのでした。
```math
H = J\sum_{i=1}^{N-1}\vec{S}_i\cdot\vec{S}_{i+1} = J\sum_{i=1}^{N-1}\left(\frac12 S_i^+S_{i+1}^-+\frac12 S_i^-S_{i+1}^+ +S_i^zS_{i+1}^z\right)
```
以下では例として$N＝4$を考えます。
そこで、状態$\ket{\psi}$を次のようなベクトル表示にします。
```math
\begin{align}
\ket{\psi} &= x_0 \ket{\uparrow\uparrow\uparrow\uparrow}+x_1\ket{\uparrow\uparrow\uparrow\downarrow}+\cdots \\
&=\pmatrix{
x_0 \\ x_1 \\ \vdots \\ x_{15}}
\end{align}
```
ここでのアイデアはハミルトニアン行列を構成することではなく、直接$H\ket{\psi}$を計算することです。
これらのそれぞれの基底を10進法では0,1,2,...,15に対応させましょう。ビット列で表現すれば、
```math
\begin{align}
\ket{\uparrow\uparrow\uparrow\uparrow} \to \ket{0000}\\
\ket{\uparrow\uparrow\uparrow\downarrow} \to \ket{0001}
\end{align}
```
のように対応させています。ここで、基底とサイトの関係については$\ket{\sigma_N\sigma_{N-1}\cdots\sigma_1}$とします（前回の記事や、多くの他の文献の場合には逆順になっていることが多いことに注意してください）。

例えば$S_1^zS_2^z\ket{0101}$を計算したければ、
```Julia
new_psi = zeros(Float32, Int(2^N))
state = 5
s1 = state >> 0 & 1
s2 = state >> 1 & 1
new_psi[state+1] += 0.25 * ((-1)^s1) * (-1)^s2
```
とします。これで計算結果のベクトルにすることができます。Juliaは1-indexであることに注意して、`state+1`としています。
ではスピンフリップの項について、$(S_1^+S_2^-+S_1^-S_2^+)\ket{0101}$の計算は次のようにする。
```Julia
if s1 != s2
    new_state = state ⊻ (1 << 0) ⊻ (1 << 1)
    new_psi[new_state+1] += 1.0
end
```
以上の計算をすべてのサイト間で行えば$H\ket{\psi}$を計算できるようになります。
磁場の項$S_1^z\ket{0101}$なども計算することが可能です。
```Julia
new_psi[state+1] += 0.5 * ((-1)^s1)
```
で十分です。

# 全体のコード
全体のコードをまとめると次のようになります。
```Julia
function hpsi(psi::Vector{Float32}, N::Int, J::Float32)
    """
    Heisenbergモデルのハミルトニアンとベクトルの積 H|ψ⟩ を計算
    psi: 入力状態ベクトル
    N: サイト数
    J: 交換相互作用定数
    """
    new_psi = zeros(Float32, length(psi))  # 出力状態ベクトル

    for state in 0:length(psi)-1
        x = psi[state+1]
        if x == 0.0
            continue  #係数が0ならスキップ
        end
        # スピン間の相互作用を計算
        for i in 0:(N - 2)
            j = i + 1  # 隣接サイト

            # 現在のスピン配置をビット列で解析
            spin_i = (state >> i) & 1
            spin_j = (state >> j) & 1

            # SzSz の寄与
            sz_energy = J * 0.25 * ((-1)^spin_i) * ((-1)^spin_j)
            new_psi[state+1] += sz_energy * x

            # SxSx + SySy の寄与 (スピンフリップ)
            if spin_i != spin_j
                # スピンフリップした新しい状態を生成
                new_state = state ⊻ (1 << i) ⊻ (1 << j)  # ビットフリップ
                new_psi[new_state+1] += J * 0.5 * x
            end
        end
    end
    return new_psi
end
```
試しに$H\ket{0101}$を計算してみましょう。
```math
H\ket{0101} = -0.75\ket{0101} + 0.5\ket{0110}+0.5\ket{0011}+0.5\ket{1001}
```
になるはずです。
```Julia
N = 4
J = Float32(1.0)
psi = zeros(Float32, 2^N)
psi[6] = 1.0

new_psi = hpsi(psi, N, J)
for i in 1:2^N
    if new_psi[i] != 0.0
        println("$(i-1) $(new_psi[i])")
    end
end
#3 0.5
#5 -0.75
#6 0.5
#9 0.5
```
このように、正しく計算できていることがわかります。

#  （追記）冪乗法で基底状態を求める
せっかくなので、冪乗法で基底状態を求めることもやってみましょう。
```math
\begin{align}
{\bf y}_k&= H{\bf x}_k\\
{\bf x}_{k+1} &= {\bf y}_k/||{\bf y}_k||
\end{align}
```
というふうに定めます。このとき、絶対値が最も大きい固有値は
```math
\lambda_0 = \lim_{k\to \infty} {\bf y}_k^\dagger{\bf x}_k
```
で与えられます。
基底状態が欲しいので、単位行列の作用分だけ加えます。具体的には`hpsi`の中身で
```Julia
#new_psi = zeros(Float32, length(psi))  # 出力状態ベクトル
new_psi = -1 * psi
```
とします。
ハミルトニアン$H$を繰り返しかける関数を次のように定義しましょう。この関数では${\bf x}_k$を入力として、
${\bf y}_k$,

${\bf x}_{k+1}$

を出力にしています。
```Julia
function hkpsi(psi::Vector{Float32}, N::Int, J::Float32)
        nextpsi = hpsi(psi, N, J)
        norm = sqrt(nextpsi'*nextpsi)
        nextpsi_normed = nextpsi/norm
    return nextpsi, nextpsi_normed
end
```

例えば$N=10$として、基底状態のエネルギーを求めるには次のようにします。
ついでに<a href="https://qiita.com/okachan6666/items/52705d8aa8d79c271a15">前回の記事</a>で作った`make_Ham`をArpackを用いて固有値を求める計算と比較してみます。

```Julia
function main()
    N = 10
    J = Float32(1.0)
    psi = zeros(Float32, 2^N)
    ind =2+2^3+2^5+2^7+2^9
    psi[ind] = 1.0
    psin, psin_normed= hkpsi(psi, N, J)
    for i in 1:200
        psi = psin_normed
        psin, psin_normed = hkpsi(psi, N, J)
    end
    return psin'*psi + 1
end
@time e = main()
println(e)

using SparseArrays
using Arpack
function hikaku()
    H = 0.25* make_Ham(10, 0.0)
    return eigs(H, nev=1, which=:SR)[1][1]
end

@time e = hikaku()
println(e)

#0.003054 seconds (1.21 k allocations: 1.599 MiB)
#-4.2580347
#0.485044 seconds (6.45 M allocations: 287.661 MiB, 4.02% gc time, 79.80% compilation time)
#-4.258035207282885
```
Arpackの方は疎行列でLanzcos法を用いて計算しているはずなので、時間がかかるのも仕方ないですが、それでも2桁の差が出ています。
ちなみに、$N=16$としてみると、疎行列を構成して計算する手法では全くダメになってしまいます。計算時間がだいぶ変わりますね。
```
  0.338363 seconds (1.21 k allocations: 100.775 MiB, 8.75% gc time)
-6.9116955
384.566716 seconds (22.37 G allocations: 1.139 TiB, 10.97% gc time, 0.11% compilation time)
-6.911737145575105
```

# まとめ
基底状態のエネルギーを求めるには行列を作らない方がよい。