🎃

Qulacs を例に Julia から Python ライブラリを呼べるようにする話

2022/02/01に公開1

本日は

Qulacs を Julia から Pythonista にとって自然な形で利用できるようにした Kyulacs.jl を(私が作ったので)紹介します. 現時点では野良パッケージです.

Qulacs 自体は Qulacs にある説明を引用します:

Qulacsは、高速な量子回路シミュレータであり、大きな量子回路やノイズがあったり、パラメトリックな量子回路にも対応しております。 Qulacsは、C / C ++で実装されており、Pythonインターフェイスもあるため、高速回路シミュレーションと高い操作性の両立しました。

もっと知りたい場合は Quantum Native Dojo3章 を見ると良いです. 量子コンピュータを初めて学ぶ際も 2x2 の行列ができる程度の知識とクロネッカー積を知っていれば 1, 2 章を読めば雰囲気を掴めると思います.

Julia からも使いたい

Qulacs を Julia から使いたい場合下記のような選択肢があります

  • C/C++ API を Clang.jl や CxxWrap.jl を介して呼び出す
  • Python API を PyCall.jl を介して呼び出す
  • コードを読んで作る

今回は Python API を使う方法を採用しました. 「だってPython便利なんだもーん」 という本音と
中身は pybind11 を介して C/C++ のコードが走るっぽいので利便性を重視する方向に傾きました.

ハイハイ,どうせ using PyCall; qulacs=pyimport("qulacs") するんでしょ?知ってる知ってる(・ω・`). まぁまぁもう少しお付き合いください.

呼び出しましょう(愚直な方法)

Qulacs の Readme にあるサンプルコードを試してみましょう.

# readme_example.py
from qulacs import Observable, QuantumCircuit, QuantumState
from qulacs.gate import Y, CNOT, merge

state = QuantumState(3)
seed = 0  # set random seed
state.set_Haar_random_state(seed)

circuit = QuantumCircuit(3)
circuit.add_X_gate(0)
merged_gate = merge(CNOT(0, 1), Y(1))
circuit.add_gate(merged_gate)
circuit.add_RX_gate(1, 0.5)
circuit.update_quantum_state(state)

observable = Observable(3)
observable.add_operator(2.0, "X 2 Y 1 Z 0")
observable.add_operator(-3.0, "Z 2")
value = observable.get_expectation_value(state)
# 0.2835596510287872
print(value)

PyCall.jl を使うと pyimport 関数によって下記のように同じことができます.

# readme_example.jl
using PyCall
qulacs = pyimport("qulacs")
QuantumState = qulacs.QuantumState
QuantumCircuit = qulacs.QuantumCircuit
Observable = qulacs.Observable

gate = pyimport("qulacs.gate")
Y = gate.Y
CNOT = gate.CNOT
merge = gate.merge

state = QuantumState(3)
seed = 0  # set random seed
state.set_Haar_random_state(seed)

circuit = QuantumCircuit(3)
circuit.add_X_gate(0)
merged_gate = merge(CNOT(0, 1), Y(1))
circuit.add_gate(merged_gate)
circuit.add_RX_gate(1, 0.5)
circuit.update_quantum_state(state)

observable = Observable(3)
observable.add_operator(2.0, "X 2 Y 1 Z 0")
observable.add_operator(-3.0, "Z 2")
value = observable.get_expectation_value(state)
# 0.2835596510287872
println(value)

PyCall.jl は偉大なのでたいていのPythonライブラリを呼び出せます.上記のコードはもちろん動作します.ですが,qulacs 直下にアトリビュートとして得られるクラスや qulacs.gate 以下にあるものを呼び出すために
qulacs.xxxqulacs.gate.xxx のようなコードを書くのがちょっとだるいんですよね.

元々のPythonのコードと同様にもう少し簡単に QuantumState やゲートを呼び出せるようにできないものか? Kyulacs.jl がそれを解決します.

呼び出しましょう(Kyulacs.jl による方法)

Kyulacs.jl を使うと下記のようにできます.

using Kyulacs: Observable, QuantumCircuit, QuantumState
using Kyulacs.Gate: CNOT, Y, merge

state = QuantumState(3)
seed = 0  # set random seed
state.set_Haar_random_state(seed)

circuit = QuantumCircuit(3)
circuit.add_X_gate(0)
merged_gate = merge(CNOT(0, 1), Y(1))
circuit.add_gate(merged_gate)
circuit.add_RX_gate(1, 0.5)
circuit.update_quantum_state(state)

observable = Observable(3)
observable.add_operator(2.0, "X 2 Y 1 Z 0")
observable.add_operator(-3.0, "Z 2")
value = observable.get_expectation_value(state)
# 0.2835596510287872
println(value)

Python の from qulacs import ... の部分を using Kyulacs: ... と読み替えることであとはほぼ全て同じコードが使いまわせます.

細かい補足

値を表示する Pythonの print は Julia では println に相当します. Julia の printprintln の改行をしない版になります.

julia> print(1); print(2); print(3)
123
julia> println(1); println(2); println(3)
1
2
3

単に Kyulacs.jl の紹介だとここでおしまいですが,Kyulacs.jl で使っているテクニックを紹介していきます.

pyimport("qulacs") と Kyulacs.jl の違い

単純に pyimport するだけの違いを説明していきます.

Julia の型として見えていること

using Kyulacs をすると qulacsQuantumState, QuantumCircuit, Observable などを即時に使うことができます. これらの識別子は Kyulacs module の内部で export をすると宣言しているからです. qulacspyimport("qulacs") によって得られた PyObject を型にもつオブジェクトです.

julia> using Kyulacs
julia> qulacs |> typeof
PyCall.PyObject
julia> qulacs.QuantumState |> typeof
PyCall.PyObject # せやろな
julia> QuantumState |> typeof
DataType # おやぁ?

QuantumState は単に PyObject を型にもつ qulacs.QuantumState ではなく Julia の構造体として Kyulacs から export されていることがわかります.

せっかくなので2量子状態を表すインスタンスを作ってみましょう. 例えばここを読んでみる

julia> using Kyulacs
julia> state = QuantumState(2)
QuantumState(PyObject  *** Quantum State ***
 * Qubit Count : 2
 * Dimension   : 4
 * State vector :
(1,0)
(0,0)
(0,0)
(0,0)
)
julia> state.get_vector()
4-element Vector{ComplexF64}:
 1.0 + 0.0im
 0.0 + 0.0im
 0.0 + 0.0im
 0.0 + 0.0im

ケットベクトルによる表記で言えば |0\rangle^{\otimes 2} という状態が得られていることになります. ちなみに Julia だと Kronecker.jl が提供する 関数を使って確認できます.

julia> using Kronecker
julia> ψ = ComplexF64[1, 0]
2-element Vector{ComplexF64}:
 1.0 + 0.0im
 0.0 + 0.0im
 
julia> ψ ⊗ ψ
4×1 Kronecker.KroneckerProduct{ComplexF64, Matrix{ComplexF64}, Matrix{ComplexF64}}:
 1.0 + 0.0im
 0.0 + 0.0im
 0.0 + 0.0im
 0.0 + 0.0im

さて, state = QuantumState(2) によって state という Julia オブジェクトが作られました.

julia> state |> typeof
QuantumState

Julia の QuantumState 構造体は Python のインスタンスを格納するフィールドを持っている構造体として定義されています.

using PyCall
qulacs = pyimport("qulacs")
struct QuantumState
    pyobj::PyObject
    QuantumState(n) = new(qulacs.QuantumState(n))
end

実際,state.pyobj が実行できます.

julia> using Kyulacs
julia> state = QuantumState(2);
julia> state.pyobj
PyObject  *** Quantum State ***
 * Qubit Count : 2
 * Dimension   : 4
 * State vector :
(1,0)
(0,0)
(0,0)
(0,0)

つまり PyObject を型にする Pythonオブジェクトを包み込んでいるだけです.

ラップしたPythonオブジェクトに紐づいているメソッドを直に呼び出せる

一方で,get_vector という 2^2 次元の状態ベクトルを取得するメソッドがドットを使った方法で呼び出せていたことを思い出しましょう.

julia> state.get_vector() # Julia のオブジェクトなのにメソッドが呼び出せる
4-element Vector{ComplexF64}:
 1.0 + 0.0im
 0.0 + 0.0im
 0.0 + 0.0im
 0.0 + 0.0im

真面目に考えると下記のように pyobj を経由する必要がある必要があるはずです.

julia> state.pyobj.get_vector() 
4-element Vector{ComplexF64}:
 1.0 + 0.0im
 0.0 + 0.0im
 0.0 + 0.0im
 0.0 + 0.0im

なぜでしょう? 🧐 例えばJuliaで標準に使える複素数型の場合で試すと下記のようなエラーが出てきます:

julia> c = ComplexF64(1, 2)
1.0 + 2.0im

julia> c.re
1.0

julia> c.get_vector
ERROR: type Complex has no field get_vector
Stacktrace:
 [1] getproperty(x::ComplexF64, f::Symbol)
   @ Base ./Base.jl:42
 [2] top-level scope
   @ REPL[15]:1

実は Base.getproperty を改造している.

上記のエラーメッセージをよくみると getproperty というのが出ています.実はプログラマーが記述した state.get_object は Julia 内部では
getproperty(state, :get_object) という関数の呼び出しの形に変換されます. :get_object は Symbolを型にもつ Julia のオブジェクトです.

Base.jl: getproperty を読むと getfield 関数に帰着されJuliaの構造体のフィールドにアクセスします.複素数の例だと re というフィールドがあるから c.re が意味を持ち c.get_vector() というのはエラーを出すわけです.

一般論の説明はこれまでにして QuantumState の実装に戻ります. 実は Kyulacs.jl 内部では
QuantumState に対する getproperty 関数を次のように実装しています:

function Base.getproperty(t::QuantumState, s::Symbol)
    if s ∈ fieldnames(QuantumState)
        return getfield(t, s)
    else
        return getproperty(getfield(t, :pyobj), s)
    end
end

これによって state.pyobj.get_vector というまどろっこしい書き方をせずに state.get_vector という形で Python ライブラリ qulacs のメソッドを呼び出すことができます. :get_vector は Julia 構造体 QuantumState のフィールドではないので
getproperty(getfield(state, :pyobj), :get_vector) というのが実行されます.

state.<tab> の補完は Python の dir の結果が得られる.

単純に pyobj を包み込むだけだと state. のタブ補完は state.pyobj が得られてしまいます. get_vector を見つけるには ``state.pyobj.<タブ>のようにやはりpyobj` を経由する必要が有ります. ですが Kyulacs の実装だと下記のようになります.

julia> using Kyulacs; state = QuantumState(2)
julia> state. # ここでタブを連打
__class__                     __ne__                         get_marginal_probability
__delattr__                   __new__                        get_qubit_count
__dir__                       __reduce__                     get_squared_norm
__doc__                       __reduce_ex__                  get_vector
__eq__                        __repr__                       get_zero_probability
__format__                    __setattr__                    load
__ge__                        __sizeof__                     multiply_coef
__getattribute__              __str__                        multiply_elementwise_function
__gt__                        __subclasshook__               normalize
__hash__                      add_state                      sampling
__init__                      allocate_buffer                set_Haar_random_state
__init_subclass__             copy                           set_classical_value
__le__                        get_classical_value            set_computational_basis
__lt__                        get_device_name                set_zero_state
__module__                    get_entropy                    to_string

これは Base.propertynames を次のように改造しているからです:

Base.propertynames(t::QuantumState)) = propertynames(getfield(t, :pyobj))

どうやら tab キーを押した時の候補は propertynames の戻り値が得られるようです. 上記の改造によって pyobj 以下の候補を列挙されるようになります.

Python メソッドの入力は Python オブジェクトの値を渡す

次のようなコードを考えます

using Kyulacs
state = QuantumState(2)
circuit = QuantumCircuit(2)
circuit.update_quantum_state(state)

circuit.update_quantum_state(state) は注意深く観察すると <Pythonのメソッド> に対してJulia のオブジェクト state をつっこんでいます. state は我々が作った Julia 構造体なのでそれをそのまま渡すとエラーが起きてしまいます. そこで下記コードによって Julia のオブジェクトをPyObjectする変換規則を与えてやります.そうすると PyCall の力で Julia のオブジェクトは包んでいる pyobj の方を update_quantum_state の引数に渡しているかのような処理を行えます.

using PyCall
PyObject(t::QuantumState) = t.pyobj

ここまでのまとめ

今までの話をまとめると下記に相当するコードが Kyulacs.jl に入り込んでいることが想像できます.

struct QuantumState
    pyobj::PyObject
    QuantumState(args...) = new(qulacs.gate.QuantumState(args...))
end

PyObject(t::QuantumState) = t.pyobj

function Base.propertynames(t::QuantumState)
    propertynames(getfield(t, :pyobj))
end

function Base.getproperty(t::QuantumState, s::Symbol)
    if s ∈ fieldnames(QuantumState)
        return getfield(t, s)
    else
        return getproperty(getfield(t, :pyobj), s)
    end
end

export QuantumState

マクロを使って賄う

さて,実際は↑のようなものが qulacs の QuantumState だけでなく QuantumCircuit などのさまざまなオブジェクトにも適用される必要があります. 各々の型の定義に対して同じような実装をつらつら書くのは辛いです. そこで Kyulacs.jl ではマクロを使って上記の処理を各々の qulacs のクラスに対して行っています.

for class in [:QuantumState, :QuantumCircuit] # ここは実はいっぱいある
    @eval begin
        struct $class
            pyobj::PyObject
            $(class)(args...) = new(qulacs.$(class)(args...))
        end

        PyObject(t::$(class)) = t.pyobj

        function Base.propertynames(t::$(class))
            propertynames(getfield(t, :pyobj))
        end

        function Base.getproperty(t::$(class), s::Symbol)
            if s ∈ fieldnames($(class))
                return getfield(t, s)
            else
                return getproperty(getfield(t, :pyobj), s)
            end
        end

        export $(class)
    end
end

@eval マクロによってループ内にあるテンプレートに命が吹き込まれ Pythonのクラスと同名の Julia の構造体が定義されていきます(そして export もされる).

qulacs.gate 以下にあるクラスも同様な手法で変換されます. 例えば CNOTusing Kyulacs.Gate とすることで使えます.

Julia のマクロすごいでしょ?この機構のおかげで私は調査も含めて 1日で Kyulacs.jl をリリースすることができました.

まとめ

Julia インターフェース Kyulacs.jl を使うことで qulacs の機能を Python ユーザーにとって自然な表記で利用できることがわかりました. Pythonクラスと同名の型を定義することで他のパッケージの抽象型のサブタイプとして活用できる余地も作ることができました.qulacs に限らず他の Python ライブラリにも応用できるテクニックですのでぜひ使ってみてください.

GitHubで編集を提案

Discussion

ごまふあざらしごまふあざらし

Kyulacs.jl を https://github.com/AtelierArith/Gallery.git レジストリに登録したので下記の手順で Docker のコンテナ内で実行できます.

$ docker run --rm -it julia:1.7.2
               _
   _       _ _(_)_     |  Documentation: https://docs.julialang.org
  (_)     | (_) (_)    |
   _ _   _| |_  __ _   |  Type "?" for help, "]?" for Pkg help.
  | | | | | | |/ _` |  |
  | | |_| | | | (_| |  |  Version 1.7.2 (2022-02-06)
 _/ |\__'_|_|_|\__'_|  |  Official https://julialang.org/ release
|__/                   |

julia> using Pkg
julia> pkg"registry add General https://github.com/AtelierArith/Gallery.git"
julia> Pkg.add("Conda") # Install Conda.jl
julia> using Conda
julia> Conda.pip_interop(true)
julia> Conda.pip("install", "qulacs")
julia> using Kyulacs: Observable, QuantumCircuit, QuantumState
julia> using Kyulacs.Gate: CNOT, Y, merge
julia> state = QuantumState(3)
julia> seed = 0  # set random seed
julia> state.set_Haar_random_state(seed)
julia> circuit = QuantumCircuit(3)
julia> circuit.add_X_gate(0)
julia> merged_gate = merge(CNOT(0, 1), Y(1))
julia> circuit.add_gate(merged_gate)
julia> circuit.add_RX_gate(1, 0.5)
julia> circuit.update_quantum_state(state)
julia> observable = Observable(3)
julia> observable.add_operator(2.0, "X 2 Y 1 Z 0")
julia> observable.add_operator(-3.0, "Z 2")
julia> value = observable.get_expectation_value(state)
julia> println(value) # 0.2086592572213417