Welcome to Quantum Native Dojo!¶
Quantum Native Dojoは量子コンピュータについて勉強したいと思っている方のために作られた自習教材です。
量子コンピュータの基本的な動作原理から、基礎アルゴリズム、それらを応用してどのように化学計算や金融計算などに役立てるかを学ぶことができます。本教材は誤り訂正の有る量子コンピュータのアルゴリズムの他、数年以内に実用されるであろうNISQ (Noisy Intermidiate-Scale Quantum) デバイスのアルゴリズムもカバーしています。
全ての教材が Jupyter notebook で製作され、そのまま Google Colaboratory 上で実行可能になっているので、面倒な環境設定をすることなく学習を始めることが可能です。
この教材の意義:Becoming Quantum Native¶
量子コンピュータは、量子力学の原理に基づいて計算を行います。一方、私達がふだん目にする物理現象は主に古典力学に支配されています。ここに「量子コンピュータは難しい」と思われる原因の一端があります。
Quantum Native Dojoでは、みなさまに量子コンピュータの動作を感覚的に理解して使いこなせる Quantum Native になっていただくことを目標としています。Quantum Nativeへの道のりは簡単ではありませんが、このDojoを通して基礎からじっくりと量子力学と量子コンピュータの原理・応用を学ぶことが着実な一歩となるでしょう。
このDojoを巣立ち、Quantum Nativeとなったみなさまが様々な量子アルゴリズム/アプリケーションを作るエンジニアとして活躍されることを期待しています!
前提となる知識¶
Quantum Native Dojoの内容を理解するには、以下のような知識が必要です。
複素数とは何か
簡単な関数(sin, cos, exp, ...)の微積分
行列とベクトルの掛け算、対角化とは何か
こちらの前提知識及びPython・NumPyの使用に不安がある方は、 Chainer Tutorial の1.〜12.を先に学習することをオススメします。
第0章 そもそも量子コンピュータとは?¶
量子コンピュータというアイディア¶
量子コンピュータのアイディア自体は古く、エッセイ「ご冗談でしょう、ファインマンさん」でも有名な物理学者リチャード・ファインマンが、1982年に「自然をシミュレーションしたければ、量子力学の原理でコンピュータを作らなくてはならない」と述べたことに端を発する[1]。そして、1985年にオックスフォード大学の物理学者デイビット・ドイチュによって量子コンピュータが理論的に定式化された[2]。
量子コンピュータと従来の古典コンピュータの最も異なる点は、その情報の表し方である。古典コンピュータの内部では、情報は0か1、どちらか1つの状態を取ることのできる「古典ビット」で表現される。これに対し、量子コンピュータの内部では、情報は0と1の両方の状態を同時に取ることのできる「量子ビット」で表現される(詳細はこれからQuantum Native Dojoで学んでいくのでご安心を)。量子コンピュータは、量子ビットを用いて多数の計算を同時に行い、その結果をうまく処理することで、古典コンピュータと比べて飛躍的に高速に計算を行える場合がある。
どのように役に立つのか¶
さきほど場合があると強調したのは、現実にある全ての問題(タスク)を、量子コンピュータが高速に計算できるわけではないからだ。むしろ、量子コンピュータの方が古典コンピュータより高速なのかどうか未だに分かっていない問題の方が圧倒的に多い。それでも、一部の問題については量子コンピュータの方が現状の古典コンピュータのアルゴリズムよりも高速であることが証明されている。その代表例が素因数分解である。
素因数分解は、\(30 = 2\times3\times5\) などの簡単な場合なら暗算でも計算できるが、分解すべき整数の桁数が大きくなってくると、最高レベルの速度を持つスーパーコンピュータでさえ、その計算には年単位・あるいは宇宙の寿命ほどの時間が必要になる。この「解くのに時間がかかる」ことを利用したのが、現在の暗号通信・情報セキュリティに広く用いられているRSA暗号である。しかし、1995年に米国の数学者ピーター・ショアが、古典よりも圧倒的に速く素因数分解を行う量子アルゴリズムを見つけた[3]ことで、一気に量子コンピュータに注目が集まることになった。
素因数分解の他にも、量子コンピュータの方が現状の古典コンピュータよりも高速であると証明されている問題がいくつかある。例えば、整理されていないデータから目的のデータを探し出す探索問題 (8-2. グローバーのアルゴリズム)や、連立一次方程式の解を求める問題 (7-2. Harrow-Hassidim-Lloyd (HHL) アルゴリズム) などである。これらの問題は、流体解析・電磁気解析・機械学習など、現代社会を支える様々な科学技術計算に活用できる。さらに、万物は元をたどれば量子力学に従っているため、ファインマンやドイチュが考えたように、究極の自然現象シミュレーターとして量子コンピュータを活用し、物質設計や素材開発を行うことも考案されている (第6章 量子化学計算)。
このように、量子コンピュータによって世の中の全ての計算が高速化される訳でないものの、現代社会に与えるインパクトは計り知れないものがある。
量子誤り訂正¶
ここまで述べたのは、量子コンピュータの理論的な話である。理論的に速く計算できると証明できても、応用するには実際に計算を行うハードウェアが必要になる。量子コンピュータのハードウェアを製作する研究は世界中で広く行われているが、課題はまだまだ多いのが現状である。もっとも大きな課題の一つが、ノイズである。量子ビットは古典ビットと比べて磁場や温度揺らぎなどの外部ノイズを非常に受けやすく、保持していた情報をすぐに失ってしまう。2019年現在でも、数個〜数十個程度の量子ビットを連結し、より安定に長く動作させる方法を探っているような段階である。
そのようなノイズの問題を克服するために研究されているのが、量子誤り訂正の技術である (第9章 量子誤り訂正)。量子誤り訂正は、計算途中に生じた量子ビットの誤り(エラー)を検知し、本来の状態に訂正する技術で、理論的には様々な手法が提案されている(なお、我々が普段使っている古典コンピュータにも古典ビットの誤り訂正機能が搭載されている。この機能のおかけで、我々はPC内のデータが突然無くなることを気にせずに暮らせるのである)。しかし、量子誤り訂正は単なる量子ビットの作製・動作実現よりもはるかに技術的難易度が高く、誤り訂正機能を持った量子ビットを集積化可能な形で作るには少なくともあと10年は必要であると言われている。
そして、前項であげた量子コンピュータの様々な応用を実用的な規模で実行するには、誤り訂正機能を持った量子ビットが1000個単位で必要となることから、真の量子コンピュータの現実的応用は数十年先になると考えられている。
NISQ (Noisy Intermidiate-Scale Quantum) デバイスの時代¶
では我々が量子コンピュータの恩恵を受けるには、あと何十年も待たないといけないのだろうか。「そんなことで手をこまねいているわけにはいかない!」ということで、科学者たちは様々な方法で量子コンピュータの有用性を示そうと模索している。その中で現在最も注目されており、世界中で研究が進められているのがNoisy Intermediate-Scale Quantum (NISQ) デバイスという量子コンピュータである。
NISQデバイスの定義は「ノイズを含む(誤り訂正機能を持たない)、中規模(〜数百qubit)な量子デバイス」であり、これであれば数年以内の実用化が可能であると考えられている。NISQデバイスは、誤り訂正機能がないので限られた量子アルゴリズムしか実行できないものの、量子化学計算や機械学習といった領域で、現在の古典コンピュータを凌駕する性能を発揮すると予想されている(第4章・第5章・第6章参照)。
NISQデバイスまで含めれば、量子コンピュータが応用され始めるのはそう遠くない未来なのである。このQuantum Native Dojoを通して、そうした量子コンピュータ応用の最先端に触れるための知識を皆様に身につけていただければ幸いである。
参考文献¶
Feybmann, “Simulating physics with computers”, International Journal of Theoretical Physics 21, 467 (pdfリンク)
Deutsch, “Quantum theory, the Church-Turing principle and the universal quantum computer” Proceedings of the Royal Society of London A 400, 97 (1985) (pdfリンク)
Shor, “Polynomial-Time Algorithms for Prime Factorization and Discrete Logarithms on a Quantum Computer”, IAM J.Sci.Statist.Comput. 26 (1997) 1484 (pdfリンク)
量子コンピュータの基礎から応用までに関するスライド¶
量子コンピュータの動作原理や応用アプリケーション、政府・企業の動きに関したさらに詳しい解説は、 Quantum Summit 2019の講演をまとめたスライドをご覧ください。
コラム:量子ビット・量子ゲート操作を物理的にどう実現するか¶
実際の量子コンピュータを構成する量子ビットはいったいどのように作られ、量子ゲート操作はどのように実行されているのだろうか。
量子ビットを実現する方法は1995年頃から複数の有望な方式(物理系)が提案されており、超伝導回路方式・イオントラップ方式・光方式などがある。各方式によって、現在実現できている量子ビット数や量子ビットの寿命(コヒーレンス時間)、エラー率等に違いがあり、世界各国で研究が盛んに進められている。
数々の量子ビット実現方式の中で、最も広く知られている方式は超伝導回路を用いた超伝導量子ビットである。これは1999年に当時NECに所属していた中村泰信(現東京大学教授)・蔡兆申(現東京理科大学教授)らによって世界で初めて製作された量子ビットで、超伝導物質を用いたジョセフソン接合と呼ばれる微細な構造を作ることで量子ビットを実現している。量子ゲート操作は、マイクロ波(電磁波の一種)のパルスをターゲットの量子ビットに送ることで実現される。また、量子ビットの測定は測定用の超伝導回路を量子ビットにつけることで行われる。
超伝導回路方式は、GoogleやRigetti conmputingが数十量子ビットの素子の開発を発表するなど、2019年3月現在で最も有望な量子コンピュータ実現方式であると言える。
量子ビットの実現方法について、より深く知りたい場合には以下を参考にされたい:
Qmedia 量子コンピュータを実現するハードウェア
レビュー論文:T. D. Ladd et al. , “Quantum Computing”, Nature, 464, 45 (2010). https://arxiv.org/abs/1009.2267
Nielsen-Chuang 第7章
Quantum computers: physical realization
第1章 量子情報の基礎¶
第1章では、量子コンピュータの仕組みを完全に理解するために必要な量子力学をマスターし、量子コンピュータの状態や操作をどのように記述すればいいのかを学ぶ。特に、テンソル積という考え方が量子コンピュータを深く理解するうえで必須である。テンソル積の計算は少し複雑であるが、Pythonの数式処理ライブラリSymPyでは、テンソル積を含めた量子力学の代数処理がサポートされているので、SymPyを用いた計算結果を見ながら量子力学と量子コンピュータの基礎の理解をすすめていこう。
1-1. 量子ビット¶
古典コンピュータ(量子コンピュータではない既存のコンピュータのこと)内部では情報は 0 と 1 の2つの状態で表現されている。例えば、スイッチのオン・オフの状態や、電荷がたまった状態とそうでない状態、電圧の高・低などでその2状態を表現している。一方、量子力学では異なる2つの状態の重ね合わせ状態というのが許されているので、量子の世界の情報の最小単位である“量子”ビットは \(\alpha\)と\(\beta\)という二つの複素数を用いた複素ベクトルを用いて
のようにその量子状態が記述される。
\(\alpha\)や\(\beta\)はどの程度の重みで0状態と1状態が重ね合わさっているかを表しており、複素確率振幅と呼ばれる。 \(\alpha\)や\(\beta\)が複素数になっているのは、量子の世界では0や1といった離散的な量も波の性質をもち干渉するためである。
古典ビットの0に対応する状態は
1に対応する状態は
となる。
列ベクトルを毎回書いているとスペースが無駄なので、ディラックのブラケット表記という簡略化した表記を導入する。これは列ベクトルである、という量子状態の型宣言のようなもので、この記号がついていると量子状態をあらわす複素ベクトルであることが一目でわかるようになっている。
この表記を用いると量子ビットは
と書かれる(スペースが省略できた!)。
複素確率振幅の意味¶
複素確率振幅はいったいどのような物理的実体に対応するだろうか。実は、量子力学では観測者(人間)は直接複素確率振幅にはアクセスすることができず、測定という操作をした時に初めて0か1かが確率的にきまる。測定結果の確率分布に影響するのが複素確率振幅である。測定結果が0になる確率\(p_0\), 1になる確率\(p_1\)は複素確率振幅の絶対値の2乗で表される:
確率の和が1になるように、規格化条件 \(|\alpha |^2 + |\beta |^2 =1\)を課す。
測定を行うと、量子状態は測定結果に対応する状態に変化する。具体的には、測定結果が0の場合は\(|0\rangle\)、1の場合は\(|1\rangle\)に変化する。この測定を、正規直交基底\(|0\rangle\), \(|1\rangle\)での射影測定と呼ぶ。\(|0\rangle\), \(|1\rangle\)以外の正規直交基底での射影測定や、より一般の測定もあるが、ここでは扱わない。
まとめると、
量子状態は、大きさが1に規格化された複素ベクトルによって記述される。
各成分の絶対値の2乗が、測定をしたときにその成分に対応する状態を得る確率である。
測定後の量子状態は、測定結果に応じて\(|0\rangle\)または\(|1\rangle\)となる。
したがって、
は、確実に0や1が得られる古典的な状態に対応し、
は0と1が同じ重みで重ね合わさった状態であり、測定をすると0と1が確率1/2で完全にランダムに得られる。
複素確率振幅は複素数なので、
といった状態も許されている。より一般に、
なども許されている。この状態の場合、状態0に対する確率振幅 (\(1/\sqrt{2}\)) が正の実数であるのに対して、状態1に対する確率振幅 (\(e^{i\phi}/\sqrt{2}\)) は、複素平面上で\(\phi\)回転している。このような、重ね合わせ状態における確率振幅間の相対的な偏角のことを位相と呼び、量子力学全般において重要な役割を果たす。
(詳細は Nielsen-Chuang の 1.2 Quantum bits
を参照)
SymPyを用いて量子ビットを表示してみる¶
SymPyでは、量子状態を扱うことができる。初期化された量子ビットを準備する場合は Qubit()
関数を用いる。
[1]:
from IPython.display import Image, display_png
from sympy import *
from sympy.physics.quantum import *
from sympy.physics.quantum.qubit import Qubit,QubitBra
init_printing() # ベクトルや行列を綺麗に表示するため
[3]:
psi = Qubit('0')
[4]:
psi #ブラケット表示
[4]:
[5]:
represent(psi) #列ベクトル表示
[5]:
SymPyでは文字をシンボルとして扱うことができるので、一般的な量子ビットも簡単に書ける。
[6]:
a, b = symbols('alpha, beta') #a, bをシンボルとして、alpha, betaとして表示
ket0 = Qubit('0')
ket1 = Qubit('1')
psi = a * ket0 + b* ket1
psi # 状態をそのまま書くとケットで表示してくれる
[6]:
[7]:
represent(psi)
[7]:
もちろん具体的な数値を代入することもできる。
[8]:
psi.subs([([a,1/sqrt(2)]),([b,1/sqrt(2)])]) # alpha, betaに具体的な数字を代入
[8]:
1-2. 量子ビットに対する基本演算¶
量子ビットについて理解が深まったところで、次に量子ビットに対する演算がどのように表されるかについて見ていこう。 これには、量子力学の性質が深く関わっている。
- 線型性:詳しくは第4章で学ぶのだが、量子力学では状態(量子ビット)の時間変化はつねに(状態の重ね合わせに対して)線型になっている。つまり、量子コンピュータ上で許された操作は状態ベクトルに対する線型変換ということになる 。1つの量子ビットの量子状態は規格化された2次元複素ベクトルとして表現されるのだったから、 1つの量子ビットに対する操作=線型演算は\(2 \times 2\)の複素行列によって表現される。
- ユニタリ性:さらに、確率の合計は常に1であるという規格化条件から、量子操作に表す線型演算(量子演算)に対してさらなる制限を導くことができる。まず、各測定結果を得る確率は複素確率振幅の絶対値の2乗で与えられるので、その合計は状態ベクトルの(自分自身との)内積と一致することに注目する:
と書ける。この状態についても上記の規格化条件が成り立つ必要があるので、
が要請される。(ダガー \(^\dagger\) は行列の転置と複素共役を両方適用したものを表し、エルミート共役という)
この関係式が任意の\(\alpha\), \(\beta\)について成り立つ必要があるので、量子演算\(U\)は以下の条件を満たすユニタリー行列に対応する:
すなわち、量子ビットに対する操作は、ユニタリー行列で表されるのである。
ここで、用語を整理しておく。量子力学では、状態ベクトルに対する線型変換のことを演算子 (operator) と呼ぶ。単に演算子という場合は、ユニタリーとは限らない任意の線型変換を指す。それに対して、上記のユニタリー性を満たす線型変換のことを量子演算 (quantum gate) と呼ぶ。量子演算は、量子状態に対する演算子のうち、(少なくとも理論的には)物理的に実現可能なものと考えることができる。
1量子ビット演算の例:パウリ演算子¶
1つの量子ビットに作用する基本的な量子演算としてパウリ演算子を導入する。 これは量子コンピュータを学んでいく上で最も重要な演算子であるので、定義を体に染み込ませておこう。
各演算子のイメージを説明する。
まず、\(I\)は恒等演算子で、要するに「何もしない」ことを表す。
\(X\)は古典ビットの反転(NOT)に対応し
のように作用する。(※ブラケット表記を用いた。下記コラムも参照。)
\(Z\)演算子は\(|0\rangle\)と\(|1\rangle\)の位相を反転させる操作で、
と作用する。 これは\(|0\rangle\)と\(|1\rangle\)の重ね合わせの「位相」という情報を保持できる量子コンピュータ特有の演算である。 例えば、
となる。
\(Y\)演算子は\(Y=iXZ\)と書け、 位相の反転とビットの反転を組み合わせたもの(全体にかかる複素数\(i\)を除いて)であると考えることができる。
(詳細は Nielsen-Chuang の 1.3.1 Single qubit gates
を参照)
SymPyを用いた一量子ビット演算¶
SymPyではよく使う基本演算はあらかじめ定義されている。
[1]:
from IPython.display import Image, display_png
from sympy import *
from sympy.physics.quantum import *
from sympy.physics.quantum.qubit import Qubit,QubitBra
init_printing() # ベクトルや行列を綺麗に表示するため
[3]:
from sympy.physics.quantum.gate import X,Y,Z,H,S,T,CNOT,SWAP, CPHASE
演算子は何番目の量子ビットに作用するか、 というのを指定して X(0)
のように定義する。 また、これを行列表示するときには、いくつの量子ビットの空間で表現するか nqubits
というのを指定する必要がある。 まだ、量子ビットは1つしかいないので、 X(0)
、nqubits=1
としておこう。
[4]:
X(0)
[4]:
[5]:
represent(X(0),nqubits=1) # パウリX
[5]:
同様に、Y
, Z
なども利用することができる。それに加え、アダマール演算 H
や、位相演算 S
、そして\(\pi/4\)の位相演算 T
も利用することができる(これらもよく出てくる演算で、定義は各行列を見てほしい):
[6]:
represent(H(0),nqubits=1)
[6]:
[7]:
represent(S(0),nqubits=1)
[7]:
[8]:
represent(T(0),nqubits=1)
[8]:
これらの演算を状態に作用させるのは、
[4]:
ket0 = Qubit('0')
S(0)*Y(0)*X(0)*H(0)*ket0
[4]:
のように *
で書くことができる。実際に計算をする場合は qapply()
を利用する。
[16]:
qapply(S(0)*Y(0)*X(0)*H(0)*ket0)
[16]:
この列ベクトル表示が必要な場合は、
[17]:
represent(qapply(S(0)*Y(0)*X(0)*H(0)*ket0))
[17]:
のような感じで、SymPyは簡単な行列の計算はすべて自動的にやってくれる。
コラム:ブラケット記法¶
ここで一旦、量子力学でよく用いられるブラケット記法というものについて整理しておく。ブラケット記法に慣れると非常に簡単に見通しよく計算を行うことができる。
列ベクトルは
とかくのであった。これをケットと呼ぶ。同様に、行ベクトルは
とかき、これをブラと呼ぶ。\({\dagger}\)マークは転置と複素共役を取る操作で、列ベクトルを行ベクトルへと移す。
2つのベクトル、
があったとする。ブラとケットを抱き合わせると
となり、内積に対応する。行ベクトルと列ベクトルをそれぞれブラ・ケットと呼ぶのは、このように並べて内積を取ると「ブラケット」になるからである。
逆に、背中合わせにすると
となり、演算子となる。例えば、\(X\)演算子は
のように書ける。このことを覚えておけば
から
同様に、
も覚えておくと便利である。
1-3. 複数量子ビットの記述¶
ここまでは1量子ビットの状態とその操作(演算)の記述について学んできた。この章の締めくくりとして、\(n\)個の量子ビットがある場合の状態の記述について学んでいこう。テンソル積がたくさん出てきてややこしいが、コードをいじりながら身につけていってほしい。
\(n\)個の古典ビットの状態は\(n\)個の\(0,1\)の数字によって表現され、そのパターンの総数は\(2^n\)個ある。 量子力学では、これらすべてのパターンの重ね合わせ状態が許されているので、\(n\)個の量子ビットの状態\(|\psi \rangle\)はどのビット列がどのような重みで重ね合わせになっているかという\(2^n\)個の複素確率振幅で記述される:
でランダムに得られ、測定後の状態は\(|i_1 \dotsc i_n\rangle\)となる。
※ここで、複数量子ビットの順番と表記の関係について注意しておく。状態をケットで記述する際に、「1番目」の量子ビット、「2番目」の量子ビット、……の状態に対応する0と1を左から順番に並べて表記した。例えば\(|011\rangle\)と書けば、1番目の量子ビットが0、2番目の量子ビットが1、3番目の量子ビットが1である状態を表す。一方、例えば011を2進数の表記と見た場合、上位ビットが左、下位ビットが右となることに注意しよう。すなわち、一番左の0は最上位ビットであって\(2^2\)の位に対応し、真ん中の1は\(2^1\)の位、一番右の1は最下位ビットであって\(2^0=1\)の位に対応する。つまり、「\(i\)番目」の量子ビットは、\(n\)桁の2進数表記の\(n-i+1\)桁目に対応している。このことは、SymPyなどのパッケージで複数量子ビットを扱う際に気を付ける必要がある(下記「SymPyを用いた演算子のテンソル積」も参照)。
(詳細は Nielsen-Chuang の 1.2.1 Multiple qbits
を参照)
例:2量子ビットの場合¶
2量子ビットの場合は、 00, 01, 10, 11 の4通りの状態の重ね合わせをとりうるので、その状態は一般的に
とかける。
となり、状態は変化しない。一方、1つ目の量子ビットが\(|1\rangle\)の場合、\(c_{00} = c_{01} = 0\)なので、
となり、\(|10\rangle\)と\(|11\rangle\)の確率振幅が入れ替わる。すなわち、2つ目の量子ビットが反転している。
つまり、CNOT演算は1つ目の量子ビットをそのままに保ちつつ、
1つ目の量子ビットが\(|0\rangle\)の場合は、2つ目の量子ビットにも何もしない(恒等演算\(I\)が作用)
1つ目の量子ビットが\(|1\rangle\)の場合は、2つ目の量子ビットを反転させる(\(X\)が作用)
という効果を持つ。 そこで、1つ目の量子ビットを制御量子ビット、2つ目の量子ビットをターゲット量子ビットと呼ぶ。
このCNOT演算の作用は、\(\oplus\)を mod 2の足し算、つまり古典計算における排他的論理和(XOR)とすると、
とも書ける。よって、CNOT演算は古典計算でのXORを可逆にしたものとみなせる (ユニタリー行列は定義\(U^\dagger U = U U^\dagger = I\)より可逆であることに注意)。 例えば、1つ目の量子ビットを\(|0\rangle\)と\(|1\rangle\)の 重ね合わせ状態にし、2つ目の量子ビットを\(|0\rangle\)として
にCNOTを作用させると、
が得られ、2つ目の量子ビットがそのままである状態\(|00\rangle\)と反転された状態\(|11\rangle\)の重ね合わせになる。(記号\(\otimes\)については次節参照)
さらに、CNOT ゲートを組み合わせることで重要な2量子ビットゲートであるSWAP ゲートを作ることができる。
を\(i\)番目の量子ビットを制御、\(j\)番目の量子ビットをターゲットとするCNOT ゲートとして、
のように書ける。これは1 番目の量子ビットと2 番目の量子ビットが交換するゲートであることが分かる。
このことは、上記のmod 2の足し算\(\oplus\)を使った表記で簡単に確かめることができる。3つのCNOTゲート\(\Lambda(X)_{1,2} \Lambda(X)_{2,1} \Lambda(X)_{1,2}\)の\(|ij\rangle\)への作用を1ステップずつ書くと、\(i \oplus (i \oplus j) = (i \oplus i) \oplus j = 0 \oplus j = j\)であることを使って、
となり、2つの量子ビットが交換されていることが分かる。
(詳細は Nielsen-Chuang の 1.3.2 Multiple qbit gates
を参照)
テンソル積の計算¶
手計算や解析計算で威力を発揮するのは、テンソル積(\(\otimes\))である。 これは、複数の量子ビットがある場合に、それをどのようにして、上で見た大きな一つのベクトルへと変換するのか?という計算のルールを与えてくれる。
量子力学の世界では、2つの量子系があってそれぞれの状態が\(|\psi \rangle\)と\(|\phi \rangle\)のとき、
とテンソル積 \(\otimes\) を用いて書く。このような複数の量子系からなる系のことを複合系と呼ぶ。例えば2量子ビット系は複合系である。
基本的にはテンソル積は、多項式と同じような計算ルールで計算してよい。 例えば、
のように計算する。列ベクトル表示すると、\(|00\rangle\), \(|01\rangle\), \(|10\rangle\), \(|11\rangle\)に対応する4次元ベクトル、
を得る計算になっている。
SymPyを用いたテンソル積の計算¶
[8]:
from IPython.display import Image, display_png
from sympy import *
from sympy.physics.quantum import *
from sympy.physics.quantum.qubit import Qubit,QubitBra
from sympy.physics.quantum.gate import X,Y,Z,H,S,T,CNOT,SWAP, CPHASE
init_printing() # ベクトルや行列を綺麗に表示するため
[3]:
a,b,c,d = symbols('alpha,beta,gamma,delta')
psi = a*Qubit('0')+b*Qubit('1')
phi = c*Qubit('0')+d*Qubit('1')
[4]:
TensorProduct(psi, phi) #テンソル積
[4]:
[5]:
represent(TensorProduct(psi, phi))
[5]:
さらに\(|\psi\rangle\)とのテンソル積をとると8次元のベクトルになる:
[6]:
represent(TensorProduct(psi,TensorProduct(psi, phi)))
[6]:
演算子のテンソル積¶
演算子についても何番目の量子ビットに作用するのか、というのをテンソル積をもちいて表現することができる。たとえば、1つめの量子ビットには\(A\)という演算子、2つめの演算子には\(B\)を作用させるという場合には、
としてテンソル積演算子が与えられる。 \(A\)と\(B\)をそれぞれ、2×2の行列とすると、\(A\otimes B\)は4×4の行列として
のように計算される。
テンソル積状態
に対する作用は、
となり、それぞれの部分系\(|\psi \rangle\)と\(|\phi\rangle\)に\(A\)と\(B\)が作用する。 足し算に対しては、多項式のように展開してそれぞれの項を作用させればよい。
テンソル積やテンソル積演算子は左右横並びで書いているが、本当は
のように縦に並べた方がその作用の仕方がわかりやすいのかもしれない。
例えば、CNOT演算を用いて作られるエンタングル状態は、
のようになる。
SymPyを用いた演算子のテンソル積¶
SymPyで演算子を使用する時は、何桁目の量子ビットに作用する演算子かを常に指定する。「何番目」ではなく2進数表記の「何桁目」であることに注意しよう。\(n\)量子ビットのうちの左から\(i\)番目の量子ビットを指定する場合、SymPyのコードではn-i
を指定する(0を基点とするインデックス)。
H(0)
は、1量子ビット空間で表示すると
[9]:
represent(H(0),nqubits=1)
[9]:
2量子ビット空間では\(H \otimes I\)に対応しており、その表示は
[10]:
represent(H(1),nqubits=2)
[10]:
CNOT演算は、
[11]:
represent(CNOT(1,0),nqubits=2)
[11]:
パウリ演算子のテンソル積\(X\otimes Y \otimes Z\)も、
[12]:
represent(X(2)*Y(1)*Z(0),nqubits=3)
[12]:
このようにして、上記のテンソル積のルールを実際にたしかめてみることができる。
複数の量子ビットの一部分だけを測定した場合¶
複数の量子ビットを全て測定した場合の測定結果の確率については既に説明した。複数の量子ビットのうち、一部だけを測定することもできる。その場合、測定結果の確率は、測定結果に対応する(部分系の)基底で射影したベクトルの長さの2乗になり、測定後の状態は射影されたベクトルを規格化したものになる。
具体的に見ていこう。以下の\(n\)量子ビットの状態を考える。
1番目の量子ビットを測定するとしよう。1つ目の量子ビットの状態空間の正規直交基底\(|0\rangle\), \(|1\rangle\)に対する射影演算子はそれぞれ\(|0\rangle\langle0|\), \(|1\rangle\langle1|\)と書ける。1番目の量子ビットを\(|0\rangle\)に射影し、他の量子ビットには何もしない演算子
を使って、測定値0が得られる確率は
である。ここで
なので、求める確率は
となり、測定後の状態は
となる。0と1を入れ替えれば、測定値1が得られる確率と測定後の状態が得られる。
ここで求めた\(p_0\), \(p_1\)の表式は、測定値\(i_1, \dotsc, i_n\)が得られる同時確率分布\(p_{i_1, \dotsc, i_n}\)から計算される\(i_1\)の周辺確率分布と一致することに注意しよう。実際、
である。
測定される量子ビットを増やし、最初の\(k\)個の量子ビットを測定する場合も同様に計算できる。測定結果\(i_1, \dotsc, i_k\)を得る確率は
であり、測定後の状態は
となる。(和をとるのは\(i_{k+1},\cdots,i_n\)だけであることに注意)
SymPyを使ってさらに具体的な例を見てみよう。H演算とCNOT演算を組み合わせて作られる次の状態を考える。
[13]:
psi = qapply(CNOT(1, 0)*H(1)*H(0)*Qubit('00'))
psi
[13]:
この状態の1つ目の量子ビットを測定して0になる確率は
で、測定後の状態は
である。
この結果をSymPyでも計算してみよう。SymPyには測定用の関数が数種類用意されていて、一部の量子ビットを測定した場合の確率と測定後の状態を計算するには、measure_partial
を用いればよい。測定する状態と、測定を行う量子ビットのインデックスを引数として渡すと、測定後の状態と測定の確率の組がリストとして出力される。1つめの量子ビットが0だった場合の量子状態と確率は[0]
要素を参照すればよい。
[14]:
from sympy.physics.quantum.qubit import measure_all, measure_partial
measured_state_and_probability = measure_partial(psi, (1,))
[28]:
measured_state_and_probability[0]
[28]:
上で手計算した結果と合っていることが分かる。測定結果が1だった場合も同様に計算できる。
[15]:
measured_state_and_probability[1]
[15]:
コラム:ユニバーサルゲートセットとは¶
\[\{H, T, {\rm CNOT} \}\]
【より詳しく知りたい人のための注】¶
◆ \(n\)量子ビットユニタリ演算の分解¶
まず、任意の\(n\)量子ビットユニタリ演算は、以下の手順を経て、いくつかの1量子ビットユニタリ演算とCNOTゲートに分解できる。
任意の\(n\)量子ビットユニタリ演算は、いくつかの2準位ユニタリ演算の積に分解できる。ここで2準位ユニタリ演算とは、例として3量子ビットの場合、\(2^3=8\)次元空間のうち2つの基底(e.g., \(\{|000\rangle, |111\rangle \}\))の張る2次元部分空間にのみ作用するユニタリ演算である
任意の2準位ユニタリ演算は、制御\(U\)ゲート(CNOTゲートのNOT部分を任意の1量子ビットユニタリ演算\(U\)に置き換えたもの)とToffoliゲート(CNOTゲートの制御量子ビットが2つになったもの)から構成できる
制御\(U\)ゲートとToffoliゲートは、どちらも1量子ビットユニタリ演算とCNOTゲートから構成できる
◆ 1量子ビットユニタリ演算の構成¶
さらに、任意の1量子ビットユニタリ演算は、\(\{H, T\}\)の2つで構成できる。
任意の1量子ビットユニタリ演算は、オイラーの回転角の法則から、回転ゲート\(\{R_X(\theta), R_Z(\theta)\}\)で(厳密に)実現可能である
実は、ブロッホ球上の任意の回転は、\(\{H, T\}\)のみを用いることで実現可能である(注1)。これはある軸に関する\(\pi\)の無理数倍の回転が\(\{H, T\}\)のみから実現できること(Solovay-Kitaevアルゴリズム)に起因する
(注1) ブロッホ球上の連続的な回転を、離散的な演算である\(\{H, T\}\)で実現できるか疑問に思われる読者もいるかもしれない。実際、厳密な意味で1量子ビットユニタリ演算を離散的なゲート操作で実現しようとすると、無限個のゲートが必要となる。しかし実際には厳密なユニタリ演算を実現する必要はなく、必要な計算精度\(\epsilon\)で任意のユニタリ演算を近似できれば十分である。ここでは、多項式個の\(\{H, T\}\)を用いることで、任意の1量子ビットユニタリ演算を十分良い精度で近似的に構成できることが、Solovay-Kitaevの定理 [3] により保証されている。
以上の議論により、3種のゲート\(\{H, T, {\rm CNOT} \}\)があれば、任意の\(n\)量子ビットユニタリ演算が実現できることがわかる。
4.5 Universal quantum gates
1-4. 回路図の基礎¶
この章では、量子ビットと演算をどのように表現するかを学んできた。最後に量子操作を記述する量子回路図についてまとめておこう。論理回路や電気回路の図にも一定のルールや記号があるように、量子回路についてもある程度標準化されている記法が存在する。
量子回路図は一般に以下のような形をしている。
主な構成要素は
量子ビット: 回路図の1つ1つの横線が、それぞれ1つの量子ビットに対応している。左端の\(|0\rangle\)は、それぞれの量子ビットが\(|0\rangle\)に初期化されていることを表す。
量子ゲート:回路図にある箱や縦線が、量子ゲートを表す。一般に、\(n\)量子ビットゲートは作用する\(n\)個の量子ビット(横線)にまたがる箱で表される。それ以外に、特殊な書き方で表すゲートがいくつかあり、例えば制御NOTゲート、SWAPゲート、制御\(U\)演算 \(\Lambda(U) = |0\rangle \langle 0| \otimes I + |1\rangle \langle 1| \otimes U\)は以下のように表される。
測定:右端にあるメーターのような記号で、量子ビットに対する測定を行うことを表す。
回路図の読み方において最も重要なのは、回路図は左から右に読むということである。つまり、楽譜のように左から順に量子ゲートや測定操作を行うことで計算が進んでいく。よって冒頭の回路は、
という状態を作った後、1番目の量子ビットを測定する操作を表す。
(詳細はNielsen-Chuang冒頭 Nomenclature and notation
の Frequently used quantum gates and circuit symbols
参照)
ここで、量子情報の基礎にまつわる2つの話題を紹介する。これらのコラムの内容は本編の理解に必須ではないので、難しくて分からない場合は適宜飛ばして次に進んでほしい。
コラム1:量子複製不可能 (No-Cloning) 定理¶
量子複製不可能 (No-Cloning) 定理
がある。証明¶
任意の状態 \(| \psi \rangle\) に対して \(U \left( | \psi \rangle \otimes | 0 \rangle \right) = | \psi \rangle \otimes | \psi \rangle\) を満たすユニタリー演算子が存在したとする。この時、任意の状態 \(| \psi \rangle, | \phi \rangle\) について
が成り立つ。左辺・右辺それぞれについて、上の式と下の式で内積をとると、\(U^\dagger U = I\) だから
となる。よって \(\langle \psi | \phi \rangle = 0, 1\)のいずれかとなるが、\(| \psi \rangle, | \phi \rangle\) は任意だったはずだから、これはおかしい。よって、\(U\)は存在しない。■
(参考:Nielsen-Chuang Box 12.1: The no-cloning theorem
)
コラム2:Bell (CHSH) 不等式¶
Bell の不等式とは物理系が局所実在性 (local realism) を満たすと仮定した時に、複数の観測系の相関の強さの上限を与える関係式である。 この不等式は John Bell によって 1964年に提唱され、量子力学を 隠れた変数
によって説明しようと試みた理論の検証に用いられた。
Bell の不等式を説明する前にまずはエンタングル状態の説明をする。
エンタングル状態¶
状態がエンタングルされている (エンタングル状態にある) とは、状態が複数の観測系の状態の直積で書く事ができないという事である。 具体例として Bell-状態
を考える。この 2つの粒子状態: \(\left|\psi \right> = (\left| 00 \right> + \left| 11 \right>) / \sqrt{2} = (\left| 0 \right>_A \left| 0 \right>_B + \left| 1 \right>_A \left| 1 \right>_B) / \sqrt{2}\) をそれぞれ Alice と Bob がシェアしている。 この状態は、次の相関関係を持つ:
もし Alice が自分の粒子を \(z\)-軸方向で観測して \(0\) の結果を得ると、Bob の粒子も瞬時に \(\left| 0 \right>\) の状態に収縮する
もし Alice が自分の粒子を \(z\)-軸方向で観測して \(1\) の結果を得ると、Bob の粒子も瞬時に \(\left| 1 \right>\) の状態に収縮する
もし Bob が自分の粒子を \(z\)-軸方向で観測して \(0\) の結果を得ると、Alice の粒子も瞬時に \(\left| 0 \right>\) の状態に収縮する
もし Bob が自分の粒子を \(z\)-軸方向で観測して \(1\) の結果を得ると、Alice の粒子も瞬時に \(\left| 1 \right>\) の状態に収縮する
これらの相関関係は、この量子状態を保っている限り Alice と Bob がいかに離れた場所にいても成立する。 この現象は、状態が Alice か Bob どちらかが粒子状態を観測するまで確定せず、どちらかが観測をすると 2人の粒子が同時にある状態に収縮すると解釈される。 量子力学の建設初期の 1935年、アインシュタインは有名な EPR 論文で、物理量は観測される前に確定されており (実在性
) 物理法則は 局所性
を持つとの立場から、この解釈を否定し量子力学を不完全な理論とした。 物理理論が実在性と局所性を同時に満たす
(局所実在性 (local realism)
と呼ばれる) と仮定すると、Bell 状態は 2つの粒子が Alice と Bob にシェアされる前にある隠れた変数 (hidden-variable
) を持ち、その隠れた変数に従って状態が観測されると考える事ができる。
実際に隠れた変数が存在するのか、あるいは局所実在性は成立しないのかという議論は長らく実証不可能であると考えられていた。 この哲学的な問いは、EPR論文からおよそ 30年後の 1964年、Bell 不等式が提唱されたことで、実験的に検証可能なものになった。 以下では、CHSH ゲーム
と呼ばれる例を用いて、非局所相関をどのように実証するか説明する。
CHSH ゲーム¶
具体例を見るために、次の例を考える: Alice と Bob は協力して、CHSH ゲーム
に挑戦する。このゲームでは、
Alice は、第三者の Charlie から送られるランダムビット \(x\) を受け取って、ビット \(a\) を返す
Bob は、 第三者の Charlie から送られるランダムビット \(y\) を受け取って、ビット \(b\) を返す
Alice と Bob が返したビットが \(a \oplus b = x \wedge y\) を満たせば 2人の勝ちとする
Alice と Bob はゲームで勝つ確率を最大化するための戦略を、ゲーム開始前に協議することができる
一度ゲームが開始されると Alice と Bob はそれぞれの実験室に篭るため、お互いにコミュニケーションを取ることはできない (Alice は Bob が受け取ったビット \(y\) を、Bob は Alice が受け取ったビット \(x\) を知ることはできない)
2人はそれぞれの実験室に (エンタングルメント状態にある) 粒子を持ち込む事ができる
\(x \wedge y\)を表で表わすと次のようになる。
\(x\) |
\(y\) |
\(x\) AND \(y\) |
---|---|---|
0 |
0 |
0 |
0 |
1 |
0 |
1 |
0 |
0 |
1 |
1 |
1 |
ランダムビットが \(0\) の時のAliceとBobのアウトプットをそれぞれ \(a_0\) と \(b_0\)、 ランダムビットが \(1\) の時のアウトプットをそれぞれ \(a_1\) と \(b_1\) とすると 2人が全ての場合で勝つためには
を全て満たすようにアウトプットビットを選ぶ必要があるが、これが不可能である事は 4つの式の両辺を mod 2 で足せば分かる。 では2人がゲームで勝つ確率の最大値はいくらであろうか?
自然が局所実在性に従うと仮定すると、Alice と Bob がこのゲームに勝つ確率は如何なる戦略を用いても 3/4 (75%) 以下である事が次のように示される。 但しここで局所実在性とは \(a = a(x, w)\), \(b = b(y, w)\) のように、Alice と Bob が予め決められた共通の変数 \(w\) とそれぞれの入力値 \(x\)、\(y\) に従って値を返すという意味である。
CHSH 不等式¶
エンタングルした粒子について、Alice がそれぞれ \(a\) か \(a^{\prime}\) のいずれかの値を観測するとする。 同様に、Bob も、\(b\) か \(b^{\prime}\) のいずれかの値を観測するとする。 ただし、これらは \(\{ \pm 1 \}\) のいずれか値をとり、隠れた変数によって決定されるものとする。 ここで局所実在性の仮定より、\(a\) と \(a^{\prime}\)、\(b\) と \(b^{\prime}\) は同時に決定されるとする。 \(a = 1\) ならば \(a^{\prime} = -1\)、\(a = -1\) ならば \(a^{\prime} = 1\) となる。 \(b\)、 \(b^{\prime}\) も同様である。 この時、 \(a + a^{\prime} = 0\)、\(a - a^{\prime} = \pm 2\) または \(a - a^{\prime} = 0\)、\(a + a^{\prime} = \pm 2\) であるから、各実現値について
が成り立つ。これより、平均値をとると
が成り立つ。この関係式は Clauser-Horne-Shimony-Holt (CHSH) 不等式
と呼ばれる。 CHSH 不等式は 2つの古典系の間の相関の強さの上限を与える。
この不等式から、自然が局所実在性に従う時に Alice と Bob が CHSH-ゲームで勝つ確率の上限を導こう。 上記の CHSH 不等式は \(a, a^{\prime} \in \{ \pm 1 \}\) という変数についての不等式だが、ビット列 \(a_{0, 1} \in \{ 0, 1 \}\) とは \(a = (-1)^{a_{0}}\)、\(a^{\prime} = (-1)^{a_{1}}\) という式で対応させられる。
入力ビット (\(x\), \(y\)) が与えられた時に 2人が勝つ (\(a_x \oplus b_y = x \wedge y\) を満たす) 確率をそれぞれ \(p_{xy}\) とすると
入力ビットが一様にランダムに与えられたとすると、2人がゲームに勝つ期待値はCHSH 不等式を用いて
と求められる。Alice と Bob が取れる最善の戦略は、それぞれの入力値に関わらず \(0\) を出力する事 (このとき勝率75%) である事がわかる。 以上より、局所実在性に従い隠れた変数 \(w\) をシェアした場合、2人がゲームに勝つ確率は 75% 以下である事がわかった。
Cirel’son (Tsirelson) 不等式¶
実は、もし 2人が隠れた変数ではなくエンタングル状態の粒子をシェアした場合、この確率の上限はおよそ 85.3%に増加する事が知られている。 Cirel'son 不等式
を用いてこれを説明する。
Cirel’son (Tsirelson) 不等式は 2つの量子系の間の相関の強さの上限を与える不等式:、
で、入力ビットが一様にランダムに与えられた時, 2人がゲームに勝つ期待値は:
と求められる。 Alice と Bob が Bell-状態の粒子 \((\left| 0 \right>_A \left| 0 \right>_B + \left| 1 \right>_A \left| 1 \right>_B) / \sqrt{2}\) をシェアする時に取れる最善の戦略は、
\(x = 0\) の時 Alice は \(Z\) 基底で \(\left| \psi \right>_A\) を観測する。 \(\left| 0 \right>_A\) を観測すれば \(a=0\)、\(\left| 1 \right>_A\) を観測すれば \(a=1\) を出力する
\(x = 1\) の時 Alice は \(X\) 基底で \(\left| \psi \right>_A\) を観測する。 \(\left| + \right>_A\) を観測すれば \(a=0\)、\(\left| - \right>_A\) を観測すれば \(a=1\) を出力する
\(y = 0\) の時 Bob は \(RX(\pi/8)\) 基底で \(\left| \psi \right>_B\) を観測する。 \(\left| H^+ \right>_B\) を観測すれば \(b=0\)、\(\left| H^+_{\perp} \right>_B\) を観測すれば \(b=1\) を出力する
\(y = 1\) の時 Bob は \(RX(-\pi/8)\) 基底で \(\left| \psi \right>_B\) を観測する。 \(\left| H^- \right>_B\) を観測すれば \(b=0\)、\(\left| H^-_{\perp} \right>_B\) を観測すれば \(b=1\) を出力する
である。だだし、
\(\left| H^+ \right>_B = \cos(\pi/8) \left| 0 \right>_B + \sin(\pi/8) \left| 1 \right>_B\)
\(\left| H^+_{\perp} \right>_B = \sin(\pi/8) \left| 0 \right>_B - \cos(\pi/8) \left| 1 \right>_B\)
\(\left| H^- \right>_B = \cos(- \pi/8) \left| 0 \right>_B + \sin(- \pi/8) \left| 1 \right>_B\)
\(\left| H^-_{\perp} \right>_B = \sin(- \pi/8) \left| 0 \right>_B - \cos(- \pi/8) \left| 1 \right>_B\)
この手続きに従うと全ての入力ビット (\(x\), \(y\)) に対して \(p_{xy} = \cos^2(\pi/8)\) となるので、2人がゲームに勝つ期待値は
が得られる。
以上のCHSH ゲームの例で分かるように、エンタングル状態にある粒子を用いる事で Bell 不等式 (CHSH 不等式) を破る事が可能であると分かった。そして 1982年に Aspect らによる CHSH ゲームの実験によって、Bell 不等式が実際に破れている事が示された*。
*いわゆる“loophole”と呼ばれる、実験の不完全さを埋める研究は今でも世界各地で行われている。
(参考:Nielsen-Chuang 2.6 EPR and the Bell inequality
)
第2章 量子アルゴリズム入門¶
量子コンピュータは、量子力学的な重ね合わせによって、\(n\)個の量子ビットを用いて\(2^n\)個の状態を同時に処理できる。しかし、これだけでは「計算が速い」ということにはならない。なぜなら、計算終了後に結果を観測する際に、\(2^n\)個の状態の内どれか一つがランダムに得られるのみだからである。したがって、欲しい答えが高確率で得られるように設計された、量子コンピュータ専用のアルゴリズムが不可欠である。そのようなアルゴリズムを量子アルゴリズムと呼ぶ。量子アルゴリズムの有名な例として、Shorの素因数分解アルゴリズム、Groverの探索アルゴリズム等がある。
この章では、量子アルゴリズムの初歩を学んでいく。まず、量子アルゴリズムは、この数年で実現される量子コンピュータ=「NISQデバイス」で実行可能(と思われる)なアルゴリズムと、十年後以降に実現されるであろう誤り訂正ありの真の量子コンピュータでしか実行が難しいアルゴリズムとの2種類に大別されることを見る。次に、アダマールテストという最も簡単な量子アルゴリズムを学ぶ。その後、量子フーリエ変換、その発展である位相推定アルゴリズムという、量子コンピュータの応用を考える上で最も重要な量子アルゴリズムについて学ぶ。(ちなみに、量子フーリエ変換・位相推定アルゴリズム共に、実用的なサイズの問題をNISQマシンで実行することは難しいと考えられているので、long-termアルゴリズムに分類される)
2-1. NISQアルゴリズムとlong-termアルゴリズム¶
現在発明・発見されている量子アルゴリズムは、実現可能性の観点から2つのグループに大別できる。 一つはNISQアルゴリズム、もう一つはlong-termアルゴリズムである。(これらの単語は一般的ではないので、他の文献を見る際には注意すること。また、この2つの区別は絶対的なものではなく、解くべき問題の大きさや技術の進歩などによって移り変るものであることに留意されたい。)それらの代表例を表に示す。
(VQE = Variational Quantum Eigensolver (5-1節), QAOA = Quantum Approximate Optimization Algorithm (5-3節), QCL = Quantum Circuit Learning (5-2節), QFT = Quantum Fourier Transform (2-3節), QPE = Quantum Phase Estimation (2-4節、7-1節), HHL = Harrow-Hassidim-Lloyd algorithm (7-2節))
NISQ アルゴリズム¶
NISQとは¶
NISQアルゴリズムの概要¶
NISQデバイスでは、前述のようにノイズの影響が不可避である。このノイズは、計算が長ければ長くなるほど(アルゴリズムが複雑になればなるほど)蓄積していき、最終的には出力結果をデタラメにしてしまう。例えば、有名な量子アルゴリズムであるShorのアルゴリズムやGroverのアルゴリズムは回路が複雑(操作の回数が多い)であり、エラー耐性の低いNISQではパワー不足で実行することが難しい。
一方で、NISQを用いたとしても何か実用的に役立つ例を見つけられないか、ということで生み出されたのがNISQアルゴリズムである。上のような言い方をするとネガティブな印象を持たれるかもしれないが、化学反応のシミュレーションなどのタスクにおいて、NISQが古典コンピュータを上回る可能性が示唆されている(Qmedia記事量子コンピュータの現在とこれから)。NISQは、量子コンピュータの古典コンピュータに対する優位性が示される「量子スプレマシー」の担い手として注目を集めているのである。
一般に量子計算は、量子ビット数が大きく、量子演算の回数が多くなるほど、エラーの影響を受けやすくなる。そのため、NISQアルゴリズムは少数の量子ビットで、かつ浅い量子回路(少ない量子ゲート数)で行える必要がある。このような背景から、NISQアルゴリズムの研究においては、「量子-古典ハイブリッドアルゴリズム」というアプローチが主流となっている。これは、行いたい計算のすべてを量子コンピュータに任せるのではなく、量子コンピュータの得意な部分のみを量子計算機に任せ、残りは古典コンピュータで処理する、というものである。Quantum Native Dojoで扱うNISQアルゴリズムは、基本的にこの量子-古典ハイブリッドアルゴリズムのアプローチに基づいている。
Long-Termアルゴリズム¶
一方、long-termアルゴリズムは、多数の量子ビットが利用可能、かつ誤り訂正が可能という仮定のもとで初めて可能になるアルゴリズムである。もちろん、NISQで実行できるかどうかは解きたい問題のサイズや精度に依存するので、どのアルゴリズムがNISQで、どのアルゴリズムがlong-termであるということに深い意味はない。基本的に全ての量子アルゴリズムはlong-termアルゴリズムであり、その一部がNISQデバイスでも実行なアルゴリズムであると考えるのが良いかもしれない。
この章で学んでいくアルゴリズムは、long-termアルゴリズムのうち入門的なものである(上表の黄色の部分を参照)。後半の章では、近年盛んに研究が進んでいるNISQアルゴリズムの他、Groverのアルゴリズムといったより高度なlong-termアルゴリズムについて取り扱う。
より深く知るには:¶
Qmedia 量子コンピュータの現在とこれから https://www.qmedia.jp/nisq-era-john-preskill/
Quantum Algorithm Zoo http://quantumalgorithmzoo.org/
Quantum Algorithm Zoo 日本語訳 https://www.qmedia.jp/algebraic-number-theoretic-algorithms/
2-2. アダマールテスト¶
最も簡単な量子アルゴリズムとして、アダマールテストとよばれる以下のような量子回路(図1)を考える。つまり、第1ビットは\(|0\rangle\)に、第2ビット以降は状態\(|\psi\rangle\)に初期化されていて、まず第1ビットにアダマールゲートをかける。そして、全体に制御ユニタリ演算子\(\Lambda(U)\)(後述)を作用させ、再び第1ビットにアダマールゲートをかけて、最後にその第1ビットを測定する。
ここで制御ユニタリ演算子\(\Lambda(U)\)というのは、第1量子ビットが\(|0\rangle\)の場合にはなにもせず、\(|1\rangle\)の場合には\(U\)を作用させるユニタリ演算である。
つまり、1つ目の量子ビットが\(|0\rangle\)か\(|1\rangle\)かによって条件分岐して、「なにもしない」または「\(U\)を作用させる」という演算が実行される。従来のコンピュータでは条件分岐は同時に実行することができないが、量子コンピュータでは状態の重ね合わせを利用して、条件分岐を同時並列的に実行することができる。
このアダマールテストの動作について考えていく。最初は簡単のために、量子状態\(|\psi \rangle\)が ユニタリー演算(行列)\(U\)の固有値\(e^{i \lambda}\)の固有状態(固有ベクトル)である場合を考える:
1つ目の量子ビットにアダマール演算\(H\)を作用させることで
が得られる。 その後、制御\(U\)演算を作用させることによって、 固有値\(e^{i\lambda}\)が1つめの量子ビットの相対位相として得られる(このことを位相キックバックと呼ぶ):
最後に、1つ目の量子ビットに再度アダマール演算を行い
が得られる。 1つ目の量子ビットを測定すると測定結果\(m=0,1\)を得る確率は
となる。 \(|\psi \rangle\)、\(U\)、\(\langle \psi |\)は それぞれ\(2^n\)次元の列ベクトル、\(2^n \times 2^n\)行列、 \(2^n\)次元の行ベクトルなので、 このアダマールテストを古典コンピュータ上で愚直に計算すると 指数的に大きなメモリーの確保と演算回数が必要になる。 一方で、量子コンピューターでは、 確率分布\(p_m\)のもとで\(m\)がサンプルされる。 \(\cos \lambda\)を ある誤差\(\epsilon\)で推定したい場合は、 その逆数\(1/\epsilon\)の多項式回程度サンプルすればよいことになる。
同じ計算を、必ずしも固有ベクトルとは限らない、一般の入力に対して行うと、測定前の状態は、
となり、0もしくは1が得られる確率は、
となる。つまり、量子コンピュータ上でアダマールテストを実行すれば、その測定結果のサンプル平均をとることでベクトル\(|\psi \rangle\)でユニタリ行列\(U\)を挟んだ値を推定することができる。同じ値を古典コンピュータで求めようとした場合、量子ビット数\(n\)が大きくなるにつれベクトルや行列の次元は指数的に大きくなるので、指数的な時間を要する。
なお、1つ目の量子ビットを測定した後の、2つ目の量子ビットの状態は、測定結果\(m = 0, 1\)に応じて以下の状態になる(規格化因子は省略):
ここで、\(U\)が1量子ビットのユニタリ演算で、かつその固有値が\(\pm 1\)であるような場合を考える。固有値\(\pm 1\)に対応する固有ベクトル\(|u_1\rangle\), \(|u_{-1}\rangle\)を使って\(|\psi\rangle = c_1|u_1\rangle + c_{-1}|u_{-1}\rangle\)と展開し代入することで、測定後の状態\(|\psi_0\rangle\), \(|\psi_1\rangle\)はそれぞれ固有値\(\pm 1\)に対応する固有状態であることが分かる。固有値が\(\pm 1\)ではない場合も、アダマールテストの出力を入力として繰り返すと\(U\)の固有状態に状態が収束していく(興味のある人は、以下の例を参考にして試してもらいたい)。
SymPyでの実装¶
具体的な例として、\(U=H\)(アダマールゲート)の場合を考えてみよう。補助量子ビットを\(|0\rangle\)、アダマールテストの入力\(|\psi\rangle\)も\(|0\rangle\)とする。
[1]:
from sympy import *
from sympy.physics.quantum import *
from sympy.physics.quantum.qubit import Qubit,QubitBra
init_printing() # ベクトルや行列を綺麗に表示するため
from sympy.physics.quantum.gate import X,Y,Z,H,S,T,CNOT,SWAP,CPHASE,CGateS
[3]:
state = Qubit('00')
制御H演算は、CGateS()
を用いて
[4]:
ctrlH = CGateS(1,H(0))
represent(ctrlH,nqubits=2)
[4]:
と行列表示される。 測定前の状態は、
[5]:
H(1)*ctrlH*H(1)*state
[5]:
とかけるが、SymPyに計算させてみると
[6]:
qapply(H(1)*ctrlH*H(1)*state)
[6]:
となる。第1章で紹介したSymPyのmeasure_partial
関数を用いて、1つ目の量子ビットの測定結果が0だった場合の量子状態と確率を求めると、
[7]:
from sympy.physics.quantum.qubit import measure_all, measure_partial, measure_all_oneshot, measure_partial_oneshot
measured_state_and_probability_zero = measure_partial(qapply(H(1)*ctrlH*H(1)*state),(1,))[0]
simplify(measured_state_and_probability_zero)
[7]:
[8]:
measured_state_zero = measured_state_and_probability_zero[0]
simplify(qapply(H(0)*measured_state_zero))
[8]:
同様に1の測定結果を得た場合は、固有値−1の固有状態であることも確認できるので試してもらいたい。
[9]:
measured_state_one = measure_partial(qapply(H(1)*ctrlH*H(1)*state),(1,))[1][0]
simplify(qapply(H(0)*measured_state_one))
[9]:
ここで、NISQデバイスの応用先の一つと考えられている、量子乱数生成について紹介する(難易度:高)。
コラム:量子乱数生成¶
NISQアルゴリズムは近未来の量子技術で実現可能な応用として注目されている。一方、計算機に限らず一般的な情報処理までスコープを広げれば、より基本的な技術で実現可能な量子情報処理の応用がいくつか考えられる。特に「量子乱数生成」「量子センシング」「量子暗号」の三つは実現がNISQより容易な応用としてしばしば名前があがる。このコラムではこうした応用の一つである、量子乱数に関する研究について簡単に解説する。
乱数の応用はモンテカルロ法を用いた計算や暗号における乱数列の生成など多岐にわたる。こうした応用において、乱数に要請される性質は0,1の出現に関してバイアスがないこと、そして以前に生成した乱数との相関がないことである。こうした理想的な性質を持つ乱数を真正乱数と呼ぶ。しかし、厳密な真正乱数の生成は現実には困難であるため、通常の計算機においては現実的なコストで実現可能な物理乱数や疑似乱数が用いられる。
物理乱数とは一般に予測が困難な計算機の物理的な情報をモニタリングし、その情報からバイアスや相関を取り去ることで生成する乱数である。例えばCPUや通信機器のノイズ情報が用いられる。こうして得られた乱数はエントロピープールと呼ばれる箇所に蓄積され、物理乱数をプログラムが使用するたびに消費される。 物理乱数の利点は予測が困難なことである。物理乱数の欠点は、生成がデバイスのノイズに依存するため概して生成が遅いことと、予測は困難であっても理論上不可能ではないことである。例えばCPUの熱ノイズを遠くにいる攻撃者が正確に予測することは現実的には困難だが、攻撃者が計算機の近くにいる場合、計算機の本体から漏れ出る情報をモニタリングすることで、計算機の使用者が想定した以上の情報が外に漏れ出てしまうという可能性はある。また、さらに理論上の話をすれば、古典力学的に扱える物理系は初期状態とダイナミクスが定まれば理論上は完全に予測可能である。
もう一つの乱数は疑似乱数である。疑似乱数とは、ある秘密の「状態」を指定し、これを決定論的に遷移させることで、遷移に際して現在の「状態」を知らない人にとって乱数と区別がつかないと期待される数列を得ることである。例として、線形合同法、xorshift、メルセンヌツイスタのようなアルゴリズムが代表的である。 疑似乱数の特徴は物理乱数と相補的であり、利点は高速であること、欠点は現在の「状態」が外部に漏れると以降の数列がすべて予測されてしまうことである。このため、暗号鍵生成のような安全性が重視される場面では物理乱数が、物理シミュレーションのような高速性が重視される場面では初期状態として物理乱数を用いて以降は疑似乱数を用いることが多い。
量子乱数は物理系の持つ予測の難しさを利用するという意味では物理乱数の一種であるが、初期状態を攻撃者が把握していても原理的に未来に生成される乱数が予測が不可能であるという点で熱ノイズなどを用いた乱数とは異なる。量子乱数の最も基本的なアイデアは、量子状態を\(|+\rangle\)に初期化し、これを\(Z\)基底で測定するものである。
[ ]:
## Google Colaboratoryの場合・Qulacsがインストールされていないlocal環境の場合のみ実行してください
!pip install qulacs
[1]:
## qulacs (3-1節)というパッケージを使用します
from qulacs import QuantumState
from qulacs.gate import H
state = QuantumState(1)
state.set_zero_state()
gate = H(0)
gate.update_quantum_state(state)
count = 10000
result = np.array(state.sampling(count))
print("0: {}".format(np.sum(result==0)))
print("1: {}".format(np.sum(result==1)))
0: 4938
1: 5062
上記のコードと全く同じ操作を現実に行えば毎回50%の確率で0,1が出現する。(上記はシミュレータなので実際には物理乱数をシードにした疑似乱数である。)
\(|+\rangle\)を用意して\(Z\)基底で測定する操作は一見すると熱ノイズなどを扱う物理乱数とあまり変わらないように思えるが、乱数生成の背景を攻撃者が完全に知っていたとしてもどういった値が出るのか理論上予測できないという点で物理乱数とは大きく異なる。完ぺきに調整された信頼できる量子乱数源は、たとえ攻撃者がその初期状態を知っていたとしても真正乱数となる。
量子乱数生成の研究の目的は、上記のような理想的な状況をより現実に近い制約の下で実現することである。 量子乱数生成に関する研究の方向性は多岐にわたるが、このコラムでは代表的な二つの応用に向けた方向性について簡単に説明する。
安価な実験で量子乱数を実現する¶
イオンや超伝導素子は理想的な量子ビットを生成するためには良い物質だが、その準備には光源/マイクロ波源や冷却機構などの高度な技術と高価な装置が必要となる。 しかし現実的な問題として、乱数を生成するためだけに希釈冷凍機や真空チャンバを購入することは殆どの場合割に合わない。 このため、いかに安価な実験装置でも量子乱数を生成できるかというテーマは応用上重要である。 一見すると単なる物理乱数に見えるが、実は初期状態を攻撃者が完全に把握していたとしても予測が出来ない量子乱数となっている例として以下の2つがある。
光のショットノイズ¶
発振したレーザーから放出される微弱な光を高精度な光強度検出器で観測すると、ショットノイズと呼ばれるノイズが不可避に乗ることが知られている。 これは、発振したレーザーから放出されるパルスは光子数分布がポアソン分布と整合するコヒーレント状態と呼ばれる純粋状態となるために、その光子数の期待値は一定であっても、光子数の射影測定によって得られる光子の数がばらついてしまうことによる。このばらつきのことをショットノイズと呼ぶ。光の強度に対するショットノイズの大きさは光の強度が小さくなればなるほど大きくなる。
通常、こうしたばらつきは精密な撮像などでSN比向上のために排除するべきノイズとなる。一方、量子乱数の文脈では純粋状態に対する射影測定の結果生じる確率的な挙動であるから量子乱数として用いることができる。もちろん、検出される光子数は0,1で等確率ではないし2光子が検出されることもあるため、適切な補正が必要となる。 また、当然ながら測定器やレーザーの電流値揺らぎによるノイズは量子乱数とはみなせず、現実的なレーザー光の光子数には時間的な相関も存在する。このため、実際にこのシステムで量子乱数を構成するにはショットノイズ以外の古典的ノイズの要因が無視できる程度にシステムが安定している必要がある。
原子核のアルファ崩壊¶
原子核のアルファ粒子は原子核のポテンシャルに閉じ込められているが、トンネル効果のために一定の確率でポテンシャルを通り抜けて原子核外に放出される。トンネル効果によって波動関数がポテンシャル内側と外側に分かれるという操作はユニタリな操作であるため、これは透過率が小さなミラーに光子を当て、透過したか否かを射影測定することと現象としては等価である。 従って、適切なレートでアルファ粒子を放出する原子集団を準備し、検知器の検知タイミングに適切な補正を施すことで量子乱数源とすることができる。 なお原子核のこうしたふるまいのエネルギースケールは室温ノイズのエネルギースケールよりはるかに大きいため、上記の観測は室温でも行える。
信頼できない量子デバイスで量子乱数を生成する¶
もしチップのベンダに悪意があり、CPUの熱ノイズを取得する関数が、メルセンヌツイスタに従う疑似乱数を生成していたとしても、それを我々が検定することは困難である。従って、CPUの熱ノイズを物理乱数として用いるとき、自身がCPUの中身を検査する能力を持たない限りはCPUのベンダが用意した命令の挙動を暗黙に信頼していることになる。現実にはCPUやチップのこうした挙動は最終的に会社の信頼度によって担保されていると思われる。
同様に、もし「量子乱数生成装置」なるものが販売されていたとして、その中身がブラックボックスとなっているとき、この装置を使うことは量子乱数生成のベンダを信頼していることを前提とする。 量子乱数生成器のベンダが社会的信頼をまだ勝ち得ていない場合、デバイスの信頼性の欠如は物理乱数を利用すること以上のリスクとなるため、販売における大きな障害となる。
保証あり量子乱数生成(Certified Quantum Random Number Generation)の研究とは、何らかの現実的な仮定をおくことで信頼できないベンダによる量子乱数生成装置から信頼できる量子乱数を生成する手法を模索する分野である。この中で最も有名なものは、量子操作を行える離れた二つの信頼できないサーバ、信頼できる古典計算機、シードとなる少量の乱数を用いて、信頼できる量子乱数を生成する手法である[S. Pironio, et al., Nature 464, 1021 (2010)]。この手法ではベル不等式の破れを二つの敵対的な量子計算サーバによって検証することで、保証された量子乱数を得るといったものである。ベル不等式の上界に古典と量子で差が出る理由は、ベル不等式の量子演算子に対し整合的なjoint probaility distributionを定義できないことに依存する。従って、ベル不等式が破れている状況では、その測定結果に不可避なランダムネスが生じているということである。下記の手法はベル不等式におけるこの性質を利用している。
手順はかみ砕くと以下のようなものである。
二つの離れたサーバをA,B、乱数を生成するプレイヤーをPとする。この時、登場人物はA,P,Bの順番に一次元に並んでおり、それぞれ\(L\)だけ距離が離れているとする。光速を\(c\)とする。この時、以下の手順を行う。
二つの離れたサーバA,Bの間で、ベル状態を共有させる。
Pは\(O(\sqrt{n})\)ビットの乱数をエントロピープールから消費し、所定の手順でこの乱数から\(2n\)ビットの数列を作る。
Pは\(2n\)ビットのうち\(n\)ビットをAに、\(n\)ビットをBに送付する。
A,Bは指定されたビットに従った基底で事前に共有したベル状態の片割れを測定し、その結果をPに返却する。
Pは、3.の送信から4.の返却までの手続きに\(4L/c\)秒以上かかっていた場合、プロトコルを破棄し1.からやり直す。
PはAとBが誠実に測定したと仮定し、演算子の期待値からベル不等式の破れを検証する。ベル不等式を破っている度合いに応じて受け取った\(2n\)ビットを縮小し、その結果を乱数とする。
上記のプロトコルではA,Bが誠実である場合、Pは\(O(n)\)ビットを得る。 A,Bが不誠実であった場合、例えば与えられた指示を無視した基底でベル状態を測定したり、Pに隠れてA,B間で通信を行って共謀してPをだまそうとする。 しかし、A,B間の通信に上記の時間制約があり、かつベル不等式が確かに破れている場合、上記のようなズルではPが乱数を得ることは妨害できない。 従って、「Pは乱数だと思って乱数を続けるが、実はその乱数はAやBによって操作された数列である」という可能性をなくすことができる。
もちろん、返却までに時間がかかったり、ベル不等式を破らない返答をすることは可能である。この場合PはA,Bが不審であると考え、乱数生成のプロトコル自体を破棄することになる。あくまで「Pが乱数だと思っているものが、実は乱数ではなかった」というケースを排除するプロトコルであって、悪意あるサーバA,Bの妨害を防ぐことができるものではない。
また、このプロトコルを開始するには\(O(\sqrt{n})\)の量子乱数が開始に必要となる。このため、このプロトコルは純粋な意味での量子乱数生成ではなく、正しくは量子乱数増幅である点にも注意が必要である。
2-3. 量子フーリエ変換¶
The quantum Fourier transform
)※なお、最後のコラムでも多少述べるが、回路が少し複雑である・入力状態を用意することが難しいといった理由から、いわゆるNISQデバイスでの量子フーリエ変換の実行は難しいと考えられている。
定義¶
まず、\(2^n\)成分の配列 \(\{x_j\}\) に対して\((j=0,\cdots,2^n-1)\)、その離散フーリエ変換である配列\(\{ y_k \}\)を
で定義する\((k=0, \cdots 2^n-1)\)。配列 \(\{x_j\}\) は\(\sum_{j=0}^{2^n-1} |x_j|^2 = 1\) と規格化されているものとする。
量子フーリエ変換アルゴリズムは、入力の量子状態
を、
となるように変換する量子アルゴリズムである。ここで、\(|i \rangle\)は、整数\(i\)の二進数での表示\(i_1 \cdots i_n\) (\(i_m = 0,1\))に対応する量子状態\(|i_1 \cdots i_n \rangle\)の略記である。(例えば、\(|2 \rangle = |0\cdots0 10 \rangle, |7 \rangle = |0\cdots0111 \rangle\)となる)
ここで、式(1)を(2)に代入してみると、
となる。よって、量子フーリエ変換では、
となる。ここで、
は2進小数であり、\(e^{i 2\pi j/2^{-l} } = e^{i 2\pi j_1 \cdots j_l . j_{l-1}\cdots j_n } = e^{i 2\pi 0. j_{l-1}\cdots j_n }\)となることを用いた。(\(e^{i2\pi}=1\)なので、整数部分は関係ない)
まとめると、量子フーリエ変換では、
という変換ができればよい。
回路の構成¶
と、角度 \(2\pi/2^l\) の一般位相ゲート
を多用する。
まず、状態\(\left( |0\rangle + e^{i 2\pi 0.j_1j_2\cdots j_n} |1\rangle \right)\)の部分をつくる。1番目の量子ビット\(|j_1\rangle\)にアダマールゲートをかけると
\[|j_1 \cdots j_n \rangle \to \frac{1}{\sqrt{2}} \left( |0\rangle + e^{i2\pi 0.j_1} |1\rangle \right) |j_2 \cdots j_n \rangle\]となるが、ここで、2番目のビット\(|j_2\rangle\)を制御ビットとする一般位相ゲート\(R_2\)を1番目の量子ビットにかけると、\(j_2=0\)の時は何もせず、\(j_2=1\)の時のみ1番目の量子ビットの\(|1\rangle\)部分に位相 \(2\pi/2^2 = 0.01\)(二進小数)がつくから、
\[\frac{1}{\sqrt{2}} \left( |0\rangle + e^{i2\pi 0.j_1} |1\rangle \right) |j_2 \cdots j_n \rangle \to \frac{1}{\sqrt{2}} \left( |0\rangle + e^{i2\pi 0.j_1j_2} |1\rangle \right) |j_2 \cdots j_n \rangle\]となる。以下、\(l\)番目の量子ビット\(|j_l\rangle\)を制御ビットとする一般位相ゲート\(R_l\)をかければ(\(l=3,\cdots n\))、最終的に
\[\frac{1}{\sqrt{2}} \left( |0\rangle + e^{i2\pi 0.j_1\cdots j_n} |1\rangle \right) |j_2 \cdots j_n \rangle\]が得られる。
次に、状態\(\left( |0\rangle + e^{i2\pi 0.j_2\cdots j_n} |1\rangle\right)\)の部分をつくる。先ほどと同様に、2番目のビット\(|j_2\rangle\)にアダマールゲートをかければ
\[\frac{1}{\sqrt{2}} \left( |0\rangle + e^{i2\pi 0.j_1\cdots j_n}|1\rangle \right) \frac{1}{\sqrt{2}} \left( |0\rangle + e^{i2\pi 0.j_2} |1\rangle \right) |j_3 \cdots j_n \rangle\]ができる。再び、3番目の量子ビットを制御ビット\(|j_3\rangle\)とする位相ゲート\(R_2\)をかければ
\[\frac{1}{\sqrt{2}} \left( |0\rangle + e^{i2\pi 0.j_1\cdots j_n}|1\rangle \right) \frac{1}{\sqrt{2}} \left( |0\rangle + e^{i2\pi 0.j_2j_3}|1\rangle \right) |j_3 \cdots j_n \rangle\]となり、これを繰り返して
\[\frac{1}{\sqrt{2}} \left( |0\rangle + e^{i2\pi 0.j_1\cdots j_n}|1\rangle \right) \frac{1}{\sqrt{2}} \left( |0\rangle + e^{i2\pi 0.j_2\cdots j_n}|1\rangle \right) |j_3 \cdots j_n \rangle\]を得る。
1,2と同様の手順で、\(l\)番目の量子ビット\(|j_l\rangle\)にアダマールゲート・制御位相ゲート\(R_l, R_{l+1},\cdots\)をかけていく(\(l=3,\cdots,n\))。すると最終的に
\[|j_1 \cdots j_n \rangle \to \left( \frac{|0\rangle + e^{i 2\pi 0.j_1\cdots j_n} |1 \rangle}{\sqrt{2}} \right) \otimes \left( \frac{|0\rangle + e^{i 2\pi 0.j_2\cdots j_n} |1 \rangle}{\sqrt{2}} \right) \otimes \cdots \otimes \left( \frac{|0\rangle + e^{i 2\pi 0.j_n} |1 \rangle}{\sqrt{2}} \right)\]が得られるので、最後にビットの順番をSWAPゲートで反転させてあげれば、量子フーリエ変換を実行する回路が構成できたことになる(式(\(*\))とはビットの順番が逆になっていることに注意)。SWAPを除いた部分を回路図で書くと以下のようである。
SymPyを用いた実装¶
量子フーリエ変換への理解を深めるために、SymPyを用いて\(n=3\)の場合の回路を実装してみよう。
[1]:
from sympy import *
from sympy.physics.quantum import *
from sympy.physics.quantum.qubit import Qubit,QubitBra
init_printing() # ベクトルや行列を綺麗に表示するため
from sympy.physics.quantum.gate import X,Y,Z,H,S,T,CNOT,SWAP,CPHASE,CGateS
まず、フーリエ変換される入力\(|x\rangle\)として、
という全ての状態の重ね合わせ状態を考える(\(x_0 = \cdots = x_7 = 1/\sqrt{8}\))。
[3]:
input = 1/sqrt(8) *( Qubit("000")+Qubit("001")+Qubit("010")+Qubit("011")+Qubit("100")+Qubit("101")+Qubit("110")+Qubit("111"))
input
[3]:
この状態に対応する配列をnumpyでフーリエ変換すると
[4]:
import numpy as np
input_np_array = 1/np.sqrt(8)*np.ones(8)
print( input_np_array ) ## 入力
print( np.fft.ifft(input_np_array) * np.sqrt(8) ) ## 出力. ここでのフーリエ変換の定義とnumpyのifftの定義を合わせるため、sqrt(2^3)をかける
[0.35355339 0.35355339 0.35355339 0.35355339 0.35355339 0.35355339
0.35355339 0.35355339]
[1.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j]
となり、フーリエ変換すると \(y_0=1,y_1=\cdots=y_7=0\) という簡単な配列になることが分かる。これを量子フーリエ変換で確かめてみよう。
まず、\(R_1, R_2, R_3\)ゲートはそれぞれ\(Z, S, T\)ゲートに等しいことに注意する(\(e^{i\pi}=-1, e^{i\pi/2}=i\))。
[5]:
represent(Z(0),nqubits=1), represent(S(0),nqubits=1), represent(T(0),nqubits=1)
[5]:
[6]:
QFT_gate = H(2)
QFT_gate = CGateS(1, S(2)) * QFT_gate
QFT_gate = CGateS(0, T(2)) * QFT_gate
2番目(SymPyでは1番目)の量子ビットにもアダマールゲートと制御\(R_2\)演算を施す。
[7]:
QFT_gate = H(1) * QFT_gate
QFT_gate = CGateS(0, S(1)) * QFT_gate
3番目(SymPyでは0番目)の量子ビットにはアダマールゲートのみをかければ良い。
[8]:
QFT_gate = H(0) * QFT_gate
最後に、ビットの順番を合わせるためにSWAPゲートをかける。
[9]:
QFT_gate = SWAP(0, 2) * QFT_gate
これで\(n=3\)の時の量子フーリエ変換の回路を構成できた。回路自体はやや複雑である。
[10]:
QFT_gate
[10]:
入力ベクトル\(|x\rangle\) にこの回路を作用させると、以下のようになり、正しくフーリエ変換された状態が出力されていることが分かる。(\(y_0=1,y_1=\cdots=y_7=0\))
[11]:
simplify( qapply( QFT_gate * input) )
[11]:
読者は是非、入力を様々に変えてこの回路を実行し、フーリエ変換が正しく行われていることを確認してみてほしい。
コラム:計算量について¶
「量子コンピュータは計算を高速に行える」とは、どういうことだろうか。本節で学んだ量子フーリエ変換を例にとって考えてみる。
一体どのような問題で量子コンピュータが高速だと思われているのか、理論的にはどのように扱われているのかなど、詳しく学びたい方はQmediaの記事「量子計算機が古典計算機より優れている」とはどういうことか(竹嵜智之)を参照されたい。
オーダー記法\(\mathcal{O}\)についての註¶
そもそも、アルゴリズムの性能はどのように定量評価できるのだろうか。ここでは、アルゴリズムの実行に必要な資源、主に時間をその基準として考える。とくに問題のサイズを\(n\)としたとき、計算ステップ数(時間)や消費メモリなど、必要な計算資源が\(n\)の関数としてどう振る舞うかを考える。(問題のサイズとは、例えばソートするデータの件数、あるいは素因数分解したい数の二進数表現の桁数などである。)
例えば、問題のサイズ\(n\)に対し、アルゴリズムの要求する計算資源が次の\(f(n)\)で与えられるとする。
\(n\)が十分大きいとき(例えば\(n=10^{10}\))、\(2n^2\)に比べて\(5n\)や\(6\)は十分に小さい。したがって、このアルゴリズムの評価という観点では\(5n+8\)という因子は重要ではない。また、\(n^2\)の係数が\(2\)であるという情報も、\(n\)が十分大きいときの振る舞いには影響を与えない。こうして、計算時間\(f(n)\)の一番「強い」項の情報が重要であると考えることができる。このような考え方を漸近的評価といい、計算量のオーダー記法では次の式で表す。
一般に\(f(n) = \mathcal{O}(g(n))\)とは、ある正の数\(n_0, c\)が存在して、任意の\(n > n_0\)に対して
が成り立つことである。上の例では、\(n_0=7, c=3\)とすればこの定義の通りである(グラフを描画してみよ)。練習として、\(f(n) = 6n^3 +5n\)のオーダー記法\(f(n) = \mathcal{O}(n^3)\)を与える\(n_0, c\)の組を考えてみよ。
アルゴリズムの性能評価では、その入力のサイズを\(n\)としたときに必要な計算資源を\(n\)の関数として表す。特にオーダー記法による漸近評価は、入力のサイズが大きくなったときの振る舞いを把握するときに便利である。そして、こうした漸近評価に基づいた計算量理論というものを用いて、様々なアルゴリズムの分類が行われている。詳細は上記のQmedia記事を参照されたい。
2-4. 位相推定アルゴリズム(入門編)¶
5.2 Phase estimation
)はじめに:アダマールテストを改良する¶
2-2節と同様に、ユニタリ演算\(U\)の固有値\(e^{i\lambda}\)を求める問題を考えよう。アダマールテストでは、固有値の位相\(\lambda\)はテストの測定結果の確率分布に反映され、測定結果をたくさんサンプルすることで\(\lambda\)を推定していた。これをもう少し工夫することで、測定結果から位相の情報をより直接的に取り出すことができることを見ていこう。
下準備として、\(\lambda/2\pi\)を2進展開しよう:
\(j_k\)は0または1の値を取る(古典)ビットである。\(\lambda\)は\(e^{i\lambda}\)の形でのみ出てくるので、\(0 \leq \lambda < 2\pi\)として一般性を失わない。この2進展開を、通常の小数の表記にならって以下のように書く。
以下では簡単のため、\(\lambda/2\pi\)は小数点以下\(n\)桁で書けるものとする。
さて、アダマールテストでは制御ユニタリ演算として\(\Lambda(U)\)を用いたが、ここではそれを少し変えて\(\Lambda(U^{2^k})\)としてみよう。2-2節の前半と同様、\(|\psi\rangle\)が\(U\)の固有状態であることを仮定する。制御ユニタリ演算を行なった後の状態は
上記の2進展開を使うと、
\(e^{i(2\pi)j_1 \ldots j_k} = 1\)だから、結局、
となる。(\(|\psi\rangle\) は省略した)
まず、\(k=n-1\)のときを考えよう。このとき、
だから、アダマールゲートをかければ
である。\(j_n\)は先ほど調べてあるので、\(j_n=0\)の時は何もせず、\(j_n=1\)の時は一般位相ゲート
の\(R_2^\dagger\)をかけると、
と変換できる。そしてアダマールゲートをかければ
このように、アダマールテストを少し変形することで、固有値の位相を1桁ずつ(確定した)量子ビットの状態として取り出すことができる。この手続きを量子回路でいっぺんに行うのが、以下で説明する位相推定アルゴリズムである。
位相推定アルゴリズム:概要¶
上記のアダマールテスト改良版で出てきた式(1)は、どこかで見たことがある形ではないだろうか。そう、2-3節で学んだ量子フーリエ変換の途中式(回路の構成
セクション)と同じである。実は、上記のアルゴリズムの測定側の量子ビットを拡張し、量子フーリエ変換を組み合わせたのが、Kitaevによって提案された位相推定アルゴリズム[1]である。詳細はともかく、どのような操作ができるアルゴリズムなのかを、まず紹介しよう。
\(U\)を量子回路として構成できる一般的なユニタリ行列とする。\(U\)の固有ベクトルを\(|{\rm eigen}_l \rangle\)とし、対応する固有値を\(e^{i\lambda_l}\)とする。ある一般的な量子状態\(|\psi\rangle\)が与えられたとする。これは必ず固有ベクトルで展開できる:
もちろん具体的に係数\(c_l\)がどのような値になるかはわからなくてよい。このとき位相推定アルゴリズムは、\(n\)個の補助量子ビットを用いて、入力状態
を、
で、どれか一つの固有ベクトル\(|{\rm eigen}_l\rangle\)とその固有値\(\lambda_l\)が乱択される。このアルゴリズムは、素因数分解や量子化学アルゴリズム(分子などのエネルギー計算)、そしてその他多くのアルゴリズムのサブルーチンとして利用されていおり、量子コンピュータが従来コンピュータよりも指数的に高速に解を得られる(と期待されている)最も重要な例である。
位相推定アルゴリズム:構成¶
以下では、入力状態\(|\psi\rangle\)を固有状態\(|{\rm eigen}\rangle\)とその固有値\(\lambda\)に限定して、位相推定アルゴリズムを説明していくことにする(入力状態が固有状態の重ね合わせの場合でも全く同じ議論が使えるので、一般性は失われていない)。 アダマールテストでは1つしか測定用の量子ビットを使わなかったが、位相推定では、測定用の補助量子ビットとして\(n\)個の量子ビットを確保する。 位相推定を行う回路は以下の図のようである。
再び、ユニタリー演算\(U\)の固有値\(e^{i\lambda}\)の位相\(\lambda\)を\(n\)ビットの2進小数を用いて
と書いておく。(\(\lambda\)の2進小数表示が\(n\)桁で終わると仮定する;終わらない場合には、最後の測定の際に若干のエラーが生じるが、測定を繰り返せばこのエラーは克服できる。詳細は冒頭のNielsen-Chuangの参照箇所にあたられたい)
- まず、\(|0\rangle\)に初期化された\(n\)個の量子ビットのそれぞれに、アダマールテストと同様にアダマールゲートと制御ユニタリー演算を作用させる。ただし\(k\)番目(\(k=1,...,n\))の補助量子ビットには制御\(U^{2^{k-1}}\)演算をすることにする。\(U|{\rm eigen}\rangle = e^{i2\pi\lambda}|{\rm eigen}\rangle\)だから、\(k\)番目の補助量子ビットには\(e^{i \lambda 2^k}\)の位相が獲得される(これを位相キックバックと呼ぶ)ことになり、\[\left( \frac{|0\rangle + e^{i (2\pi)0.j_1\cdots j_n} |1\rangle }{\sqrt{2}} \right) \otimes \left( \frac{|0\rangle + e^{i (2\pi)0.j_2\cdots j_n} |1\rangle }{\sqrt{2}} \right) \otimes \cdots \otimes \left( \frac{|0\rangle + e^{i (2\pi)0.j_n} |1\rangle }{\sqrt{2}} \right) \otimes |{\rm eigen} \rangle\]という状態が得られる。つまり、固有値の位相を2進小数表示で1ビットずつシフトしたものが各補助量子ビットの位相に格納されることになる。
\(n\)個の補助量子ビットの状態は、2-3節で学んだ量子フーリエ変換の結果の式と全く同じ形をしている。よって、逆量子フーリエ変換(図の\(QFT^\dagger\))をこれらの補助量子ビットに作用させると、
\[\left( \frac{|0\rangle + e^{i (2\pi)0.j_1\cdots j_n} |1\rangle }{\sqrt{2}} \right) \otimes \left( \frac{|0\rangle + e^{i (2\pi)0.j_2\cdots j_n} |1\rangle }{\sqrt{2}} \right) \otimes \cdots \otimes \left( \frac{|0\rangle + e^{i (2\pi)0.j_n} |1\rangle }{\sqrt{2}} \right) \otimes \rangle \rightarrow |j_1...j_n\rangle\]となる。よって、この段階で補助量子ビットの測定を行えば、100%の確率で \(j_1, j_2,\cdots,j_n\) が得られ、\(U\)の固有値の位相\(\lambda\)が求められる。
まとめると、測定用の各量子ビットを制御ビットとする制御\(U^{2^k}\)演算を行なって固有値の位相の情報を補助量子ビットに移した後、逆量子フーリエ変換で位相の値を取り出すのが、位相推定アルゴリズムである。
SymPyによる具体例¶
SymPyで具体例を見ていこう。T演算とS演算を用いて以下のような4×4行列\(U\)をつくり、この行列の固有値を求めてみる。
[1]:
from sympy import *
from sympy.physics.quantum import *
from sympy.physics.quantum.qubit import Qubit,QubitBra
init_printing() # ベクトルや行列を綺麗に表示するため
from sympy.physics.quantum.gate import X,Y,Z,H,S,T,CNOT,SWAP,CPHASE,CGateS
[10]:
represent(T(0)*S(1),nqubits = 2)
[10]:
CGateS
関数を用いて、\(U\)を制御化した cP_2,3,4
を定義しよう。(2,3,4が3つの補助量子ビットに対応している。0,1は\(U\)の作用する空間である。先ほどの位相推定の回路図でいうと、下から順番に0,1,2,…とビットを名付けている。)[11]:
cP_2 = CGateS(2,T(0))*CGateS(2,S(1))
cP_3 = CGateS(3,T(0))*CGateS(3,S(1))
cP_4 = CGateS(4,T(0))*CGateS(4,S(1))
[12]:
PhaEst = H(4)*H(3)*H(2)
PhaEst
[12]:
次に、制御ユニタリーをそれぞれ、1回、2回、4回と作用させていく。
[13]:
PhaEst = cP_2*cP_3*cP_3*cP_4*cP_4*cP_4*cP_4*PhaEst
[14]:
PhaEst = H(4)*PhaEst
補助量子ビット3には、まず補助量子ビット4を制御とする制御\(R_2^\dagger=S^{-1}=SZ\)演算を作用させる。
[15]:
PhaEst = CGateS(4,S(3))*PhaEst
PhaEst = CGateS(4,Z(3))*PhaEst
その後に\(H\)演算を作用させる。
[16]:
PhaEst = H(3)*PhaEst
補助量子ビット2には、
補助量子ビット3を制御とする制御\(R_2^\dagger = S^{-1} = SZ\)演算
補助量子ビット4を制御とする制御\(R_3^\dagger = TS^{-1} = TSZ\)演算
\(H\)演算
の3つを作用させる。
[17]:
PhaEst = CGateS(3,S(2))*PhaEst
PhaEst = CGateS(3,Z(2))*PhaEst
[18]:
PhaEst = CGateS(4,T(2))*PhaEst
PhaEst = CGateS(4,S(2))*PhaEst
PhaEst = CGateS(4,Z(2))*PhaEst
PhaEst = H(2)*PhaEst
このように構成した位相推定アルゴリズムを固有ベクトルに作用させてみよう。アルゴリズム自体は非常に複雑だ。
[19]:
PhaEst
[19]:
しかし実際、入力に作用させると、
[20]:
simplify(qapply(PhaEst*Qubit("00011")))
[20]:
のように設計通り単純な解が得られることが確認できる。 実際、入力\(|\psi\rangle\)は\(|11\rangle\)なので対応する固有値は\(e^{i5\pi/4}\)だが、補助量子ビット2,3,4は011となっており、固有値の位相\(\lambda=5\pi/4\)の2進小数0.011が得られている!他の入力に対しても、
[21]:
simplify(qapply(PhaEst*Qubit("00000")))
[21]:
[22]:
simplify(qapply(PhaEst*Qubit("00010")))
[22]:
[23]:
simplify(qapply(PhaEst*Qubit("00001")))
[23]:
[24]:
simplify(qapply(PhaEst*H(0)*H(1)*Qubit("00000")))
[24]:
のように、それぞれの固有ベクトルに対して、固有値の位相が3つの補助量子ビットへと重ね合わせのまま取り出されている。この状態の補助量子ビットを測定すると、確率的にどれか一つの固有ベクトルと固有値が得られる。
コラム:素因数分解と位相推定(やや難)¶
位相推定の重要な応用例として、素因数分解アルゴリズムを紹介する。 素因数分解問題は\(n\)桁の整数\(N\)が与えられた時に\(N\)の1ではない約数を 見つける問題であり、従来のコンピュータでは多項式時間で解けるアルゴリズムは 見つかっていない。現在のベストアルゴリズムの計算コストは、
であり、準指数的な計算時間がかかる。 このような素因数分解問題の難しさを利用したRSA暗号などが日常的にも使われている。
P. Shorは1994年に、 量子コンピュータを用いることによって素因数分解問題が桁数\(n\)に対して多項式時間で解くことができる ことを示した。これがいわゆるShorの素因数分解アルゴリズムである。
\(N\)を素因数分解したい整数だとしよう。まず、\(N\)と互いに素な整数\(x\)を見つけてくる。 (\(x\)はユークリッドの互除法で簡単に見つけられる。 適当に\(x\)を取ってきて、ユークリッドの互除法で\(N\)と\(x\)の最大公約数を計算し、 それが1以外の整数であれば\(N\)の非自明な約数がみつかったことになり(素)因数分解が完了するし、 1しかなければ\(x\)は\(N\)と互いに素な整数であることになる。) このとき、\(x\)の\(N\)に関する位数\(r\)を考えてみる。 位数\(r\)とは、
を満たす最小の整数であり、ランダムに\(x\)を選ぶと高い確率で\(r\)は偶数になることが知られている。 \(r\)が偶数であると、上式は
のように変形することができる。 これはつまり、\(x^{r/2} \pm 1 \equiv 0 \: (\textrm{mod} \: N)\)であるか、 \(x^{r/2} + 1\)と\(x^{r/2} - 1\)が\(N\)と非自明な公約数(\(N\)の因数)をもつかのどちらかであることを意味する。 実は、\(x\)がランダムに選ばれているとき、後者の確率が十分高いことも示すことができる。よって、\(x^{r/2} + 1\)か\(x^{r/2} - 1\)と\(N\)との公約数をユークリッドの互除法で用いることにより、最終的に非自明な\(N\)の因数が見つかることになる。 これを繰り返していくことによって、\(N\)をどんどん小さな因数へと分解していくことができ、最終的に素因数分解が達成できる。
そして、素因数分解の鍵となる位数\(r\)は、実は入力\(y\)をmod \(N\) のもとで\(x\)倍するという古典計算に対応したユニタリ行列
の固有値を求めることによって決定することができる。実際、固有状態のラベル\(0 \leq s \leq r-1\)を用いて,固有ベクトルは
と書き下すことができ、
を満たす。つまり\(U_x\)の固有値の位相推定から\(s/r\)を求めその分母として\(r\)を得ることができる(そして、その位数\(r\)から上記の手順で\(N\)の素因数分解ができる)。 これがいわゆる、ショアによる素因数分解アルゴリズム(キタエフの位相推定を利用したバージョン)である。
(詳細は Nielsen-Chuang の 5.3 Applicaitons: order-finding and factoring
を参照)
第2章のまとめ¶
第2章では、以下のことを学んだ。
量子アルゴリズムとは何か、NISQアルゴリズムとlong-termアルゴリズムとは何か
アダマールテスト
量子フーリエ変換
位相推定アルゴリズム
この章で学んだアルゴリズム、特に位相推定アルゴリズムは量子コンピュータを使いこなす上で非常に大切なもので、様々な応用がある。
素因数分解問題:上記コラムで見たように、位相推定アルゴリズムを用いて位数という数を推定することによって素因数分解を行うことができる。
量子系のエネルギー計算:量子力学において、エネルギーはハミルトニアンと呼ばれる行列の固有値によって与えられる。もっとも安定的な状態のエネルギーはもっとも小さい値の固有値、安定な状態は対応する固有ベクトルで与えられる。ハミルトニアン自体はユニタリー行列ではないものの、行列の指数関数で定義される時間発展に対応するユニタリ行列\(e^{-i H t}\)に対して位相推定を行うことによって、エネルギーを計算することができる(7-1節)。
連立一次方程式\(Ax=b\)の求解:位相推定を使って求めた固有値・固有ベクトルを用いることにより、連立一次方程式の解を求めることができる(7-2節)。また、その機械学習への応用も研究されている。
第3章 量子アルゴリズムの実行環境¶
量子アルゴリズムを開発し、それが実際の問題に対して有効かを確かめるためには、何らかの実行環境を用意しなければならない。量子コンピュータの実機に直接アクセスしてアルゴリズムを実行し、試行錯誤で結果をデバックできれば一番良いのだが、現在つくられている量子コンピュータ(NISQデバイス)は世界にせいぜい数十台しかないのでそれは難しい。さらにそもそも、現在(2019年4月)の実機はノイズが非常に大きく、新たに開発したアルゴリズムの性能を正確に評価するのは困難である。こういった要因から、量子アルゴリズムの開発には量子コンピュータの動作をシミュレーションするシミュレータの利用が必須である。
そこでこの章では、さまざまな量子アルゴリズムを実行・開発するための環境について紹介する。
3-1節では、量子コンピュータの動作を超高速にシミュレーション可能で、量子アルゴリズムの開発・デバッグに有用なライブラリ Qulacs の使い方について紹介する。
3-2節では、IBMの量子コンピュータシミュレーションライブラリ Qiskit と、実際の量子コンピュータをクラウド公開しているサービス IBM Q Experience の使い方について紹介する。
3-1. 世界最高速シミュレータQulacsの使い方¶
量子アルゴリズムを実際に実行するのに第2章ではSymPyを用いたが、SymPyは代数的な計算に特化している分、大規模・高速な計算は不得手である。この節では、世界最高クラスの動作速度を持つ量子コンピュータのシミュレータ Qulacs の使い方を紹介する。Qulacsの内部はC++で実装されており非常に高速で動作するが、Pythonインタフェースを通して簡単に実装することができる。
※なお、量子コンピュータのシミュレータとしては他にもIBMのQiskit, Rigetti ConmputingのPyQuil(クラウド量子コンピュータあり), GoogleのCirq, MicrosoftのQ# がある。PyQuilについては、こちらの記事も参考にしてみてほしい。
Qulacsのインストール¶
Qulacs は pip を使って簡単にインストールすることができる。詳しくはQulacsのドキュメントを参照されたい。
[ ]:
## Google Colaboratoryの場合 ・ Qulacsがインストールされていないlocal環境の場合のみ実行してください
!pip install qulacs
Qulacsの使い方(1):量子状態¶
量子状態の作成¶
Qulacsでは、以下のコードで\(n\)量子ビットの量子状態 (QuantumState
クラス) を生成できる。生成した量子状態は \(|0\rangle^{\otimes n}\) に初期化されている。
[1]:
from qulacs import QuantumState
# 5-qubitの状態を生成
n = 5
state = QuantumState(n)
# |00000>に初期化
state.set_zero_state()
\(n\)が非常に大きい場合など、メモリが不足している場合は量子状態を生成できない。
量子状態のデータの取得¶
QuantumState.get_vector()
を用いると、量子状態を表す \(2^n\) の長さの配列を取得できる。特にGPUで量子状態を作成したり、大きい \(n\) では非常に重い操作になるので注意。
[2]:
from qulacs import QuantumState
n = 5
state = QuantumState(n)
state.set_zero_state()
# 状態ベクトルをnumpy arrayとして取得
data = state.get_vector()
print(data)
[1.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j
0.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j
0.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j
0.+0.j 0.+0.j]
量子状態の初期化¶
生成した量子状態は、二進数を用いて初期化(set_computational_basis
)したり、ランダムな状態に初期化(set_Haar_random_state
)することができる。
[3]:
from qulacs import QuantumState
n = 5
state = QuantumState(n)
state.set_zero_state()
# |00101> に初期化
state.set_computational_basis(0b00101)
print(state.get_vector())
# ランダムな初期状態を生成
state.set_Haar_random_state()
print(state.get_vector())
# シードを指定してランダムな初期状態を生成
seed = 0
state.set_Haar_random_state(seed)
print(state.get_vector())
[0.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j 1.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j
0.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j
0.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j
0.+0.j 0.+0.j]
[-0.02437065-0.24658958j -0.01613144+0.13503688j -0.18994795-0.12319831j
0.06504845+0.16830255j -0.21598901+0.18730307j -0.16812459-0.02947785j
-0.19220647-0.09961786j 0.10572396+0.00775966j -0.0680691 -0.02781797j
-0.13307298+0.16938736j 0.03958908+0.10715579j 0.01297565-0.13433703j
-0.06164082-0.07189014j -0.07461534+0.18664457j 0.12610337-0.12079184j
0.04721677+0.05207131j -0.01081212+0.24851703j 0.02671697-0.00200128j
-0.03405007-0.3028626j 0.04368712-0.047096j 0.27087639+0.20331916j
-0.11928844+0.02439986j 0.09817583+0.22850291j -0.08822065-0.01851942j
-0.08319959+0.12819258j 0.00568074-0.02627961j 0.08279638-0.10588521j
0.04417022-0.01161947j -0.05749897-0.22828609j -0.00629634+0.04739956j
-0.09164645-0.10147242j 0.13382742+0.04209609j]
[ 0.09232558+0.06460115j 0.14544754-0.10413526j 0.11300793-0.02455806j
0.00811251+0.2426378j -0.01116588+0.23770313j -0.10691448+0.0487731j
-0.01654446+0.17073103j 0.22250403+0.01934699j 0.04728154+0.22585226j
0.04475383+0.20375993j -0.10592159+0.10428549j -0.10175932-0.04016904j
0.04241271+0.08723859j 0.18205362+0.06190871j 0.14103367-0.12925877j
-0.08269267+0.08879486j -0.14479848-0.0183179j -0.32601567+0.06762062j
0.03482754+0.04464901j 0.09181499+0.05497985j 0.06870746+0.12628442j
-0.00624006-0.21793139j -0.11181371+0.2659879j -0.04589826+0.00891387j
-0.04058365+0.30265587j -0.13894575-0.04392724j -0.03499327+0.0184768j
0.05033425-0.07376874j 0.07124237+0.15451312j 0.09319498+0.08341551j
-0.03002195-0.14677347j -0.05309219+0.10184815j]
量子状態のデータのコピーとロード¶
量子状態を複製(copy
)したり、他の量子状態のデータをロード(load
)できる。
[4]:
from qulacs import QuantumState
n = 5
state = QuantumState(n)
state.set_computational_basis(0b00101)
# コピーして新たな量子状態を作成
second_state = state.copy()
print(second_state.get_vector())
# 量子状態を新たに作成し、既存の状態のベクトルをコピー
third_state = QuantumState(n)
third_state.load(state)
print(third_state.get_vector())
[0.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j 1.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j
0.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j
0.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j
0.+0.j 0.+0.j]
[0.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j 1.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j
0.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j
0.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j
0.+0.j 0.+0.j]
量子状態に関する計算¶
上で挙げた以外にも、量子状態(QuantumState
)には種々の処理が可能である。
[5]:
from qulacs import QuantumState
n = 5
state = QuantumState(n)
state.set_Haar_random_state()
# normの計算 (qulacs v0.1.8 で get_norm から get_squared_norm に名前が変更になりました)
norm = state.get_squared_norm()
print("squared_norm : ", norm)
# Z基底で測定した時のentropyの計算
entropy = state.get_entropy()
print("entropy : ",entropy)
# index-th qubitをZ基底で測定して0を得る確率の計算
index = 3
zero_probability = state.get_zero_probability(index)
print("prob_meas_3rd : ",zero_probability)
# 周辺確率を計算 (以下は0,3-th qubitが0、1,2-th qubitが1と測定される確率の例)
value_list = [0,1,1,0,2]
marginal_probability = state.get_marginal_probability(value_list)
print("marginal_prob : ",marginal_probability)
squared_norm : 1.0
entropy : 3.0719687986623603
prob_meas_3rd : 0.5740657157322318
marginal_prob : 0.04265173032311748
量子状態の内積¶
inner_product
関数で内積を計算できる。
[6]:
from qulacs import QuantumState
from qulacs.state import inner_product
n = 5
state_bra = QuantumState(n)
state_ket = QuantumState(n)
state_bra.set_Haar_random_state()
state_ket.set_computational_basis(0)
# 内積値の計算
value = inner_product(state_bra, state_ket)
print(value)
(0.07448994812927281-0.1223698589819414j)
量子状態の解放¶
del
を用いて量子状態を強制的にメモリから解放することができる。del
せずとも利用されなくなったタイミングで解放されるが、メモリがシビアな際に便利である。
[7]:
from qulacs import QuantumState
n = 5
state = QuantumState(n)
# 量子状態を開放
del state
量子状態の詳細情報の取得¶
QuantumState
クラスのオブジェクトを直接 print
すると、量子状態の情報が出力される。
[8]:
from qulacs import QuantumState
n = 5
state = QuantumState(n)
print(state)
*** Quantum State ***
* Qubit Count : 5
* Dimension : 32
* State vector :
(1,0)
(0,0)
(0,0)
(0,0)
(0,0)
(0,0)
(0,0)
(0,0)
(0,0)
(0,0)
(0,0)
(0,0)
(0,0)
(0,0)
(0,0)
(0,0)
(0,0)
(0,0)
(0,0)
(0,0)
(0,0)
(0,0)
(0,0)
(0,0)
(0,0)
(0,0)
(0,0)
(0,0)
(0,0)
(0,0)
(0,0)
(0,0)
Qulacsの使い方(2):量子ゲート¶
量子ゲートの生成と作用¶
デフォルトで実装されている量子ゲートはqulacs.gate
モジュールで定義される。
[9]:
import numpy as np
from qulacs import QuantumState
from qulacs.gate import X, RY, DenseMatrix
n = 3
state = QuantumState(n)
state.set_zero_state()
print(state.get_vector())
# 1st-qubitにX操作 (|000> -> |010>)
index = 1
x_gate = X(index)
x_gate.update_quantum_state(state)
print(state.get_vector())
# 1st-qubitをYパウリでpi/4.0回転
angle = np.pi / 4.0
ry_gate = RY(index, angle)
ry_gate.update_quantum_state(state)
print(state.get_vector())
# 2nd-qubitにゲート行列で作成したゲートを作用
dense_gate = DenseMatrix(2, [[0,1],[1,0]])
dense_gate.update_quantum_state(state)
print(state.get_vector())
# ゲートの解放
del x_gate
del ry_gate
del dense_gate
[1.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j]
[0.+0.j 0.+0.j 1.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j]
[0.38268343+0.j 0. +0.j 0.92387953+0.j 0. +0.j
0. +0.j 0. +0.j 0. +0.j 0. +0.j]
[0. +0.j 0. +0.j 0. +0.j 0. +0.j
0.38268343+0.j 0. +0.j 0.92387953+0.j 0. +0.j]
事前に定義されているゲートは以下の通りである。
single-qubit Pauli operation: Identity, X, Y, Z
single-qubit Clifford operation : H, S, Sdag, T, Tdag, sqrtX, sqrtXdag, sqrtY, sqrtYdag
two-qubit Clifford operation : CNOT, CZ, SWAP
single-qubit Pauli rotation : RX, RY, RZ
General Pauli operation : Pauli, PauliRotation
IBMQ basis-gate : U1, U2, U3
General gate : DenseMatrix
Measurement : Measurement
Noise : BitFlipNoise, DephasingNoise, IndepenedentXZNoise, DepolarizingNoise
回転ゲートであるRX
, RY
, RZ
, PauliRotation
は所定のパウリ演算子\(P\)について、引数\(\theta\)に対して\(\exp(i\frac{\theta}{2}P)\)という操作を行う。それぞれのゲートの詳細はAPIドキュメントを参照されたい。
量子ゲートの合成¶
続けて作用する量子ゲートを合成し、新たな単一の量子ゲートを生成できる。
[10]:
import numpy as np
from qulacs import QuantumState
from qulacs.gate import X, RY, merge
n = 3
state = QuantumState(n)
state.set_zero_state()
index = 1
x_gate = X(index)
angle = np.pi / 4.0
ry_gate = RY(index, angle)
# ゲートを合成して新たなゲートを生成
# 第一引数が先に作用する
x_and_ry_gate = merge(x_gate, ry_gate)
x_and_ry_gate.update_quantum_state(state)
print(state.get_vector())
[0.38268343+0.j 0. +0.j 0.92387953+0.j 0. +0.j
0. +0.j 0. +0.j 0. +0.j 0. +0.j]
量子ゲートのゲート行列の和¶
実際の量子コンピュータでこの操作を行うことは一般に難しいが、量子ゲートのゲート要素の和を取ることができる。 (現状ではcontrol-qubitがある場合の和は動作が未定義なので利用しないことを勧める。)
[11]:
import numpy as np
from qulacs import QuantumState
from qulacs.gate import P0,P1,add, merge, Identity, X, Z
gate00 = merge(P0(0),P0(1))
gate11 = merge(P1(0),P1(1))
# |00><00| + |11><11|
proj_00_or_11 = add(gate00, gate11)
print(proj_00_or_11)
gate_ii_zz = add(Identity(0), merge(Z(0),Z(1)))
gate_ii_xx = add(Identity(0), merge(X(0),X(1)))
proj_00_plus_11 = merge(gate_ii_zz, gate_ii_xx)
# ((|00>+|11>)(<00|+<11|))/2 = (II + ZZ)(II + XX)/4
proj_00_plus_11.multiply_scalar(0.25)
print(proj_00_plus_11)
*** gate info ***
* gate name : DenseMatrix
* target :
0 : commute
1 : commute
* control :
* Pauli : no
* Clifford : no
* Gaussian : no
* Parametric: no
* Diagonal : no
* Matrix
(1,0) (0,0) (0,0) (0,0)
(0,0) (0,0) (0,0) (0,0)
(0,0) (0,0) (0,0) (0,0)
(0,0) (0,0) (0,0) (1,0)
*** gate info ***
* gate name : DenseMatrix
* target :
0 : commute
1 : commute
* control :
* Pauli : no
* Clifford : no
* Gaussian : no
* Parametric: no
* Diagonal : no
* Matrix
(0.5,0) (0,0) (0,0) (0.5,0)
(0,0) (0,0) (0,0) (0,0)
(0,0) (0,0) (0,0) (0,0)
(0.5,0) (0,0) (0,0) (0.5,0)
特殊な量子ゲートと一般の量子ゲート¶
Qulacsにおける基本量子ゲートは以下の二つに分けられる。
特殊ゲート:そのゲートの作用について、専用の高速化がなされた関数があるもの。
一般ゲート:ゲート行列を保持し、行列をかけて作用するもの。
前者は後者に比べ専用の関数が作成されているため高速だが、コントロール量子ビットを増やすなど、量子ゲートの作用を変更する操作が後から行えない。こうした変更をしたい場合、特殊ゲートを一般ゲートに変換する必要があり、gate.to_matrix_gate
で実現できる。
[12]:
import numpy as np
from qulacs import QuantumState
from qulacs.gate import to_matrix_gate, X
n = 3
state = QuantumState(n)
state.set_zero_state()
index = 0
x_gate = X(index)
x_mat_gate = to_matrix_gate(x_gate)
# 1st-qubitが0の場合だけゲートを作用
control_index = 1
control_with_value = 0
x_mat_gate.add_control_qubit(control_index, control_with_value)
x_mat_gate.update_quantum_state(state)
print(state.get_vector())
[0.+0.j 1.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j]
量子ゲートのゲート行列の取得¶
生成した量子ゲートのゲート行列を取得できる。(control量子ビットなどはゲート行列に含まれない。)特にゲート行列を持たない種類のゲート(例えば\(n\)量子ビットのパウリ回転ゲート)などは取得に非常に大きなメモリと時間を要するので気を付けてること。
[13]:
from qulacs.gate import X, to_matrix_gate
gate = X(0)
print(gate)
print(to_matrix_gate(gate))
*** gate info ***
* gate name : X
* target :
0 : commute X
* control :
* Pauli : yes
* Clifford : yes
* Gaussian : no
* Parametric: no
* Diagonal : no
*** gate info ***
* gate name : DenseMatrix
* target :
0 : commute X
* control :
* Pauli : no
* Clifford : no
* Gaussian : no
* Parametric: no
* Diagonal : no
* Matrix
(0,0) (1,0)
(1,0) (0,0)
一般的な量子ゲートの実現¶
qulacs.gate.DenseMatrix
を使うと、一般の行列からゲートを生成することができる。
[14]:
from qulacs.gate import DenseMatrix
# 1-qubit gateの場合
gate = DenseMatrix(0, [[0,1],[1,0]])
print(gate)
# 2-qubit gateの場合
gate = DenseMatrix([0,1], [[1,0,0,0],[0,1,0,0],[0,0,0,1],[0,0,1,0]])
print(gate)
*** gate info ***
* gate name : DenseMatrix
* target :
0 : commute
* control :
* Pauli : no
* Clifford : no
* Gaussian : no
* Parametric: no
* Diagonal : no
* Matrix
(0,0) (1,0)
(1,0) (0,0)
*** gate info ***
* gate name : DenseMatrix
* target :
0 : commute
1 : commute
* control :
* Pauli : no
* Clifford : no
* Gaussian : no
* Parametric: no
* Diagonal : no
* Matrix
(1,0) (0,0) (0,0) (0,0)
(0,0) (1,0) (0,0) (0,0)
(0,0) (0,0) (0,0) (1,0)
(0,0) (0,0) (1,0) (0,0)
Qulacsの使い方(3):量子回路¶
量子回路の構成¶
量子回路(QuantumCircuit
クラス)は量子ゲートの集合として表され、例えば以下のように量子回路を構成できる。
[15]:
from qulacs import QuantumState, QuantumCircuit
from qulacs.gate import Z
n = 5
state = QuantumState(n)
state.set_zero_state()
# 量子回路を定義
circuit = QuantumCircuit(n)
# 量子回路にhadamardゲートを追加
for i in range(n):
circuit.add_H_gate(i)
# ゲートを生成し、それを追加することもできる。
for i in range(n):
circuit.add_gate(Z(i))
# 量子回路を状態に作用
circuit.update_quantum_state(state)
print(state.get_vector())
[ 0.1767767+0.j -0.1767767-0.j -0.1767767-0.j 0.1767767+0.j
-0.1767767-0.j 0.1767767+0.j 0.1767767+0.j -0.1767767-0.j
-0.1767767-0.j 0.1767767+0.j 0.1767767+0.j -0.1767767-0.j
0.1767767+0.j -0.1767767-0.j -0.1767767-0.j 0.1767767+0.j
-0.1767767-0.j 0.1767767+0.j 0.1767767+0.j -0.1767767-0.j
0.1767767+0.j -0.1767767-0.j -0.1767767-0.j 0.1767767+0.j
0.1767767+0.j -0.1767767-0.j -0.1767767-0.j 0.1767767+0.j
-0.1767767-0.j 0.1767767+0.j 0.1767767+0.j -0.1767767-0.j]
なお、add_gate
で追加された量子回路は量子回路の解放時に一緒に解放される。従って、代入したゲートは再利用できない。引数として与えたゲートを再利用したい場合はgate.copy
を用いて自身のコピーを作成するか、add_gate_copy
関数を用いる必要がある。
量子回路のdepthの計算と最適化¶
量子ゲートをまとめて一つの量子ゲートとすることで、量子ゲートの数を減らすことができ、数値計算の時間を短縮できることがある。(もちろん、対象となる量子ビットの数が増える場合や、専用関数を持つ量子ゲートを合成して専用関数を持たない量子ゲートにしてしまった場合は、トータルで計算時間が減少するかは状況に依る。)
下記のコードではoptimize
関数を用いて、量子回路の量子ゲートをターゲットとなる量子ビットが3つになるまで貪欲法で合成を繰り返している。
[16]:
from qulacs import QuantumCircuit
from qulacs.circuit import QuantumCircuitOptimizer
n = 5
depth = 10
circuit = QuantumCircuit(n)
for d in range(depth):
for i in range(n):
circuit.add_H_gate(i)
# depthを計算(depth=10)
print(circuit.calculate_depth())
# 最適化
opt = QuantumCircuitOptimizer()
# 作成を許す最大の量子ゲートのサイズ
max_block_size = 1
opt.optimize(circuit, max_block_size)
# depthを計算(depth=1へ)
print(circuit.calculate_depth())
10
1
量子回路の情報デバッグ¶
量子回路をprint
すると、量子回路に含まれるゲートの統計情報などが表示される。
[17]:
from qulacs import QuantumCircuit
from qulacs.circuit import QuantumCircuitOptimizer
n = 5
depth = 10
circuit = QuantumCircuit(n)
for d in range(depth):
for i in range(n):
circuit.add_H_gate(i)
print(circuit)
*** Quantum Circuit Info ***
# of qubit: 5
# of step : 10
# of gate : 50
# of 1 qubit gate: 50
Clifford : yes
Gaussian : no
Qulacsの使い方(4):オブザーバブル¶
に応じた確率\(|c_i|^2\)で測定値\(a_i\)が得られ、その期待値は\(\langle\psi|A|\psi\rangle\)となる。
オブザーバブルの生成¶
Qulacsでは、オブザーバブルはパウリ演算子\(X,Y,Z\)の(直積の)集合として表現される。(エルミートな演算子は必ずパウリ演算子の直積の和で表されるので。)パウリ演算子は下記のように定義できる。
[18]:
from qulacs import Observable
n = 5
coef = 2.0
# 2.0 X_0 X_1 Y_2 Z_4というパウリ演算子を設定
Pauli_string = "X 0 X 1 Y 2 Z 4"
observable = Observable(n)
observable.add_operator(coef,Pauli_string)
オブザーバブルの評価¶
状態に対してオブザーバブルの期待値を評価できる。
[19]:
from qulacs import Observable, QuantumState
n = 5
coef = 2.0
Pauli_string = "X 0 X 1 Y 2 Z 4"
observable = Observable(n)
observable.add_operator(coef,Pauli_string)
state = QuantumState(n)
state.set_Haar_random_state()
# 期待値の計算
value = observable.get_expectation_value(state)
print(value)
-0.027383733754500428
Qulacsの使い方(5):変分量子回路¶
量子回路をParametricQuantumCircuit
クラスとして定義すると、通常のQuantumCircuit
クラスの関数に加え、変分法を用いて量子回路を最適化するのに便利ないくつかの関数を利用することができる。これは第5章で学ぶ変分量子回路を実装する時に非常に役に立つ。
変分量子回路の利用例¶
一つの回転角を持つ量子ゲート(X-rot, Y-rot, Z-rot, multi_qubit_pauli_rotation)はパラメトリックな量子ゲートとして量子回路に追加することができる。パラメトリックなゲートとして追加された量子ゲートについては、量子回路の構成後にパラメトリックなゲート数を取り出したり、後から回転角を変更することが可能である。
[20]:
from qulacs import ParametricQuantumCircuit
from qulacs import QuantumState
import numpy as np
n = 5
depth = 10
# construct parametric quantum circuit with random rotation
circuit = ParametricQuantumCircuit(n)
for d in range(depth):
for i in range(n):
angle = np.random.rand()
circuit.add_parametric_RX_gate(i,angle)
angle = np.random.rand()
circuit.add_parametric_RY_gate(i,angle)
angle = np.random.rand()
circuit.add_parametric_RZ_gate(i,angle)
for i in range(d%2, n-1, 2):
circuit.add_CNOT_gate(i,i+1)
# add multi-qubit Pauli rotation gate as parametric gate (X_0 Y_3 Y_1 X_4)
target = [0,3,1,4]
pauli_ids = [1,2,2,1]
angle = np.random.rand()
circuit.add_parametric_multi_Pauli_rotation_gate(target, pauli_ids, angle)
# get variable parameter count, and get current parameter
parameter_count = circuit.get_parameter_count()
param = [circuit.get_parameter(ind) for ind in range(parameter_count)]
# set 3rd parameter to 0
circuit.set_parameter(3, 0.)
# update quantum state
state = QuantumState(n)
circuit.update_quantum_state(state)
# output state and circuit info
print(state)
print(circuit)
*** Quantum State ***
* Qubit Count : 5
* Dimension : 32
* State vector :
(-0.0673932,0.0932352)
(0.0793816,0.0803179)
(-0.0240998,-0.0720735)
(0.0267232,0.103591)
(-0.089328,-0.0454438)
(-0.0273612,-0.172908)
(0.0753719,-0.185454)
(0.120598,0.0489211)
(-0.0433311,-0.00542669)
(0.407492,-0.0683546)
(0.0712898,0.029486)
(-0.0374001,0.100097)
(0.0395997,0.166802)
(0.113313,0.0278069)
(0.00456149,0.0702255)
(-0.121551,0.306851)
(-0.0113109,0.0106071)
(0.158906,0.0897413)
(0.276642,0.00709558)
(-0.163862,0.0615158)
(-0.0507503,0.0898438)
(0.221342,0.0332379)
(-0.125741,-0.130305)
(0.0463867,0.225922)
(0.0493533,-0.127222)
(-0.235716,0.0564754)
(0.0206978,-0.129814)
(0.108871,0.107555)
(0.0917828,-0.0557612)
(0.180461,0.121747)
(0.0456678,0.0580318)
(0.0311144,0.203219)
*** Quantum Circuit Info ***
# of qubit: 5
# of step : 41
# of gate : 171
# of 1 qubit gate: 150
# of 2 qubit gate: 20
# of 3 qubit gate: 0
# of 4 qubit gate: 1
Clifford : no
Gaussian : no
*** Parameter Info ***
# of parameter: 151
3-2. QiskitとIBM Q Experienceの使い方¶
IBM Q Experienceは2016年5月24日にIBM社が世界で初めてクラウド上に公開した量子コンピュータである[1]。この節では、IBM Q Experienceの使い方及びIBM社が開発している量子計算用フレームワークQiskitの使い方を学ぶ。
IBM Q Experienceの実行環境を用意する¶
アカウント登録¶
まず、IBM Q ExperienceのWebページ[1] の右上のSign Inボタンをクリックしてアカウントを作る。
メールアドレスとパスワードもしくは外部サービスのアカウントが要求されるが、Sign Inボタン下のSign Upをクリックする。
必要事項を記入し、Sign Upボタンをクリックする。
登録したメールアドレスにメールが送られるので、そのメール内のリンクをクリックする。すると別ウィンドウで登録完了の画面が出るので、次からはログイン可能になる。
APIの取得¶
これでIBM Q Experienceに登録できたので、次はIBM Q Experienceの実機(本物の量子コンピュータ)のアクセスに必要なIBM Q Experience APIの取得の仕方を学ぶ。
ログイン後にWelcome to Experience!のポップアップが出るので右上の×印をクリックして閉じる。
画面右上のアイコン → “My Account”をクリックし、登録したアカウント情報を見る。
画面右上に“Account”, “Community”, “Advanced”のうち“Advanced”をクリックする。
“API Token”の欄が“undefined”になっているので、“Regenerate”ボタンをクリックしてAPIを生成する。隣の“Copy this token”ボタンを押すとAPIをクリップボードに貼り付けられる。
ちなみに、IBM Q ExperienceのWebページ[1]上のGUIでドラッグ&ドロップで回路を生成して実行することや、Jupyter Notebook (Qiskit Notebooksと呼ばれている)をWebページ上で作成して実行することも可能である。
Qiskitの実行環境を用意する¶
Qiskitはpipを経由してインストールすることができる。PC内のコマンドライン(Windowsのコマンドプロンプト、Macのターミナル)を使う場合はコマンドラインにpip install qiskit
と入力して実行する。Jupyter NotebookまたはGoogle Colaboratoryをお使いの方は、以下のセルの!pip install qiskit
を実行すると使えるようになる。
[ ]:
!pip install qiskit
Qiskitを使ってみる¶
QiskitはPythonフレームワークなので書く時の文法はPythonと同じである。その前に実機の使用に必要な情報をローカル環境に保存しておこう(このステップは各自の環境ごとに一回実行すれば良い)。
[ ]:
## "MY_API_TOKEN" に、上で取得したtokenを入れる
from qiskit import IBMQ
IBMQ.save_account('MY_API_TOKEN')
ここでは、\(|\Psi^{+}\rangle=\frac{|00\rangle + |11\rangle}{\sqrt{2}}\)というBell状態ペアの生成のプログラムを例に学んでいく。
[2]:
#必要なモジュールのインポート
from qiskit import IBMQ, QuantumCircuit, ClassicalRegister, QuantumRegister
from qiskit import execute, Aer
from qiskit.qasm import pi
from qiskit.tools.visualization import plot_histogram, circuit_drawer
import numpy as np
[3]:
# 自分のアカウント情報をloadする。(あらかじめ IBMQ.save_account を実行しておく必要がある. 複数のアカウントを使い分ける時はここで行う)
provider = IBMQ.load_account()
# 自分のアカウントで使用できるバックエンドを見る
provider.backends()
[3]:
[<IBMQSimulator('ibmq_qasm_simulator') from IBMQ(hub='ibm-q', group='open', project='main')>,
<IBMQBackend('ibmqx2') from IBMQ(hub='ibm-q', group='open', project='main')>,
<IBMQBackend('ibmq_16_melbourne') from IBMQ(hub='ibm-q', group='open', project='main')>,
<IBMQBackend('ibmq_vigo') from IBMQ(hub='ibm-q', group='open', project='main')>,
<IBMQBackend('ibmq_ourense') from IBMQ(hub='ibm-q', group='open', project='main')>,
<IBMQBackend('ibmq_london') from IBMQ(hub='ibm-q', group='open', project='main')>,
<IBMQBackend('ibmq_burlington') from IBMQ(hub='ibm-q', group='open', project='main')>,
<IBMQBackend('ibmq_essex') from IBMQ(hub='ibm-q', group='open', project='main')>]
色々と出てきたが、'ibmq_qasm_simulator'
(32量子ビットのシミュレータ)以外は5量子ビット・14量子ビットの実機の量子コンピュータである(IBM Q Experienceのログイン後の画面に詳細が載っている)。以下のコマンドを打つと1番空いていてジョブが投げやすい実機が分かる。
[4]:
from qiskit.providers.ibmq import least_busy
backend_lb = least_busy(provider.backends(simulator=False, operational=True))
print("Least busy backend: ", backend_lb)
Least busy backend: ibmqx2
ibmqx2
が1番忙しくないことが分かったので、今回はそれを実機として使う。まずはシミュレータで量子回路を実行してみよう。
[5]:
#量子レジスタqを生成する。
q = QuantumRegister(2)
#古典レジスタcを生成する
c = ClassicalRegister(2)
#量子レジスタqと古典レジスタc間で量子回路を生成する。
qc = QuantumCircuit(q, c)
#1番目の量子ビットにHゲートをかける。
qc.h(q[0])
#1-2番目の量子ビットにCNOTゲートをかける。(1番目の量子ビットが制御量子ビット、2番目の量子ビットがコントロール量子ビット)
qc.cx(q[0],q[1])
#1番目の量子ビットの測定値を1番目の古典ビットに、2番目の量子ビットの測定値を2番目の古典ビットに渡す。
qc.measure(q[0], c[0])
qc.measure(q[1], c[1])
#localのシミュレータとleast busyなbackend
backend_sim = Aer.get_backend("qasm_simulator")
#量子回路qcを指定したバックエンド(backend_sim)で4096回実行する。
result = execute(qc, backend_sim, shots=4096).result()
#結果を出力する。
print(result.get_counts(qc))
#結果のヒストグラムを描画する。
plot_histogram(result.get_counts(qc))
{'00': 2089, '11': 2007}
[5]:

生成した状態は\(\frac{|00\rangle + |11\rangle}{\sqrt{2}}\)だから、式の通り\(|00\rangle\)、\(|11\rangle\)が得られる確率は共に50%に近いことが分かる。次に実機の結果を見てみよう。但し、実機での実験は結果を得るのに非常に時間がかかるので注意が必要だ。
[6]:
#least busyだったbackendを選ぶ
backend_sim = backend_lb
#量子回路qcを指定したバックエンド(backend_sim)で4096回実行する。
result = execute(qc, backend_sim, shots=4096).result()
#結果を出力する。
print(result.get_counts(qc))
#結果のヒストグラムを描画する。
plot_histogram(result.get_counts(qc))
{'00': 1844, '11': 2041, '01': 107, '10': 104}
[6]:

実機による結果を見てみると、理論上は得られない\(|01\rangle\)と\(|10\rangle\)が得られていることが分かる。これは演算過程で生じたエラーによるものである。この結果は、今の量子コンピュータがNISQデバイス(中規模でノイズの発生を許す量子コンピュータ)と呼ばれることを分かりやすく示している。
参考文献¶
[1] https://quantumexperience.ng.bluemix.net/qx/experience [online] (参照日時 2019-02-13)
第4章 量子ダイナミクスシミュレーション¶
本章ではNISQデバイスの重要な応用先の一つと考えられている、量子ダイナミクスシミュレーションについて学ぶ。まず4-1節で量子ダイナミクスを理解するのに最低限必要な知識を学び、4-2節で具体的な物理系のダイナミクスのシミュレーションをQulacsを用いて実装してみる。
※この章は量子力学の背景知識を必要とする箇所が多いので、量子力学に馴染みのない読者は読み飛ばしてもよいし、READMEに挙げた量子論の参考文献やNielsen-Chuangの2.2 The postulates of quantum mechanics
を参照するのもよい。
4-1. シュレディンガー方程式とは、量子ダイナミクスとは¶
ダイナミクスのシミュレーションとは¶
古典・量子によらず、コンピュータの応用先として重要なものに、「物理系のダイナミクスのシミュレーション」がある。
物理系のシミュレーションは、身の回りの生活を成り立たせる上で無くてはならない技術である。例えば、ビルや航空機の設計、気象予報、創薬といった、ありとあらゆる場面でコンピュータを用いた物理系のシミュレーションが行われている。物理系のシミュレーションのゴールは、系(着目している対象)の初期状態(最初の状態)と、その系のダイナミクス=時間発展(系が時間の経過に伴ってどう変化するか)のルールが与えられたときに、一定時間後に系がどういう状態にあるかを知ることである。
例えばビリヤードのシミュレーションを考えよう。初期状態は各玉の最初の位置と速度である。また、古典力学系なので時間発展のルールはニュートンの運動方程式(微分方程式)である。これらを与えてやることで、ブレイクショットの終了時に各玉がどこにあるかを予測することができるのである。
上の例のように、系の時間発展のルールは、一般に微分方程式で表される。古典力学系ならニュートンの運動方程式、電磁気学系ならマクスウェル方程式、流体力学系ならナビエ・ストークス方程式、といった具合である。したがって、物理系のシミュレーションの本質は、微分方程式を数値的に解くことに帰着される。そして、当然ながら量子系のシミュレーション、すなわち量子力学に従う系のシミュレーションもその中に含まれる。
量子系のシミュレーション¶
量子系はシュレディンガー方程式(後述)というものに従って時間発展する。よって、量子系のダイナミクスをシミュレートするにはシュレディンガー方程式を数値的に解くことが必要となる。他の物理系のシミュレーションと同様、古典コンピュータを用いて量子系のシミュレーションを行うことは原理的には可能である。実際、様々な近似を巧みに用いることで、古典コンピュータを用いた量子系のシミュレーションは一定の成功を収めてきた。
しかし、量子系を古典コンピュータでシミュレーションする場合、系のサイズ(粒子やスピンの数)が大きくなると、計算にかかる時間が指数関数的に増大するため、すぐに太刀打ちできなくなってしまう。例えば、\(n\)個の量子ビットのある系をシミュレーションするためにはおよそ\(2^n\)個の方程式を解かなければならない(参照:Nielsen-Chuang 4.7 Simulation of quantum systems
)。
そこで、指数関数的な速度向上が期待される量子コンピュータを用いて量子系のシミュレーションを行うことで、上記の問題を解決しようというアイデアが生まれた。まさに「量子の問題は量子で解決」である。
本章では、量子コンピュータを用いた量子系のダイナミクスのシミュレーション手法の内、代表的なものについて学んでいく。
用語の整理¶
ここで、量子ダイナミクスのシミュレーションに出てくる用語を整理しておこう。
シュレディンガー方程式¶
ここで\(E\)は系の(固有)エネルギーと呼ばれる。
ハミルトニアン¶
シュレディンガー方程式に出てくる演算子であり、系の時間発展を司る。量子コンピュータでシミュレーションする範囲においては、ただの大きな行列だと思っておいても良い。第1章で学んだ量子操作と異なる点は、ハミルトニアンはエルミート\(H=H^\dagger\)だということである。そのため、一般にハミルトニアンを作用させた状態\(H|\psi\rangle\)を量子コンピュータ上で作ることはできない。(ユニタリかつエルミートであるパウリ演算子\(X,Y,Z\)という例外を除く)
ハミルトニアンは、系のエネルギーに対応するオブザーバブル(次項)でもある。ハミルトニアンには、粒子同士に働く力や、外部からの力(電場・磁場など)といった系の情報のすべてが詰まっている。このハミルトニアンをシュレディンガー方程式に入れ、それを解くことで、系のエネルギーの値が得られる。
オブザーバブル(物理量)¶
の係数\(|c_i|^2\)に比例した確率で得られる。実は第1章で学んだ射影測定は、\(Z\)というオブザーバブルに関する射影測定のことだったのである。
エネルギー固有状態(基底状態・励起状態)¶
前述のように、ハミルトニアン\(H\)は系のエネルギーを表すオブザーバブルだから、その固有値が分かると系が取り得るエネルギーの値(固有エネルギー)が分かる。系のエネルギーが分かると何が嬉しいのだろうか?それは、「実際にどの状態が実現しやすいか」が分かるという点である。
与えられたハミルトニアンのもとでシュレディンガー方程式を解くと、一般に複数の解(状態とエネルギーの組)が得られる。それらの中で、最も低いエネルギーを持つ、すなわち最も安定な状態は「基底状態」と呼ばれ、自然の中で最も実現しやすい状態である(系の温度が上がると、より高エネルギーな状態も出現しやすくなる)。
物質科学や量子化学の文脈においては、各状態のエネルギーを知ることは非常に重要である。なぜなら、どんな結晶構造が安定なのか・どんな化学反応が実際に起こるのか・反応に必要なエネルギーはどれくらいか、と言ったことを検討することが可能になるからである。
ダイナミクス(時間発展)¶
ハミルトニアン、つまり系が時間に依存しない場合のシュレーディンガー方程式を形式的にとくと
※進んだ註:Dojoでは扱っていないが、混合状態と呼ばれる状態についてのダイナミクスは、von-Neumann方程式と呼ばれるシュレディンガー方程式に似た方程式で記述される。
4-2. トロッター分解を用いた量子シミュレーション¶
トロッター分解とは¶
(リー・)トロッター分解とは、正方行列\(A,B\)の和の指数関数を、それぞれの指数関数の積に近似する公式である:
ここで\(O\)は2.3節コラムで紹介したオーダー記法であり、近似の大体の精度を表す。(\(A,B\)が行列なので、\(e^{A+B}\neq e^A\cdot e^B\) であることに注意)
トロッター分解を用いた量子シミュレーションの仕組み¶
前節で学んだように、量子状態の時間発展はシュレディンガー方程式\(i\frac{\partial}{\partial t}|\psi(t)\rangle = H|\psi(t)\rangle\)に従う(\(\hbar=1\)とした)。特に、ハミルトニアン\(H\)が時間に依存しない場合、時間\(t\)が経った時の状態は\(|\psi(t)\rangle = e^{-iHt}|\psi(0)\rangle\)となる(\(|\psi(0)\rangle\)は初期状態)。つまり、量子系のダイナミクスのシミュレーションでは\(e^{-iHt}\)という演算子が計算できれば良いのだが、\(n\)量子ビット系だと\(H\)は\(2^n\)次元となって非常に大きな行列になる。ゆえに古典コンピュータでこの指数関数を計算するのは難しいし、量子コンピュータでもあらゆる\(2^n\)次元の行列\(H\)の指数関数を計算するのは難しいと考えられている。
このように、ハミルトニアンが少数の項の和で書ける場合には、トロッター分解を用いて量子コンピュータで高速に計算を行うことができるのだ。
※ ちなみに、物理・量子化学の分野で興味のあるハミルトニアンは、大抵は効率的なトロッター分解ができる形をしている。
量子ダイナミクスの実装(1):イジングモデル¶
物質が磁場に反応する性質を磁性というが、ミクロな世界では電子や原子核が非常に小さな磁性を持ち、スピンと呼ばれている。大雑把には、スピンとは小さな磁石のことであると思って良い。私たちが日常目にする磁石は、物質中の電子(原子核)のスピンが示す小さな磁性が綺麗に揃った結果、巨大な磁性が現れたものなのである。
イジングモデルは、そんな物質中のスピンを振る舞いを記述するモデルで、磁石の本質を取り出したモデルとして考案された。定義は以下の通りである。
このモデルを構成する\(Z_i Z_{i+1}\)は隣り合う量子ビット間の相互作用を表している。\(J>0\)の時、 \((Z_i,Z_{i+1})=(1,1),(-1,-1)\) で \(Z_i Z_{i+1}=1\)、\((Z_i,Z_{i+1})=(1,-1),(-1,1)\) で \(Z_i Z_{i+1}=-1\) だから、エネルギーが低いのはスピンが互い違いになる場合\(|010101\cdots\rangle\)である。逆に\(J<0\)ではスピンが揃った場合 \(|000\cdots\rangle, |111\cdots\rangle\) がエネルギーが低く安定になり、系は巨大な磁化を持った磁石として振る舞う。
さて、実はこのモデルそのものは量子コンピュータを使わなくても簡単に解けてしまうが、まずはこのモデルでトロッター分解の基本を実装してみよう。\(e^{-iHt}\)のトロッター分解は(1)式で導いているので、必要になるのは\(e^{-i \delta Z_i Z_{i+1}}\)というゲートをどのように実装するかである。これは次のようにすれば良い(計算して確かめてみてほしい):
今回はz方向の全磁化
[ ]:
## Google Colaboratoryの場合・Qulacsがインストールされていないlocal環境の場合のみ実行してください
!pip install qulacs
[1]:
#必要なライブラリをインポート
from qulacs import QuantumState,QuantumCircuit, Observable, PauliOperator
from qulacs.gate import X,Z,RX,RY,RZ,CNOT,merge,DenseMatrix,add
from qulacs.state import inner_product
import matplotlib.pyplot as plt
import numpy as np
%matplotlib inline
[2]:
# 今回は粒子6個の系を想定する。
nqubits = 6
# ダイナミクスをシミュレーションする時間
t = 1.0
# トロッター分解の分割数
M = 100
# 時間の刻み幅
delta = t/M
## 全磁化に対応するオブザーバブルを準備しておく
magnetization_obs = Observable(nqubits)
for i in range(nqubits):
magnetization_obs.add_operator(PauliOperator("Z "+str(i), 1.0/nqubits))
## 初期状態は|000000>
state = QuantumState(nqubits)
state.set_zero_state()
# トロッター分解の1回分、e^{iZ_1Z_2*delta}*e^{iZ_2Z_3*delta}*...e^{iZ_nZ_1*delta} を量子ゲートに変換
circuit_trotter_Ising = QuantumCircuit(nqubits)
for i in range(nqubits):
circuit_trotter_Ising.add_CNOT_gate(i,(i+1)%(nqubits))
circuit_trotter_Ising.add_RZ_gate((i+1)%nqubits,2*delta) ## RZ(a)=exp(i*a/2*Z)に注意
circuit_trotter_Ising.add_CNOT_gate(i,(i+1)%(nqubits))
## 時間と磁化を記録するリスト
x = [i*delta for i in range(M+1)]
y = []
#t=0の時の全磁化のみ先に計算
y.append( magnetization_obs.get_expectation_value(state) )
#t=0以降の全磁化を計算
for i in range(M):
# delta=t/Mだけ時間発展
circuit_trotter_Ising.update_quantum_state(state)
# 磁化を計算して記録
y.append(magnetization_obs.get_expectation_value(state))
#グラフの描画
plt.xlabel("time")
plt.ylabel("Value of magnetization")
plt.title("Dynamics of Ising model")
plt.plot(x, y, "-")
plt.show()

この結果を見ると分かるように、\(z\)方向の全磁化は一定である(縦軸のスケールにも注意)。 実は、イジングモデルでは\(z\)方向の相互作用しかないので、\(z\)方向の全磁化も保存してしまうのだ。これでは面白くないので、次は\(x\)軸方向に磁場を加えた横磁場イジングモデルの時間発展を見てみよう。
量子ダイナミクスの実装(2):横磁場イジングモデル¶
イジングモデルに、\(x\)軸方向の一様な磁場をかけた横磁場イジングモデルを考えよう。
この\(h\)は横磁場の強さを表す係数で、\(X_i\)は\(i\)番目の粒子の\(x\)方向の磁化を表すパウリ演算子(オブザーバブル)である。
[3]:
# 今回は粒子6個の系を想定する。
nqubits = 6
# ダイナミクスをシミュレーションする時間
t = 3.0
# トロッター分解の分割数
M = 100
# 時間の刻み幅
delta = t/M
## 横磁場の強さ
h = 3.
## 全磁化に対応するオブザーバブルを準備しておく
magnetization_obs = Observable(nqubits)
for i in range(nqubits):
magnetization_obs.add_operator(PauliOperator("Z "+str(i), 1.0/nqubits))
## 初期状態は|000000>
state = QuantumState(nqubits)
state.set_zero_state()
# トロッター分解の1回分、
# e^{iZ_1Z_2*delta}*e^{iZ_2Z_3*delta}*...e^{iZ_nZ_1*delta} * e^{i X_1*delta}*...*e^{i X_n*delta} を量子ゲートに変換
circuit_trotter_transIsing = QuantumCircuit(nqubits)
for i in range(nqubits):
circuit_trotter_transIsing.add_CNOT_gate(i,(i+1)%(nqubits))
circuit_trotter_transIsing.add_RZ_gate((i+1)%nqubits,2*delta) ## RZ(a)=exp(i*a/2*Z)に注意
circuit_trotter_transIsing.add_CNOT_gate(i,(i+1)%(nqubits))
circuit_trotter_transIsing.add_RX_gate(i, 2*delta*h) ## RX(a)=exp(i*a/2*X)に注意
## 時間と磁化を記録するリスト
x = [i*delta for i in range(M+1)]
y = []
#t=0の時の全磁化のみ先に計算
y.append( magnetization_obs.get_expectation_value(state) )
#t=0以降の全磁化を計算
for i in range(M):
# delta=t/Mだけ時間発展
circuit_trotter_transIsing.update_quantum_state(state)
# 磁化を計算して記録
y.append(magnetization_obs.get_expectation_value(state))
#グラフの描画
plt.xlabel("time")
plt.ylabel("Value of magnetization")
plt.title("Dynamics of transverse Ising model")
plt.plot(x, y, "-")
plt.show()

量子ダイナミクスの実装(3):厳密解との比較¶
トロッター分解には誤差がある。上で計算したダイナミクスがどれほどの精度のなのか、\(e^{-iHt}\)を直接計算した厳密なダイナミクスと比べてみよう。
[4]:
# 今回は粒子6個の系を想定する。
nqubits = 6
# ダイナミクスをシミュレーションする時間
t = 3.0
# トロッター分解の分割数
M = 100
# 時間の刻み幅
delta = t/M
## 横磁場の強さ
h = 3.
## 全磁化に対応するオブザーバブル.
magnetization_obs = Observable(nqubits)
for i in range(nqubits):
magnetization_obs.add_operator(PauliOperator("Z "+str(i), 1.0))
## 初期状態は|000000>
state_trotter = QuantumState(nqubits)
state_trotter.set_zero_state()
state_exact = QuantumState(nqubits)
state_exact.set_zero_state()
# トロッター分解の1回分、
# e^{iZ_1Z_2*delta}*e^{iZ_2Z_3*delta}*...e^{iZ_nZ_1*delta} * e^{i X_1*delta}*...*e^{i X_n*delta} を量子ゲートに変換
circuit_trotter_transIsing = QuantumCircuit(nqubits)
for i in range(nqubits):
circuit_trotter_transIsing.add_CNOT_gate(i,(i+1)%(nqubits))
circuit_trotter_transIsing.add_RZ_gate((i+1)%nqubits,2*delta) ## RZ(a)=exp(i*a/2*Z)に注意
circuit_trotter_transIsing.add_CNOT_gate(i,(i+1)%(nqubits))
circuit_trotter_transIsing.add_RX_gate(i, 2*delta*h) ## RX(a)=exp(i*a/2*X)に注意
# e^{-iHt}を直接対角化する。Hの行列表現を得るために、gateを生成してそのmatrixを取得する
zz_matrix = np.array([[1,0,0,0],[0,-1,0,0],[0,0,-1,0],[0,0,0,1]]) ## Z_i*Z_{i+1}の行列表示
hx_matrix = h*np.array( [ [0,1], [1,0] ] )
zz = DenseMatrix([0,1], zz_matrix) ## 0~1間の相互作用
hx = DenseMatrix(0, hx_matrix) ## 0サイトへの横磁場
## qulacs.gate.addを用いて、1以降のサイトの相互作用と横磁場を足していく
for i in range(1, nqubits):
zz = add(zz, DenseMatrix([i,(i+1)%nqubits], zz_matrix))
hx = add(hx, DenseMatrix(i, hx_matrix) )
## 最終的なハミルトニアン
ham = add(zz, hx)
matrix = ham.get_matrix() #行列の取得
eigenvalue, P = np.linalg.eigh(np.array(matrix)) #取得した行列の固有値、固有ベクトルを取得
## e^{-i*H*delta}を行列として作る
e_iHdelta = np.diag(np.exp(-1.0j*eigenvalue*delta))
e_iHdelta = np.dot(P, np.dot(e_iHdelta, P.T))
## 回路に変換
circuit_exact_transIsing = QuantumCircuit(nqubits)
circuit_exact_transIsing.add_dense_matrix_gate( np.arange(nqubits), e_iHdelta)
## 時間と磁化を記録するリスト
x = [i*delta for i in range(M+1)]
y_trotter = []
y_exact = []
#t=0の時の全磁化のみ先に計算
y_trotter.append( magnetization_obs.get_expectation_value(state_trotter) )
y_exact.append( magnetization_obs.get_expectation_value(state_exact) )
#t=0以降の全磁化を計算
for i in range(M):
# delta=t/Mだけ時間発展
circuit_trotter_transIsing.update_quantum_state(state_trotter)
circuit_exact_transIsing.update_quantum_state(state_exact)
# 磁化を計算して記録
y_trotter.append( magnetization_obs.get_expectation_value(state_trotter) )
y_exact.append( magnetization_obs.get_expectation_value(state_exact) )
#グラフの描画
plt.xlabel("time")
plt.ylabel("Value of magnetization")
plt.title("Dynamics of transverse Ising model")
plt.plot(x, y_trotter, "-", label="Trotter")
plt.plot(x, y_exact, "-", label="exact")
plt.legend()
plt.show()

この範囲では、どうやらほぼ一致しているようだ。誤差を見てみよう。
[5]:
#グラフの描画
plt.xlabel("time")
plt.ylabel("Value of magnetization")
plt.title("Error")
plt.plot(x, np.abs(np.array(y_trotter) - np.array(y_exact)), "-", label="Trotter")
plt.legend()
plt.show()

興味のある読者は、分割数\(M\)を荒くしたり、時間\(t\)を大きくしたり色々と試してみてほしい。
第5章 変分量子回路に基づくアルゴリズム¶
この章では、NISQデバイスの応用として最も重要である、変分量子回路を用いた量子・古典ハイブリットアルゴリズムについて学んでいく。NISQデバイスを実際の問題の応用していく上で極めて重要なアルゴリズムばかりなので、しっかりと身につけよう。
5-1. Variational Quantum Eigensolver(VQE)アルゴリズム¶
まず、物質科学と量子化学への応用が期待されているアルゴリズム、VQE (Variational Quantum Eigensolver : 変分量子固有値ソルバー) アルゴリズムを紹介する。このアルゴリズムは物質の基底エネルギーの値を求めるのに用いられる。
背景¶
分子や物質の性質は、ほとんどの場合その中に存在する電子の動きによって決まっていると考えられている。したがって、電子を支配する方程式であるシュレディンガー方程式(4-1節も参照)
を解けば、分子や物質の性質を計算によって明らかにすることができる。ここで \(H\) はハミルトニアンと呼ばれる演算子 (行列) であり、分子の形など、系の詳細によって決まる。 \(H|\psi\rangle = E|\psi\rangle\) の形からもわかるように、シュレディンガー方程式を解くということは、ハミルトニアン \(H\) の固有値問題を解き、固有値 \(E_i\) と対応する固有ベクトル (固有状態とも呼ばれる) \(|\phi_i\rangle\) を求めることと同値である。このとき固有値 \(E_i\) は、固有状態 \(|\phi_i\rangle\) の持つエネルギーとなる。
この問題を解くことを、化学分野では量子化学計算と呼び、現在も問題の解法について活発な研究が進められている。というのも、この問題は電子の数に対して指数的に難しくなるため、分子のサイズや結晶の単位格子が大きくなれば、厳密に解くことは実質不可能になる。そこで様々な近似解法が研究されているのだ。この問題の難しさは、\(H\) が量子状態に作用する行列であって、粒子数が多くなればその次元が指数的に大きくなっていくところを原因としている。
変分法¶
極限的な環境を考えない限り、電子の状態は通常一番エネルギーの低い状態、すなわち基底状態に落ちていることがほとんどである。そこで固有状態の中でも、特に基底状態が興味を持たれる。
非常に大きな次元を持つハミルトニアンの基底状態を求めるのに有力な手法として、変分法がある。変分法では、任意の状態 \(|\psi\rangle\) についてそのエネルギー期待値が必ず基底エネルギー \(E_0\) よりも高くなる、つまり
となることを使う。(このことは変分原理とも呼ばれる。) このことから、ランダムに状態 \(\{|\psi_i\rangle\}\) をたくさん持ってきて、その中で一番エネルギーが低い状態を見つければ、それは \(\{|\psi_i\rangle\}\) の中で最も基底状態に近い状態であると考えられるだろう。
実際には、ランダムに状態を持ってきていたのでは、基底状態に近い状態が得られる確率は系のサイズに対して指数的に小さくなってしまう。そこで普通は物理的・化学的直観や経験をもとにパラメータ付きの量子状態 \(|\psi(\theta)\rangle\) (\(\theta\) はパラメータ) を構成し、
を最小化するような \(\theta\) を見つけるというアプローチがとられる。古典コンピュータ上で計算をする都合上、これまでは量子状態 \(|\psi(\theta)\rangle\) は、古典計算機でも効率的に記述できるものの中から選ぶ必要があった。
VQEとは¶
VQE とは、変分法に対して、量子コンピュータで効率的に記述できる量子状態を用いて基底状態を探索するアルゴリズムである。
アルゴリズムは以下のようになる。
量子コンピュータ上で量子状態\(|\psi(\theta)\rangle\)を生成する。
\(\langle H(\theta)\rangle = \langle \psi(\theta)|H|\psi(\theta)\rangle\) を測定する。
測定結果をもとに、古典コンピュータによって \(\langle\psi(\theta)|H|\psi(\theta)\rangle\) が小さくなるような \(\theta\) を決定する。
これを \(\langle\psi(\theta)|H|\psi(\theta)\rangle\) が収束するまで繰り返すことで、近似的な基底状態を求める。
(図の引用:参考文献[1])
実装例¶
量子ゲートの準備¶
[2]:
import numpy as np
nqubits = 2
#パウリ演算子を準備する。
pI = np.array([[1+0.0j,0+0.0j],[0+0.0j,1+0.0j]])
pX = np.array([[0+0.0j,1+0.0j],[1+0.0j,0+0.0j]])
pZ = np.array([[1+0.0j,0+0.0j],[0+0.0j,-1+0.0j]])
pY = np.array([[0+0.0j,-1.0j],[0.0+1.0j,0.0+0.0j]])
pHad = (pX+pZ)/np.sqrt(2)
pP0 = (pI+pZ)/2
pP1 = (pI-pZ)/2
[3]:
#パウリ演算子を1量子ゲートに変換する。
X=[1]*(nqubits)
Y=[1]*(nqubits)
Z=[1]*(nqubits)
H=[1]*(nqubits)
P0=[1]*(nqubits)
P1=[1]*(nqubits)
for i in range(nqubits):
for j in range(nqubits):
if(i != j):
X[i] = np.kron(pI,X[i])
Y[i] = np.kron(pI,Y[i])
Z[i] = np.kron(pI,Z[i])
H[i] = np.kron(pI,H[i])
P0[i] = np.kron(pI,P0[i])
P1[i] = np.kron(pI,P1[i])
else:
X[i] = np.kron(pX,X[i])
Y[i] = np.kron(pY,Y[i])
Z[i] = np.kron(pZ,Z[i])
H[i] = np.kron(pHad,H[i])
P0[i] = np.kron(pP0,P0[i])
P1[i] = np.kron(pP1,P1[i])
Ide = np.eye(2**nqubits)
[4]:
#2量子ゲートを準備する。
CZ = [[0 for i in range(nqubits)] for j in range(nqubits)]
CX = [[0 for i in range(nqubits)] for j in range(nqubits)]
for i in range(nqubits):
for j in range(nqubits):
CZ[i][j]= (P0[i]+np.dot(P1[i],Z[j]))
CX[i][j]= (P0[i]+np.dot(P1[i],X[j]))
[5]:
#変分量子ゲート(X,Y,Zに関する回転の角度を指定できるゲート)を準備する。
from scipy.linalg import expm
def RX(target,angle):
return expm(-0.5*angle*1.j*X[target])
def RY(target,angle):
return expm(-0.5*angle*1.j*Y[target])
def RZ(target,angle):
return expm(-0.5*angle*1.j*Z[target])
[6]:
#初期状態|0000・・・0>を準備する。
def StateZeros(nqubits):
State = np.zeros(2**nqubits)
State[0]=1
return State
ハミルトニアンを準備する¶
参考文献[1]の Supplementary Information の表から、H-He間の距離が\(0.9\)オングストロームの時のハミルトニアンの係数を読み取り、定義する。このハミルトニアンの最小エネルギー固有状態を求めれば、様々なH-He\(^+\)分子の性質を知ることができる。
※ このハミルトニアンは、電子-原子核間のクーロン相互作用および電子同士のクーロン相互作用の大きさから導かれている。詳細は第6章の量子化学計算に関する項で学ぶことになる。
[8]:
M = (-3.8505 * Ide - 0.2288 * X[1] - 1.0466 * Z[1] - 0.2288 * X[0] + 0.2613 * np.dot(X[0],X[1]) + \
0.2288 *np.dot(X[0],Z[1]) - 1.0466*Z[0] + 0.2288* np.dot(Z[0],X[1]) + 0.2356 * np.dot(Z[0],Z[1]) )/2
量子回路を準備する¶
論文と全く同じ形式の変分量子回路を以下のように実装する。
[14]:
n_param = 6
def TwoQubitPQC(phi):
state = StateZeros(2)
state = np.dot(RX(0,phi[0]),state)
state = np.dot(RZ(0,phi[1]),state)
state = np.dot(RX(1,phi[2]),state)
state = np.dot(RZ(1,phi[3]),state)
state = np.dot(CX[1][0],state)
state = np.dot(RZ(1,phi[4]),state)
state = np.dot(RX(1,phi[5]),state)
return state
量子状態のエネルギー期待値を測定する¶
変分量子回路によって出力される状態のエネルギー期待値を算出する関数を以下のように定義する。
[15]:
def ExpectVal(Operator,State):
BraState = np.conjugate(State.T) #列ベクトルを行ベクトルへ変換
tmp = np.dot(BraState,np.dot(Operator,State)) #行列を列ベクトルと行ベクトルではさむ
return np.real(tmp) #要素の実部を取り出す
エネルギー期待値の最小化¶
エネルギー期待値の最小化を、scipy.optimize.minimize
に実装されている Powell 法によって行う。Powell 法は勾配情報を使わない最適化手法の一つである。パラメータの初期値はランダムに指定する。
[32]:
import scipy.optimize
import matplotlib.pyplot as plt
def cost(phi):
return ExpectVal(M, TwoQubitPQC(phi))
cost_val = [] #コスト関数の変化を保存するための関数
#この関数がiteration ごとに呼ばれる。
def callback(phi):
global cost_val
cost_val.append(cost(phi))
init = np.random.rand(n_param)
callback(init)
res = scipy.optimize.minimize(cost, init,
method='Powell',
callback=callback)
plt.plot(cost_val)
plt.xlabel("iteration")
plt.ylabel("energy expectation value")
plt.show()

ハミルトニアンを対角化して得られた厳密なエネルギーと比べることで、VQE によって算出された値が正しいか検証してみよう。
[23]:
import scipy.linalg
l, P = scipy.linalg.eigh(M)
print(l[0]) #最小固有値
print(cost(res.x)) #VQEの結果
-2.8626207640766816
-2.8623984117519257
Powell法で算出した固有値と一致はしていないものの、小数第3位まで同じであるので、殆ど正しいと言っていいだろう。
次に回路の出力にノイズが存在するケースでも検証してみよう。NISQでは出力にエラー(ノイズ)がのることが避けられないため、ノイズありでもアルゴリズムが動くのか・どの程度のノイズまでなら耐えられるのかといった検証は非常に重要である。
[24]:
def cost(phi):
return ExpectVal(M,TwoQubitPQC(phi))+np.random.normal(0,0.01)
def callback(phi):
global cost_val
cost_val.append(cost(phi))
[34]:
cost_val=[] # コスト関数の履歴
init = np.random.rand(6)
callback(init)
res = scipy.optimize.minimize(cost, init,
method='Powell',
callback=callback)
plt.plot(cost_val)
plt.xlabel("iteration")
plt.ylabel("energy expectation value")
plt.show()
print(cost(res.x))

-2.862398401331204
ノイズが小さければほとんど同じような振る舞いで最適化が行えることがわかる。(興味のある読者はぜひノイズを大きくして実験してみてほしい。)
参考文献¶
[1] A. Peruzzo et al. , “A variational eigenvalue solver on a photonic quantum processor“ Nat. Commun. 5:4213 doi: 10.1038/ncomms5213 (2014)
5-2. Quantum Circuit learning¶
以下では、まずアルゴリズムの概要と具体的な学習の手順を紹介し、最後に量子シミュレータQulacsを用いた実装例を提示する。
QCLの概要¶
学習の手順¶
学習データ \(\{(x_i, y_i)\}_i\) を用意する(\(x_i\)は入力データ、\(y_i\)は\(x_i\)から予測したい正解データ(教師データ))
\(U_{\text{in}}(x)\)という、入力\(x\)から何らかの規則で決まる回路を用意し、\(x_i\)の情報を埋め込んだ入力状態\(\{|\psi_{\rm in}(x_i)\rangle\}_i = \{U_{\text{in}}(x_i)|0\rangle\}_i\) を作る
入力状態に、パラメータ\(\theta\)に依存したゲート\(U(\theta)\)を掛けたものを出力状態\(\{|\psi_{\rm out}(x_i, \theta)\rangle = U(\theta)|\psi_{\rm in}(x_i)\rangle \}_i\)とする
出力状態のもとで何らかのオブザーバブルを測定し、測定値を得る(例:1番目のqubitの\(Z\)の期待値\(\langle Z_1\rangle = \langle \psi_{\rm out} |Z_1|\psi_{\rm out} \rangle\))
\(F\)を適当な関数(sigmoidとかsoftmaxとか定数倍とか何でもいい)として、\(F(測定値_i)\)をモデルの出力\(y(x_i, \theta)\)とする
正解データ\(\{y_i\}_i\)とモデルの出力\(\{y(x_i, \theta)\}_i\)の間の乖離を表す「コスト関数\(L(\theta)\)」を計算する
コスト関数を最小化する\(\theta=\theta^*\)を求める
\(y(x, \theta^*)\)が、所望の予測モデルである
(QCLでは、入力データ \(x\) をまず\(U_{\text{in}}(x)\)を用いて量子状態に変換し、そこから変分量子回路\(U(\theta)\)と測定等を用いて出力\(y\)を得る(図では出力は\(\langle B(x,\theta)\rangle\))。出典:参考文献[1]の図1を改変)
量子シミュレータQulacsを用いた実装¶
以下では関数の近似のデモンストレーションとして、sin関数 \(y=\sin(\pi x)\) のフィッティングを行う。
[30]:
import numpy as np
import matplotlib.pyplot as plt
from functools import reduce
[31]:
######## パラメータ #############
nqubit = 3 ## qubitの数
c_depth = 3 ## circuitの深さ
time_step = 0.77 ## ランダムハミルトニアンによる時間発展の経過時間
## [x_min, x_max]のうち, ランダムにnum_x_train個の点をとって教師データとする.
x_min = - 1.; x_max = 1.;
num_x_train = 50
## 学習したい1変数関数
func_to_learn = lambda x: np.sin(x*np.pi)
## 乱数のシード
random_seed = 0
## 乱数発生器の初期化
np.random.seed(random_seed)
学習データの準備¶
[32]:
#### 教師データを準備
x_train = x_min + (x_max - x_min) * np.random.rand(num_x_train)
y_train = func_to_learn(x_train)
# 現実のデータを用いる場合を想定し、きれいなsin関数にノイズを付加
mag_noise = 0.05
y_train = y_train + mag_noise * np.random.randn(num_x_train)
plt.plot(x_train, y_train, "o"); plt.show()

入力状態の構成¶
[ ]:
## Google Colaboratoryの場合・Qulacsがインストールされていないlocal環境の場合のみ実行してください
!pip install qulacs
[33]:
# 初期状態の作成
from qulacs import QuantumState, QuantumCircuit
state = QuantumState(nqubit) # 初期状態 |000>
state.set_zero_state()
print(state.get_vector())
[1.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j]
[34]:
# xをエンコードするゲートを作成する関数
def U_in(x):
U = QuantumCircuit(nqubit)
angle_y = np.arcsin(x)
angle_z = np.arccos(x**2)
for i in range(nqubit):
U.add_RY_gate(i, angle_y)
U.add_RZ_gate(i, angle_z)
return U
[35]:
# 入力状態を試す
x = 0.1 # 適当な値
U_in(x).update_quantum_state(state) # U_in|000>の計算
print(state.get_vector())
[-6.93804351e-01+7.14937415e-01j -3.54871219e-02-3.51340074e-02j
-3.54871219e-02-3.51340074e-02j 1.77881430e-03-1.76111422e-03j
-3.54871219e-02-3.51340074e-02j 1.77881430e-03-1.76111422e-03j
1.77881430e-03-1.76111422e-03j 8.73809020e-05+9.00424970e-05j]
変分量子回路\(U(\theta)\)の構成¶
次に、最適化すべき変分量子回路\(U(\theta)\)を作っていく。これは以下の3手順で行う。
横磁場イジングハミルトニアン作成
回転ゲート作成
1.と2.のゲートを交互に組み合わせ、1つの大きな変分量子回路\(U(\theta)\)を作る
4-2節で学んだ横磁場イジングモデルによる時間発展を行い量子回路の複雑性(エンタングルメント)を増すことで、モデルの表現力を高める。(本パートは、詳細を知りたい読者以外は読み飛ばしていただいて構わない。)
横磁場イジングモデルのハミルトニアンは以下の通りで、\(U_{\text{rand}} = e^{-iHt}\)という時間発展演算子を定義する。
ここで係数\(a\), \(J\)は\([-1, 1]\)の一様分布である。
[36]:
## 基本ゲート
from qulacs.gate import X, Z
I_mat = np.eye(2, dtype=complex)
X_mat = X(0).get_matrix()
Z_mat = Z(0).get_matrix()
[37]:
## fullsizeのgateをつくる関数.
def make_fullgate(list_SiteAndOperator, nqubit):
'''
list_SiteAndOperator = [ [i_0, O_0], [i_1, O_1], ...] を受け取り,
関係ないqubitにIdentityを挿入して
I(0) * ... * O_0(i_0) * ... * O_1(i_1) ...
という(2**nqubit, 2**nqubit)行列をつくる.
'''
list_Site = [SiteAndOperator[0] for SiteAndOperator in list_SiteAndOperator]
list_SingleGates = [] ## 1-qubit gateを並べてnp.kronでreduceする
cnt = 0
for i in range(nqubit):
if (i in list_Site):
list_SingleGates.append( list_SiteAndOperator[cnt][1] )
cnt += 1
else: ## 何もないsiteはidentity
list_SingleGates.append(I_mat)
return reduce(np.kron, list_SingleGates)
[38]:
#### ランダム磁場・ランダム結合イジングハミルトニアンをつくって時間発展演算子をつくる
ham = np.zeros((2**nqubit,2**nqubit), dtype = complex)
for i in range(nqubit): ## i runs 0 to nqubit-1
Jx = -1. + 2.*np.random.rand() ## -1~1の乱数
ham += Jx * make_fullgate( [ [i, X_mat] ], nqubit)
for j in range(i+1, nqubit):
J_ij = -1. + 2.*np.random.rand()
ham += J_ij * make_fullgate ([ [i, Z_mat], [j, Z_mat]], nqubit)
## 対角化して時間発展演算子をつくる. H*P = P*D <-> H = P*D*P^dagger
diag, eigen_vecs = np.linalg.eigh(ham)
time_evol_op = np.dot(np.dot(eigen_vecs, np.diag(np.exp(-1j*time_step*diag))), eigen_vecs.T.conj()) # e^-iHT
[39]:
time_evol_op.shape
[39]:
(8, 8)
[40]:
# qulacsのゲートに変換しておく
from qulacs.gate import DenseMatrix
time_evol_gate = DenseMatrix([i for i in range(nqubit)], time_evol_op)
先ほど構成したランダム横磁場イジングモデルによる時間発展\(U_{\text{rand}}\)と、 \(j \:(=1,2,\cdots n)\)番目の量子ビットに回転ゲート
という変分量子回路を用いる。全部で \(3nd\) 個のパラメータがあることになる。各\(\theta\)の初期値は\([0, 2\pi]\)の一様分布にとっておく。
[41]:
from qulacs import ParametricQuantumCircuit
[42]:
# output用ゲートU_outの組み立て&パラメータ初期値の設定
U_out = ParametricQuantumCircuit(nqubit)
for d in range(c_depth):
U_out.add_gate(time_evol_gate)
for i in range(nqubit):
angle = 2.0 * np.pi * np.random.rand()
U_out.add_parametric_RX_gate(i,angle)
angle = 2.0 * np.pi * np.random.rand()
U_out.add_parametric_RZ_gate(i,angle)
angle = 2.0 * np.pi * np.random.rand()
U_out.add_parametric_RX_gate(i,angle)
[43]:
# パラメータthetaの初期値のリストを取得しておく
parameter_count = U_out.get_parameter_count()
theta_init = [U_out.get_parameter(ind) for ind in range(parameter_count)]
[44]:
theta_init
[44]:
[6.007250646127814,
4.046309757767312,
2.663159813474645,
3.810080933381979,
0.12059442161498848,
1.8948504571449056,
4.14799267096281,
1.8226113595664735,
3.88310546309581,
2.6940332019609157,
0.851208649826403,
1.8741631278382846,
3.5811951525261123,
3.7125630518871535,
3.6085919651139333,
4.104181793964002,
4.097285684838374,
2.71068197476515,
5.633168398253273,
2.309459341364396,
2.738620094343915,
5.6041197193647925,
5.065466226710866,
4.4226624059922806,
0.6297441057449945,
5.777279648887616,
4.487710439107831]
後の便利のため、\(U(\theta)\)のパラメータ\(\theta\)を更新する関数を作成しておく。
[45]:
# パラメータthetaを更新する関数
def set_U_out(theta):
global U_out
parameter_count = U_out.get_parameter_count()
for i in range(parameter_count):
U_out.set_parameter(i, theta[i])
測定¶
[46]:
# オブザーバブルZ_0を作成
from qulacs import Observable
obs = Observable(nqubit)
obs.add_operator(2.,'Z 0') # オブザーバブル2 * Zを設定。ここで2を掛けているのは、最終的な<Z>の値域を広げるためである。未知の関数に対応するためには、この定数もパラメータの一つとして最適化する必要がある。
[47]:
obs.get_expectation_value(state)
[47]:
1.9899748742132404
一連の流れを関数にまとめる¶
ここまでの流れをまとめて、入力\(x_i\)からモデルの予測値\(y(x_i, \theta)\)を返す関数を定義する。
[48]:
# 入力x_iからモデルの予測値y(x_i, theta)を返す関数
def qcl_pred(x, U_out):
state = QuantumState(nqubit)
state.set_zero_state()
# 入力状態計算
U_in(x).update_quantum_state(state)
# 出力状態計算
U_out.update_quantum_state(state)
# モデルの出力
res = obs.get_expectation_value(state)
return res
コスト関数計算¶
コスト関数 \(L(\theta)\)は、教師データと予測データの平均二乗誤差(MSE)とする。
[49]:
# cost function Lを計算
def cost_func(theta):
'''
theta: 長さc_depth * nqubit * 3のndarray
'''
# U_outのパラメータthetaを更新
# global U_out
set_U_out(theta)
# num_x_train個のデータについて計算
y_pred = [qcl_pred(x, U_out) for x in x_train]
# quadratic loss
L = ((y_pred - y_train)**2).mean()
return L
[50]:
# パラメータthetaの初期値におけるコスト関数の値
cost_func(theta_init)
[50]:
1.3889259316193516
[51]:
# パラメータthetaの初期値のもとでのグラフ
xlist = np.arange(x_min, x_max, 0.02)
y_init = [qcl_pred(x, U_out) for x in xlist]
plt.plot(xlist, y_init)
[51]:
[<matplotlib.lines.Line2D at 0x1a24c6b5320>]

学習(scipy.optimize.minimizeで最適化)¶
ようやく準備が終わり、いよいよ学習を行う。ここでは簡単のため、勾配の計算式を与える必要のないNelder-Mead法を用いて最適化する。勾配を用いる最適化手法(例:BFGS法)を用いる場合は、勾配の便利な計算式が参考文献[1]で紹介されているので参照されたい。
[52]:
from scipy.optimize import minimize
[53]:
%%time
# 学習 (筆者のPCで1~2分程度かかる)
result = minimize(cost_func, theta_init, method='Nelder-Mead')
Wall time: 1min 21s
[54]:
# 最適化後のcost_functionの値
result.fun
[54]:
0.003987076559624744
[55]:
# 最適化によるthetaの解
theta_opt = result.x
print(theta_opt)
[7.17242144 5.4043736 1.27744316 3.09192904 0.13144047 2.13757354
4.58470259 2.01924008 2.96107066 2.91843537 1.0609229 1.70351774
6.41114609 6.25686828 2.41619471 3.69387805 4.07551328 1.47666316
3.4108701 2.28524042 1.75253621 6.47969129 3.18418337 1.58699008
1.2831137 4.82903335 5.95931349]
結果のプロット¶
[56]:
# U_outに最適化されたthetaを代入
set_U_out(theta_opt)
[57]:
# プロット
plt.figure(figsize=(10, 6))
xlist = np.arange(x_min, x_max, 0.02)
# 教師データ
plt.plot(x_train, y_train, "o", label='Teacher')
# パラメータθの初期値のもとでのグラフ
plt.plot(xlist, y_init, '--', label='Initial Model Prediction', c='gray')
# モデルの予測値
y_pred = np.array([qcl_pred(x, U_out) for x in xlist])
plt.plot(xlist, y_pred, label='Final Model Prediction')
plt.legend()
plt.show()

5.2c.Application of QCL to Machine Learning
において、代表的な機械学習のデータセットの一つであるIrisデータセットの分類に挑戦されたい。参考文献¶
コラム:Quantum Circuit Learningを用いた分類¶
ここではQCLの機械学習への応用例として、代表的な機械学習のデータセットの一つであるIrisデータセット(Fisherのあやめ)の分類を行う。関数の詳細はこのノートブックと同じフォルダに入っているqcl_prediction.py
にある。
※ このコラムでは、scikit-learn, pandas
を使用する。
[1]:
from qcl_classification import QclClassification
[2]:
import numpy as np
import matplotlib.pyplot as plt
%matplotlib inline
データの概要¶
まず、scikit-learnから、Irisデータセットを読み込む。
[3]:
# Irisデータセットの読み込み
import pandas as pd
from sklearn import datasets
iris = datasets.load_iris()
# 扱いやすいよう、pandasのDataFrame形式に変換
df = pd.DataFrame(iris.data, columns=iris.feature_names)
df['target'] = iris.target
df['target_names'] = iris.target_names[iris.target]
df.head()
[3]:
sepal length (cm) | sepal width (cm) | petal length (cm) | petal width (cm) | target | target_names | |
---|---|---|---|---|---|---|
0 | 5.1 | 3.5 | 1.4 | 0.2 | 0 | setosa |
1 | 4.9 | 3.0 | 1.4 | 0.2 | 0 | setosa |
2 | 4.7 | 3.2 | 1.3 | 0.2 | 0 | setosa |
3 | 4.6 | 3.1 | 1.5 | 0.2 | 0 | setosa |
4 | 5.0 | 3.6 | 1.4 | 0.2 | 0 | setosa |
ここで、列名の最初の4つ(sepal lengthなど)は、「がく」や花びらの長さ・幅を表しており、target, target_namesはそれぞれ品種番号(0,1,2)、品種名を表している。
[4]:
# サンプル総数
print(f"# of records: {len(df)}\n")
# 各品種のサンプル数
print("value_counts:")
print(df.target_names.value_counts())
# of records: 150
value_counts:
versicolor 50
setosa 50
virginica 50
Name: target_names, dtype: int64
各サンプルについて、sepal length(がくの長さ)など4種類のデータがあるが、これらのうちpetal length(花びらの長さ), petal width(花びらの幅)に着目して分析を行う。
[5]:
## 教師データ作成
# ここではpetal length, petal widthの2種類のデータを用いる。より高次元への拡張は容易である。
x_train = df.loc[:,['petal length (cm)', 'petal width (cm)']].to_numpy() # shape:(150, 2)
y_train = np.eye(3)[iris.target] # one-hot 表現 shape:(150, 3)
[6]:
# データ点のプロット
plt.figure(figsize=(8, 5))
for t in range(3):
x = x_train[iris.target==t][:,0]
y = x_train[iris.target==t][:,1]
cm = [plt.cm.Paired([c]) for c in [0,6,11]]
plt.scatter(x, y, c=cm[t], edgecolors='k', label=iris.target_names[t])
# label
plt.title('Iris dataset')
plt.xlabel('petal length (cm)')
plt.ylabel('petal width (cm)')
plt.legend()
plt.show()

QCLを用いた分類¶
以下ではQCLを用いた分類問題を解くクラスであるQclClassificationを用いて、実際にIrisデータセットが分類される様子を見る。
[7]:
# 乱数発生器の初期化(量子回路のパラメータの初期値に用いる)
random_seed = 0
np.random.seed(random_seed)
[8]:
# 量子回路のパラメータ
nqubit = 3 ## qubitの数。必要とする出力の次元数よりも多い必要がある
c_depth = 2 ## circuitの深さ
num_class = 3 ## 分類数(ここでは3つの品種に分類)
[9]:
# QclClassificationクラスをインスタンス化
qcl = QclClassification(nqubit, c_depth, num_class)
[10]:
# 最適化手法BFGS法を用いて学習を行う
import time
start = time.time()
res, theta_init, theta_opt = qcl.fit(x_train, y_train, maxiter=10)
print(f'elapsed time: {time.time() - start:.1f}s')
Initial parameter:
[2.74944154 5.60317502 6.0548717 2.40923412 4.97455513 3.32314479
3.56912924 5.8156952 0.44633272 0.54744954 0.12703594 5.23150478
4.88930306 5.46644755 6.14884039 5.02126135 2.89956035 4.90420945]
Initial value of cost function: 0.9218
============================================================
Iteration count...
Iteration: 1 / 10, Value of cost_func: 0.8144
Iteration: 2 / 10, Value of cost_func: 0.7963
Iteration: 3 / 10, Value of cost_func: 0.7910
Iteration: 4 / 10, Value of cost_func: 0.7852
Iteration: 5 / 10, Value of cost_func: 0.7813
Iteration: 6 / 10, Value of cost_func: 0.7651
Iteration: 7 / 10, Value of cost_func: 0.7625
Iteration: 8 / 10, Value of cost_func: 0.7610
Iteration: 9 / 10, Value of cost_func: 0.7574
Iteration: 10 / 10, Value of cost_func: 0.7540
============================================================
Optimized parameter:
[ 2.86824917 6.24554315 5.32897857 1.98106103 4.56865599 3.43562639
3.42852657 5.05621041 0.65309871 1.32475316 -0.62232819 5.72359395
4.27144455 5.69505507 5.8268154 5.44168286 2.90543743 4.74716602]
Final value of cost function: 0.7540
elapsed time: 17.3s
プロット¶
[11]:
# グラフ用の設定
h = .05 # step size in the mesh
X = x_train
x_min, x_max = X[:, 0].min() - .5, X[:, 0].max() + .5
y_min, y_max = X[:, 1].min() - .5, X[:, 1].max() + .5
xx, yy = np.meshgrid(np.arange(x_min, x_max, h), np.arange(y_min, y_max, h))
[12]:
# 各petal length, petal widthについて、モデルの予測値をプロットする関数
def decision_boundary(X, y, theta, title='(title here)'):
plt.figure(figsize=(8, 5))
# Plot the decision boundary. For that, we will assign a color to each
# point in the mesh [x_min, x_max]x[y_min, y_max].
qcl.set_input_state(np.c_[xx.ravel(), yy.ravel()])
Z = qcl.pred(theta) # モデルのパラメータθも更新される
Z = np.argmax(Z, axis=1)
# Put the result into a color plot
Z = Z.reshape(xx.shape)
plt.pcolormesh(xx, yy, Z, cmap=plt.cm.Paired)
# Plot also the training points
plt.scatter(X[:, 0], X[:, 1], c=y, edgecolors='k', cmap=plt.cm.Paired)
# label
plt.title(title)
plt.xlabel('petal length (cm)')
plt.ylabel('petal width (cm)')
plt.show()
[13]:
# パラメータthetaの初期値のもとでのグラフ
decision_boundary(x_train, iris.target, theta_init, title='Initial')

[14]:
# パラメータthetaの最適解のもとでのグラフ
decision_boundary(x_train, iris.target, theta_opt, title='Optimized')

5-2節ではQuantum circuit learningという量子機械学習手法について学んだ。このコラムでは、NISQデバイスを用いた機械学習手法である量子リザーバコンピューティングについて紹介する。
コラム:量子リザーバコンピューティング¶
リカレントニューラルネットワーク(Recurrent neural network, RNN)とは主として時系列データの学習に用いられるニューラルネットワークモデルの一種である。このコラムではRNNの一種であるリザーバコンピューティング(Reservoir computing)と、その量子版である量子リザーバコンピューティング(Quantum reservoir computing)について解説する。
必要なライブラリの動作を確認しているバージョンは下記のとおりである。
[1]:
import sys
import numpy as np
import scipy
import matplotlib
import matplotlib.pyplot as plt
import tensorflow as tf
import tqdm
import time
print("python: %s"%sys.version)
print("numpy: %s"%np.version.version)
print("scipy: %s"%scipy.version.version)
print("matplotlib: %s"%matplotlib.__version__)
print("tensorflow: %s"%tf.VERSION)
print("tqdm: %s"%tqdm._version.__version__)
python: 3.5.5 |Anaconda 4.2.0 (64-bit)| (default, Apr 7 2018, 04:52:34) [MSC v.1900 64 bit (AMD64)]
numpy: 1.14.3
scipy: 1.2.0
matplotlib: 2.1.2
tensorflow: 1.3.0
tqdm: 4.31.1
問題設定¶
時系列データとは入力の系列に時間的な相関が存在するようなデータである。例えば株価の推移、電気信号の入出力、文章における言葉の系列、音声信号などが時系列である。こうしたデータに対し、株価の予測、応答の解析、続く文章の予測、音声信号からの話者の識別、などさまざまなタスクがありうる。
時系列データに対して内部的な構造を調べたり将来の時系列を予測する際には、時間相関に関する構造を考慮した学習モデルを用いるのが有効である。RNNは時系列データの機械学習を行うために考案されたニューラルネットワークの一種である。RNNでは隠れ層のユニットからの出力が自身に回帰(リカレント)することで、過去の入力と現在の入力の二つに依存した演算を可能としている。この性質により、時間的な相関を踏まえた出力を行うことが可能となる。
このコラムではエコーする信号の学習タスクを例に、時系列の学習をどのように行うかを見ていく。この例では\(n_x\)次元の時間\(t\)に依存する信号\(x_t \in \mathbb{R}^{n_x}\)が入力として与えられ、\(n_y\)次元の時刻\(t\)に依存する信号\(y_t = f(\{x_T\}_{T=0}^{t}) \in \mathbb{R}^{n_y}\)を予測する回帰のタスクを考える。入力としてサイン波などの信号を考えよう。
[2]:
def sinwave(freq, phase, sampling_freq):
"""sinwave
"""
phase_series = np.linspace(0,1,sampling_freq) * freq + phase/2/np.pi
sequence = np.sin( phase_series*2*np.pi )
return sequence
def sqwave(freq,phase,sampling_freq):
"""square wave
"""
phase_series = np.linspace(0,1,sampling_freq) * freq + phase/2/np.pi
sequence = (np.mod(phase_series,1)<0.5).astype(np.float64)*2-1
return sequence
def sawwave(freq, phase, sampling_freq):
"""saw wave
"""
phase_series = np.linspace(0,1,sampling_freq) * freq + phase/2/np.pi
sequence = np.mod((phase_series*2+1.),2.)-1.
return sequence
def triwave(freq,phase,sampling_freq):
"""triangle wave
"""
phase_series = np.linspace(0,1,sampling_freq) * freq + phase/2/np.pi
sequence = -np.abs(np.mod(phase_series+0.25,1.)-0.5)*4+1
return sequence
def moving_noise(force,damp,cov,sampling_freq,seed=-1):
"""continuously moving noise signal
"""
if seed>0:
np.random.seed(seed)
x = 0.
v = np.random.rand()
sequence = [x]
for _ in range(sampling_freq-1):
v += np.random.normal()*force - cov*v - damp * x
x += v
sequence.append(sequence[-1]+v)
# 後の手続きのため、[-1,1]の範囲に正規化する
sequence -= (np.max(sequence) + np.min(sequence))/2
sequence/= np.max(np.abs(sequence))
return sequence
def fm_sinwave(freq, phase, fm_amp, fm_freq, sampling_freq):
"""frequency modulated sinwave
"""
time_series = np.linspace(0,1,sampling_freq)
phase_series = time_series * freq + phase/2/np.pi + fm_amp * np.sin(fm_freq*time_series*np.pi*2)
sequence = np.sin( phase_series*2*np.pi )
return sequence
それぞれ信号を生成すると以下のようになる。
[3]:
freq = 2
phase = np.pi*2 * np.random.rand()
sample = 1000
force = 0.1
damp = 0.01
cov = 0.01
fm_amp = 0.1
fm_freq = freq*3
plt.figure(figsize=(10,8))
plt.plot(sinwave(freq,phase,sample), label="sin wave")
plt.plot(sawwave(freq,phase,sample), label="saw wave")
plt.plot(sqwave(freq,phase,sample), label="square wave")
plt.plot(triwave(freq,phase,sample), label="triangle wave")
plt.plot(moving_noise(force,damp,cov,sample), label="moving noise")
plt.plot(fm_sinwave(freq,phase,fm_amp, fm_freq,sample), label="fm sin wave")
plt.legend(loc="lower right")
plt.show()

エコーする信号を予測するタスクでは\(n_x = n_y = 1\)かつ、\(y_t = f(\{x_T\}_{T=0}^{t}) = x_{t-t_{\rm delay}}\)である。\(t_{\rm delay}\)が正の場合、モデルが過去の記憶をどの程度保つかのテストとなり、\(t_{\rm delay}\)が負の場合、過去の系列から未来の信号を予測するタスクとなる。
[4]:
def generate_echo_sequence(sequence, delay):
sequence = np.roll(sequence,delay)
# 存在しない信号については0で埋める。
if delay > 0:
sequence[:delay]=0
elif delay<0:
sequence[delay:]=0
return sequence
下記のコードでエコーが生成出来ていることがわかる。
[5]:
x = sinwave(freq,phase,sample)
y_p10 = generate_echo_sequence(x,100)
y_m10 = generate_echo_sequence(x,-100)
plt.plot(x,label="input")
plt.plot(y_p10,label="delay=100")
plt.plot(y_m10,label="delay=-100")
plt.legend(loc="lower right")
plt.show()

RNNのモデルでは時間\(t\)に依存する\(n_h\)次元の隠れ層\(h_t \in \mathbb{R}^{n_h}\)を考える。なお、\(x_t, y_t, h_t\)は全て横ベクトルとして扱う。初期状態の隠れ層\(h_0\)は0に初期化するか、正規分布などでランダムに決めておく。 \(t\geq 1\)についての\(h_t\)は行列\(W_h, W_x\)およびバイアスベクトル\(b_h\)を用いて
で漸化式として与えられる。ここで\({\rm activate}_{\rm state}\)は非線形性を確保し値の値域を\([-1,1]\)に抑えるため、tanhやsigmoid関数を要素ごとに作用する操作がしばしば用いられる。以下のコードでは例としてtanhを例として紹介する。\(W_h \in \mathbb{R}^{n_h \times n_h}\)、\(W_x \in \mathbb{R}^{n_x \times n_h}\)、\(b_h \in \mathbb{R}^{n_h}\)はデータセットを説明するために最適化されるパラメータである。こうしたパラメータを訓練パラメータと呼ぶ。
時刻\(t\)での予測の出力\(\tilde{y}_t\)は\(h_t\)を用いて
で与えられる。\({\rm activate}_{\rm out}\)も適当なelement-wiseな非線形関数である。\(W_y \in \mathbb{R}^{n_h \times n_y}\)と\(b_y \in \mathbb{R}^{h_y}\)は訓練パラメータである。 教師データとして時系列\(\{x_t,y_t\}_{t=0}^T\)を与えられたとき、学習の目的は\(\tilde{y}_t\)が\(y_t\)に近くなるよう\(W_h, W_s, W_y\)を訓練パラメータとして最適化することである。
上記の二つの式はベクトルを結合し一つの大きな行列をかけるという形に直すことができ、数値計算上はこちらの方が簡便である。具体的には、二つのベクトル\(h_{t-1}, x_{t-1}\)と定数1をつなげたベクトル\((h_{t-1}, x_{t-1}, 1)\)と、行列\(W \in \mathbb{R}^{(n_h+n_x+1) \times n_h}\)を用いて以下のようにまとめることができる。
これを愚直に実装すると以下のようになる。
[6]:
sequence_length = 1000
hidden_unit_count = 10
delay = 5
x = sinwave(freq,phase,sequence_length)
y = generate_echo_sequence(x,delay)
W_state = np.random.rand( hidden_unit_count+2, hidden_unit_count )
W_out = np.random.rand( hidden_unit_count+1, 1 )
state = np.zeros( hidden_unit_count )
y_pred = []
for sequence_cursor in range(sequence_length):
new_state = np.tanh( np.concatenate( [state, [x[sequence_cursor],1] ] ) @ W_state )
output = np.tanh( np.concatenate( [new_state, [1]]) @ W_out )
output = np.squeeze(output,axis=0)
y_pred.append(output)
state = new_state
現状での信号を比較すると、でたらめな値を出していることがわかる。
[7]:
plt.plot(y,label="label")
plt.plot(y_pred,label="prediction")
plt.legend()
plt.show()

学習においては予測とラベルの差を測るロス関数を最小化する。ここではロス関数としてL2 distanceを採用する。従って訓練パラメータ\(W_{\rm state}, W_{\rm out}\)に依存する最小化するべき値\(L\)は
である。現状のロスは以下のように計算できる。
[8]:
loss = np.sum( (y_pred - y)**2/2 )
print("loss=%f"%loss)
loss=745.046656
我々のタスクは、入出力のペアをいくつか教師データとして与えられた状態から初めて、未知のテストデータの入力に対し出力の予測を行うことである。
素朴なRNNによる時系列回帰¶
リカレントニューラルネットワークは時刻ごとのユニットレイヤがそれぞれ独立したレイヤだと思えば、深さがシーケンスの長さに等しいフィードフォワードニューラルネットワークだと考えることができる。このことを用いて、通常のニューラルネットワークで用いられるような逆誤差伝播と確率勾配法を用いて訓練パラメータの最適化を行うことができる。
以下ではtensorflowを用いたできるだけ素朴な実装を挙げる。注意点として、長いシーケンスをそのまま展開し逆誤差伝播を行うと、展開されたレイヤが深くなるためになかなか学習が進まない。このため、シーケンスを適切な長さのバッチに切ってこまめに逆後差伝播を行い同時にRNNとなっているレイヤの最終状態を保存しておき、次のバッチでは保存している状態を初期状態として学習を再開する。
このことを踏まえてtensorflowで素朴に(BasicRNNCell
やstatic_rnn
といった補助関数をできるだけ用いないで)RNNを実装すると以下のようになる。
[9]:
class SimpleRecurrentNeuralNetwork(object):
def __init__(self):
self.session = None
def __del__(self):
if self.session is not None:
self.session.close()
def __feed_forward(self, input_sequence, initial_state):
W_state = tf.Variable(tf.random_normal([self.hidden_unit_count+2, self.hidden_unit_count], dtype=tf.float64), dtype = tf.float64)
W_out = tf.Variable(tf.random_normal([self.hidden_unit_count+1, 1], dtype=tf.float64), dtype=tf.float64)
state = initial_state
unstacked_input_sequence = tf.unstack(input_sequence, axis=1)
unstacked_output_sequence = []
for input_value in unstacked_input_sequence:
input_value = tf.reshape(input_value, [-1, 1])
bias_value = np.ones([self.sequence_count,1])
concatenated_state = tf.concat([state, input_value, bias_value], axis=1)
state = tf.tanh( tf.matmul(concatenated_state, W_state) )
bias_value = np.ones([self.sequence_count,1])
concatenated_state = tf.concat([state, bias_value], axis=1)
output_value = tf.matmul(concatenated_state, W_out)
unstacked_output_sequence.append(output_value)
final_state = state
output_sequence = tf.stack(unstacked_output_sequence, axis = 1)
output_sequence = tf.squeeze(output_sequence,axis=2)
return output_sequence, final_state
def generate_batch(self, sequence_list, batch_length):
sequence_count = sequence_list.shape[0]
sequence_batch = np.reshape(sequence_list, [sequence_count, -1, batch_length] )
sequence_batch = np.transpose(sequence_batch, [1,0,2])
return sequence_batch
def train(self, input_sequence_list, output_sequence_list, hidden_unit_count, batch_length = 10, learning_rate = 1e-5, epoch_count = 500):
assert(input_sequence_list.shape == output_sequence_list.shape)
if self.session is not None:
self.session.close()
tf.reset_default_graph()
self.sequence_count, self.sequence_length = input_sequence_list.shape
self.hidden_unit_count = hidden_unit_count
self.batch_length = batch_length
self.batch_count = self.sequence_length//self.batch_length
self.input_sequence_op = tf.placeholder(tf.float64, [None, self.batch_length])
self.output_sequence_op = tf.placeholder(tf.float64, [None, self.batch_length])
self.initial_state_op = tf.placeholder(tf.float64, [None, self.hidden_unit_count])
self.prediction_sequence_op, self.last_state_op = self.__feed_forward(self.input_sequence_op, self.initial_state_op)
self.loss_op = tf.nn.l2_loss(self.prediction_sequence_op - self.output_sequence_op)
optimizer = tf.train.GradientDescentOptimizer(learning_rate = learning_rate)
self.train_op = optimizer.minimize(self.loss_op/self.sequence_count)
init_op = tf.global_variables_initializer()
self.session = tf.Session()
self.session.run(init_op)
self.loss_history = []
input_sequence_batch = self.generate_batch(input_sequence_list, self.batch_length)
output_sequence_batch = self.generate_batch(output_sequence_list, self.batch_length)
epoch_range = tqdm.trange(epoch_count)
for _ in epoch_range:
last_state = np.zeros( [self.sequence_count, self.hidden_unit_count] )
loss_sum = 0
for batch_index in range(self.batch_count):
feed_dict = {
self.input_sequence_op: input_sequence_batch[batch_index],
self.output_sequence_op: output_sequence_batch[batch_index],
self.initial_state_op: last_state
}
_, loss, last_state = self.session.run([self.train_op, self.loss_op, self.last_state_op], feed_dict = feed_dict)
loss_sum += loss/self.sequence_count
self.loss_history.append(loss_sum)
epoch_range.set_description("loss=%.3f"%loss_sum)
def predict(self, input_sequence_list,output_sequence_list):
sequence_count, sequence_length = input_sequence_list.shape
input_sequence_batch = self.generate_batch(input_sequence_list, self.batch_length)
output_sequence_batch = self.generate_batch(output_sequence_list, self.batch_length)
batch_count = sequence_length //self.batch_length
last_state = np.zeros( [sequence_count, self.hidden_unit_count] )
prediction_sequence_batch_list = []
loss_sum = 0
for batch_index in range(batch_count):
feed_dict = {
self.input_sequence_op: input_sequence_batch[batch_index],
self.output_sequence_op: output_sequence_batch[batch_index],
self.initial_state_op: last_state
}
prediction_sequence_batch, last_state, loss = self.session.run([self.prediction_sequence_op, self.last_state_op, self.loss_op], feed_dict = feed_dict)
prediction_sequence_batch_list.append(prediction_sequence_batch)
loss_sum += loss/sequence_count
prediction_sequence_list = np.hstack(prediction_sequence_batch_list)
return prediction_sequence_list, loss_sum
このモデルを用いて先ほど定義した時系列を学習する。まずは教師データを生成しよう。
[10]:
def generate_data(sequence_count, sequence_length, delay):
input_sequence_list = []
for sequence_index in range(sequence_count):
r = sequence_index%6
if r==0:
input_sequence = sinwave(3.+np.random.rand()*3, np.random.rand()*2*np.pi, sequence_length)
elif r==1:
input_sequence = sqwave(3.+np.random.rand()*3, np.random.rand()*2*np.pi, sequence_length)
elif r==2:
input_sequence = triwave(3.+np.random.rand()*3, np.random.rand()*2*np.pi, sequence_length)
elif r==3:
input_sequence = sawwave(3.+np.random.rand()*3, np.random.rand()*2*np.pi, sequence_length)
elif r==4:
input_sequence = moving_noise(np.random.rand(), np.random.rand(), np.random.rand(), sequence_length)
elif r==5:
input_sequence = fm_sinwave(3.+np.random.rand()*3, np.random.rand()*2*np.pi, np.random.rand(), np.random.rand()*5, sequence_length)
else:
assert(0<=r and r<6)
exit(0)
input_sequence_list.append(input_sequence)
output_sequence_list = []
for input_sequence in input_sequence_list:
output_sequence = generate_echo_sequence(input_sequence, delay)
output_sequence_list.append(output_sequence)
input_sequence_list = np.array(input_sequence_list)
output_sequence_list = np.array(output_sequence_list)
assert(input_sequence_list.shape == (sequence_count, sequence_length))
assert(output_sequence_list.shape == (sequence_count, sequence_length))
return input_sequence_list, output_sequence_list
[11]:
sequence_count = 100
sequence_length = 400
delay = 5
input_sequence_list, output_sequence_list = \
generate_data(sequence_count, sequence_length, delay)
次に、作成した教師データを用いて学習を行う。
[12]:
model = SimpleRecurrentNeuralNetwork()
batch_length = 10
epoch_count = 300
learning_rate = 1e-3
hidden_unit_count = 10
model.train(input_sequence_list, output_sequence_list, hidden_unit_count, learning_rate = learning_rate, batch_length = batch_length, epoch_count = epoch_count)
loss=4.818: 100%|████████████████████████████████████████████████████████████████████| 300/300 [00:20<00:00, 16.61it/s]
学習結果の仮定でのロスのふるまいを表示すると以下のようになる。
[13]:
plt.plot(model.loss_history)
plt.yscale("log")
plt.show()

教師データに対する予測結果を見てみよう。
[14]:
prediction_sequence_list, loss = model.predict(input_sequence_list,output_sequence_list)
print("loss=%f"%loss)
def plot(input_sequence_list, output_sequence_list, prediction_sequence_list):
cmap = plt.get_cmap("tab10")
plt.figure(figsize=(12,10))
for index in range(6):
plt.subplot(6,1,index+1)
plt.plot(input_sequence_list[index],color=cmap(index),linestyle="--",label="input")
plt.plot(output_sequence_list[index],color=cmap(index),linestyle=":",label="label")
plt.plot(prediction_sequence_list[index],color=cmap(index),label="prediction")
plt.legend()
plt.show()
plot(input_sequence_list, output_sequence_list, prediction_sequence_list)
loss=3.689388

波形の種類によって学習の精度にばらつきはあるものの、データをそれなりにうまく説明できていることがわかる。なお、delayが小さい場合はほぼ入力と重なって見えることもあるが、計算するとその時刻での入力をそのまま出力する形になってしまった場合のロスより精度は高いことがわかる。
[15]:
trained_loss = np.sum((prediction_sequence_list-output_sequence_list)**2)/2/sequence_count
trivial_loss = np.sum((input_sequence_list-output_sequence_list)**2)/2/sequence_count
print("trained loss=%f"%trained_loss)
print("trivial loss=%f"%trivial_loss)
trained loss=3.689388
trivial loss=43.343652
次に、学習したモデルが教師データに過学習していないことを示すため、テストデータを別途に作成しその振る舞いを調べる。
[16]:
test_input_sequence_list, test_output_sequence_list = generate_data(sequence_count = sequence_count, sequence_length = sequence_length, delay = delay)
test_prediction_sequence_list, test_loss = model.predict(test_input_sequence_list,test_output_sequence_list)
print("test_loss=%f"%test_loss)
plot(test_input_sequence_list, test_output_sequence_list, test_prediction_sequence_list)
test_loss=3.808512

未知のデータに対しても教師データと同程度の予測結果が得られていることがわかる。(といっても位相の遅れや周波数しか変わらないが)
より高度なRNN¶
リカレントするレイヤのセルをより複雑で表現能力の高いものに置き換えることで、より高い表現能力を獲得すると期待される。精度での学習を期待することができる。tensorflowはこうした代表的なRNNのセルを簡単に扱えるインターフェイスを提供している。下記はtensorflowのRNNCellを利用した場合のコードである。
[17]:
class RecurrentNeuralNetwork_BasicCell(SimpleRecurrentNeuralNetwork):
def __feed_forward(self, input_sequence):
W_out = tf.Variable(tf.random_normal([self.hidden_unit_count+1, 1], dtype=tf.float64), dtype=tf.float64)
input_sequence = tf.expand_dims(input_sequence, axis=2)
unstacked_input_sequence = tf.unstack(input_sequence, axis=1)
cell = tf.contrib.rnn.BasicRNNCell(self.hidden_unit_count)
state_op = cell.zero_state(batch_size = self.sequence_count, dtype=tf.float64)
unstacked_state, final_state = tf.contrib.rnn.static_rnn(cell, unstacked_input_sequence, dtype=tf.float64, initial_state = state_op)
unstacked_output_sequence = []
for state in unstacked_state:
bias_value = np.ones([self.sequence_count,1])
concatenated_state = tf.concat([state, bias_value], axis=1)
output_value = tf.matmul(concatenated_state, W_out)
unstacked_output_sequence.append(output_value)
output_sequence = tf.stack(unstacked_output_sequence, axis = 1)
output_sequence = tf.squeeze(output_sequence,axis=2)
return output_sequence, final_state, state_op
def train(self, input_sequence_list, output_sequence_list, hidden_unit_count, batch_length = 10, learning_rate = 1e-5, epoch_count = 500):
assert(input_sequence_list.shape == output_sequence_list.shape)
if self.session is not None:
self.session.close()
tf.reset_default_graph()
self.sequence_count, self.sequence_length = input_sequence_list.shape
self.hidden_unit_count = hidden_unit_count
self.batch_length = batch_length
self.batch_count = self.sequence_length//self.batch_length
self.input_sequence_op = tf.placeholder(tf.float64, [None, self.batch_length])
self.output_sequence_op = tf.placeholder(tf.float64, [None, self.batch_length])
self.prediction_sequence_op, self.last_state_op, self.initial_state_op = self.__feed_forward(self.input_sequence_op)
self.loss_op = tf.nn.l2_loss(self.prediction_sequence_op - self.output_sequence_op)
optimizer = tf.train.GradientDescentOptimizer(learning_rate = learning_rate)
self.train_op = optimizer.minimize(self.loss_op/self.sequence_count)
init_op = tf.global_variables_initializer()
self.session = tf.Session()
self.session.run(init_op)
self.loss_history = []
input_sequence_batch = self.generate_batch(input_sequence_list, self.batch_length)
output_sequence_batch = self.generate_batch(output_sequence_list, self.batch_length)
epoch_range = tqdm.trange(epoch_count)
for _ in epoch_range:
last_state = None
loss_sum = 0
for batch_index in range(self.batch_count):
feed_dict = {
self.input_sequence_op: input_sequence_batch[batch_index],
self.output_sequence_op: output_sequence_batch[batch_index],
}
if batch_index > 0:
feed_dict[self.initial_state_op] = last_state
_, loss, last_state = self.session.run([self.train_op, self.loss_op, self.last_state_op], feed_dict = feed_dict)
loss_sum += loss/self.sequence_count
epoch_range.set_description("loss=%.3f"%loss_sum)
self.loss_history.append(loss_sum)
def predict(self, input_sequence_list,output_sequence_list):
sequence_count, sequence_length = input_sequence_list.shape
input_sequence_batch = self.generate_batch(input_sequence_list, self.batch_length)
output_sequence_batch = self.generate_batch(output_sequence_list, self.batch_length)
batch_count = sequence_length //self.batch_length
last_state = np.zeros( [sequence_count, self.hidden_unit_count] )
prediction_sequence_batch_list = []
loss_sum = 0
for batch_index in range(batch_count):
feed_dict = {
self.input_sequence_op: input_sequence_batch[batch_index],
self.output_sequence_op: output_sequence_batch[batch_index],
}
if batch_index > 0:
feed_dict[self.initial_state_op] = last_state
prediction_sequence_batch, last_state, loss = self.session.run([self.prediction_sequence_op, self.last_state_op, self.loss_op], feed_dict = feed_dict)
prediction_sequence_batch_list.append(prediction_sequence_batch)
loss_sum += loss/sequence_count
prediction_sequence_list = np.hstack(prediction_sequence_batch_list)
return prediction_sequence_list, loss_sum
早速学習を行ってみよう。
[18]:
model = RecurrentNeuralNetwork_BasicCell()
model.train(input_sequence_list, output_sequence_list, hidden_unit_count, learning_rate = learning_rate, batch_length = batch_length, epoch_count = epoch_count)
loss=1.289: 100%|████████████████████████████████████████████████████████████████████| 300/300 [00:17<00:00, 17.38it/s]
[19]:
plt.plot(model.loss_history)
plt.yscale("log")
plt.show()
prediction_sequence_list, loss = model.predict(input_sequence_list,output_sequence_list)
print("loss=%f"%loss)
plot(input_sequence_list, output_sequence_list, prediction_sequence_list)
test_input_sequence_list, test_output_sequence_list = generate_data(sequence_count = sequence_count, sequence_length = sequence_length, delay = delay)
test_prediction_sequence_list, test_loss = model.predict(test_input_sequence_list,test_output_sequence_list)
print("test_loss=%f"%test_loss)
plot(test_input_sequence_list, test_output_sequence_list, test_prediction_sequence_list)

loss=1.281115

test_loss=1.300333

正しく学習できていることがわかる。上記モデルに少しの変更を加えることで、セルをLSTMに変更することができる。
[20]:
import numpy as np
import tensorflow as tf
class RecurrentNeuralNetwork_LSTM(RecurrentNeuralNetwork_BasicCell):
def __feed_forward(self, input_sequence):
input_sequence = tf.expand_dims(input_sequence, axis=2)
unstacked_input_sequence = tf.unstack(input_sequence, axis=1)
cell = tf.contrib.rnn.BasicLSTMCell(self.hidden_unit_count, forget_bias = 1.0)
state_op = cell.zero_state(batch_size = self.sequence_count, dtype=tf.float64)
unstacked_state, final_state = tf.contrib.rnn.static_rnn(cell, unstacked_input_sequence, dtype=tf.float64, initial_state = state_op)
unstacked_output_sequence = []
for state in unstacked_state:
output_value = tf.matmul(state,self.W_out) + self.b_out
unstacked_output_sequence.append(output_value)
output_sequence = tf.stack(unstacked_output_sequence, axis = 1)
output_sequence = tf.squeeze(output_sequence,axis=2)
return output_sequence, final_state, state_op
[21]:
model = RecurrentNeuralNetwork_LSTM()
model.train(input_sequence_list, output_sequence_list, hidden_unit_count, learning_rate = learning_rate, batch_length = batch_length, epoch_count = epoch_count)
loss=0.818: 100%|████████████████████████████████████████████████████████████████████| 300/300 [00:17<00:00, 16.95it/s]
[22]:
plt.plot(model.loss_history)
plt.yscale("log")
plt.show()
prediction_sequence_list, loss = model.predict(input_sequence_list,output_sequence_list)
print("loss=%f"%loss)
plot(input_sequence_list, output_sequence_list, prediction_sequence_list)
test_prediction_sequence_list, test_loss = model.predict(test_input_sequence_list,test_output_sequence_list)
print("test_loss=%f"%test_loss)
plot(test_input_sequence_list, test_output_sequence_list, test_prediction_sequence_list)

loss=0.814625

test_loss=0.810004

リザーバコンピューティング¶
RNNでは時系列の予測を
と与えるものであった。
リザーバコンピューティングは予測に用いる関数の形式は上記の枠組みに入るものの、下記の点で異なる。
\(W_{\rm state}\)をランダムなパラメータで初期化し、学習を行わない。
\({\rm activate}_{\rm out}: x \rightarrow x\)とする。
上記のような制約をRNNに加えることはモデルの自由度を狭める意味で不利である。一方、上記の制約のおかげでリザーバコンピューティングには下記のような利点がある。
学習が\(h_t\)から\(y_t\)への線形回帰となるため、非常に高速に学習が行える。このため、\(n_h\)の値を大きくしても学習が十分高速に終了する。
\(W_{\rm state}\)の更新を行わないため、非線形だが相互作用の調整が難しいアナログなダイナミクスの時間発展を\(h_t\)の更新過程とみなすことができる。
学習が線形回帰となるため、逆誤差伝播や確率勾配法などを利用する必要がなくなる。具体的には、下記の手続きで計算を行う。
を最小化するような\(W_y\)と\(b_y\)を求めたい。
この式は
とおくことで、以下の式の最小化問題に置き換えられる。
これを最小化するのは\(H\)の疑似逆行列を\(H^+\)として\(V = H^+ Y\)である。疑似逆行列は\(H\)に対する特異値分解\(H = U D V^{\dagger}\)を考え、\(D\)の転置を取って非ゼロの対角要素を全て逆数にした行列を\(D^+\)としたとき、\(H^+ := VD^+U^{\dagger}\)となる。
技術的な注意点として、行列\(H\)が非常に小さい特異値を持っていた場合、逆数が発散し計算結果が不安定になってしまう。これを回避するために、あるしきい値を設けてその値より特異値が小さい場合は0とみなす処理が有効である。scipyのscipy.linalg.pinvではrcondという引数でこれがサポートされている。
[23]:
class ReservoirComputing(object):
def __feed_forward(self, input_sequence_list):
sequence_count, sequence_length = input_sequence_list.shape
predict_time_series = []
state = np.zeros( [input_sequence_list.shape[0], self.hidden_unit_count] )
state_list = []
for sequence_index in range(sequence_length):
input_value_list = input_sequence_list[:,sequence_index]
input_value_list = np.expand_dims(input_value_list, axis=1)
stacked_state = np.hstack( [state, input_value_list, np.ones([sequence_count,1])] )
state = np.tanh( stacked_state @ self.W_state )
state_list.append(state)
stacked_state = np.hstack( [state, np.ones([sequence_count,1])])
predict_value_list = stacked_state @ self.W_out
predict_value_list = np.squeeze(predict_value_list, axis=1)
predict_time_series.append(predict_value_list)
predict_sequence_list = np.transpose(np.array(predict_time_series), [1,0])
state_list = np.transpose(np.array(state_list),[1,0,2])
return predict_sequence_list, state_list
def train(self, input_sequence_list, output_sequence_list, hidden_unit_count, radius, beta):
assert(input_sequence_list.shape == output_sequence_list.shape)
self.hidden_unit_count = hidden_unit_count
self.radius = radius
self.sequence_count, self.sequence_length = input_sequence_list.shape
self.hidden_unit_count = hidden_unit_count
self.W_state = np.random.rand(self.hidden_unit_count+2, self.hidden_unit_count)
self.W_out = np.random.rand(self.hidden_unit_count+1,1)
norm = np.max(np.abs(np.linalg.eig(self.W_state[:self.hidden_unit_count,:])[0]))
self.W_state *= self.radius/norm
_, state_list = self.__feed_forward(input_sequence_list)
state_list = np.array(state_list)
V = np.reshape(state_list, [-1, hidden_unit_count])
V = np.hstack( [V, np.ones([V.shape[0], 1]) ] )
S = np.reshape(output_sequence_list, [-1])
self.W_out = np.linalg.pinv(V, rcond = beta) @ S
self.W_out = np.expand_dims(self.W_out,axis=1)
def predict(self, input_sequence_list,output_sequence_list):
prediction_sequence_list, _ = self.__feed_forward(input_sequence_list)
loss = np.sum((prediction_sequence_list-output_sequence_list)**2)/2
loss /= prediction_sequence_list.shape[0]
return prediction_sequence_list, loss
早速学習を行ってみよう。
[24]:
hidden_unit_count = 100
radius = 1.25
beta = 1e-14
model = ReservoirComputing()
start_time = time.time()
model.train(input_sequence_list, output_sequence_list, hidden_unit_count, radius, beta)
print("elapsed %f sec"%(time.time()-start_time))
elapsed 0.543546 sec
学習時間が圧倒的に高速であることがわかる。学習結果を見てみよう。
[25]:
prediction_sequence_list, loss = model.predict(input_sequence_list,output_sequence_list)
print("loss=%f"%loss)
plot(input_sequence_list, output_sequence_list, prediction_sequence_list)
test_prediction_sequence_list, test_loss = model.predict(test_input_sequence_list,test_output_sequence_list)
print("test_loss=%f"%test_loss)
plot(test_input_sequence_list, test_output_sequence_list, test_prediction_sequence_list)
loss=6.179668

test_loss=6.474486

かなり良くフィットできているが、隠れ層のユニット数が10倍であるにも関わらずlossの値はRNNの結果に比べ劣っていることがわかる。これは学習が高速になったことの代償であるといういうことができる。
量子リザーバコンピューティング¶
量子リザーバコンピューティングとは、非線形性を有する物理系として量子系を用いたものである。 先述したようにリザーバコンピューティングはフィードフォワードなネットワークに比べアナログなシステムと相性が良いために、量子系もリザーバコンピューティングに自然に適用することができる。量子リザーバコンピューティングはごく最近提案された手法であり、いくつかの詳細で細かい改善の余地がある。下記の記述は論文Boosting computational power through spatial multiplexing in quantum reservoir computingを参考にした、あくまで一つの実装例であることに注意してほしい。
量子リザーバコンピューティングでは、隠れ層のユニット数を\(n\)としたとき、\(n+1\)個以上の量子ビットを用いてモデルを作成する。 入力の時系列は時間発展を行うハミルトニアンの個別の量子ビットに関する項の係数となる。 ニューロンの読み出しとしては、個々の量子ビットの期待値測定を用いる。 期待値を測定する必要があるため、量子ビットの間の相互作用の操作性が低くともコヒーレンス時間が長くアンサンブルとなっている物理系がこの実験に適している。 具体的には、並列して複数の量子系に同じユニタリ操作を行え、かつ容易にアンサンブルから期待値の測定が可能なNMRが量子リザーバコンピューティングに適していると目されている。
なお、シュレディンガー方程式は線形方程式であるため、上記システムは非線形ダイナミクスに当たらないように一見見えるかもしれないが、 量子力学が線形なのは波動関数に対してであって、時間発展後のパウリ基底での測定期待値は波動関数の要素に対して非線形になっているため、 入力がハミルトニアンの係数となり、出力が量子ビットの測定期待値とした場合、この対応は非線形なマップとなっている。
大規模な量子リザーバコンピューティングの動作は量子コンピュータが無ければ観測することが難しいものの、ユニット数が8個程度までの小規模なものであれば通常のラップトップでもシミュレートすることができる。
[26]:
class QuantumReservoirComputing(object):
def __feed_forward(self, input_sequence_list):
sequence_count, sequence_length = input_sequence_list.shape
predict_sequence_list = []
state_list = []
dim = 2**self.qubit_count
sequence_range = tqdm.trange(sequence_count)
for sequence_index in sequence_range:
rho = np.zeros( [dim,dim] )
rho[0,0]=1
state = []
for time_step in range(sequence_length):
rho = self.P0op @ rho @ self.P0op + self.Xop[0] @ self.P1op @ rho @ self.P1op @ self.Xop[0]
# (1 + u Z)/2 = (1+u)/2 |0><0| + (1-u)/2 |1><1|
value = input_sequence_list[sequence_index, time_step]
rho = (1+value)/2 * rho + (1-value)/2 *self.Xop[0] @ rho @ self.Xop[0]
rho = self.Uop @ rho @ self.Uop.T.conj()
current_state = []
for qubit_index in range(1,self.qubit_count):
expectation_value = np.real(np.trace(self.Zop[qubit_index] @ rho))
current_state.append(expectation_value)
state.append(current_state)
state = np.array(state)
state_list.append(state)
stacked_state = np.hstack( [state, np.ones([sequence_length,1])])
predict_sequence = stacked_state @ self.W_out
predict_sequence = np.squeeze(predict_sequence, axis=1)
predict_sequence_list.append(predict_sequence)
predict_sequence_list = np.array(predict_sequence_list)
state_list = np.array(state_list)
return predict_sequence_list, state_list
def train(self, input_sequence_list, output_sequence_list, hidden_unit_count, max_coupling_energy, trotter_step, beta):
assert(input_sequence_list.shape == output_sequence_list.shape)
self.hidden_unit_count = hidden_unit_count
self.trotter_step = trotter_step
self.sequence_count, self.sequence_length = input_sequence_list.shape
self.hidden_unit_count = hidden_unit_count
self.W_out = np.random.rand(self.hidden_unit_count+1,1)
I = [[1,0],[0,1]]
Z = [[1,0],[0,-1]]
X = [[0,1],[1,0]]
P0 = [[1,0],[0,0]]
P1 = [[0,0],[0,1]]
self.qubit_count = self.hidden_unit_count+1
self.dim = 2**self.qubit_count
self.Zop = [1]*self.qubit_count
self.Xop = [1]*self.qubit_count
self.P0op = [1]
self.P1op = [1]
for cursor_index in range(self.qubit_count):
for qubit_index in range(self.qubit_count):
if cursor_index == qubit_index:
self.Xop[qubit_index] = np.kron(self.Xop[qubit_index],X)
self.Zop[qubit_index] = np.kron(self.Zop[qubit_index],Z)
else:
self.Xop[qubit_index] = np.kron(self.Xop[qubit_index],I)
self.Zop[qubit_index] = np.kron(self.Zop[qubit_index],I)
if cursor_index == 0:
self.P0op = np.kron(self.P0op, P0)
self.P1op = np.kron(self.P1op, P1)
else:
self.P0op = np.kron(self.P0op, I)
self.P1op = np.kron(self.P1op, I)
self.hamiltonian = np.zeros( (self.dim,self.dim) )
for qubit_index in range(self.qubit_count):
coef = (np.random.rand()-0.5) * 2 * max_coupling_energy
self.hamiltonian += coef * self.Zop[qubit_index]
for qubit_index1 in range(self.qubit_count):
for qubit_index2 in range(qubit_index1+1, self.qubit_count):
coef = (np.random.rand()-0.5) * 2 * max_coupling_energy
self.hamiltonian += coef * self.Xop[qubit_index1] @ self.Xop[qubit_index2]
self.Uop = scipy.linalg.expm(1.j * self.hamiltonian)
_, state_list = self.__feed_forward(input_sequence_list)
state_list = np.array(state_list)
V = np.reshape(state_list, [-1, hidden_unit_count])
V = np.hstack( [V, np.ones([V.shape[0], 1]) ] )
S = np.reshape(output_sequence_list, [-1])
self.W_out = np.linalg.pinv(V, rcond = beta) @ S
self.W_out = np.expand_dims(self.W_out,axis=1)
def predict(self, input_sequence_list,output_sequence_list):
prediction_sequence_list, _ = self.__feed_forward(input_sequence_list)
loss = np.sum((prediction_sequence_list-output_sequence_list)**2)/2
loss /= prediction_sequence_list.shape[0]
return prediction_sequence_list, loss
早速計算を行ってみよう。
[27]:
# この値は量子ビットに相当するため、大きくすると容易にOut of Memoryを起こすので注意
hidden_unit_count = 5
max_coupling_energy = 1.0
trotter_step = 10
beta = 1e-14
model = QuantumReservoirComputing()
model.train(input_sequence_list, output_sequence_list, hidden_unit_count, max_coupling_energy, trotter_step, beta)
100%|████████████████████████████████████████████████████████████████████████████████| 100/100 [00:44<00:00, 2.38it/s]
訓練の結果を見てみよう。古典のモデルと異なり、量子系をシミュレートするために予測についてもシミュレーションでは非常に時間がかかる。
[28]:
prediction_sequence_list, loss = model.predict(input_sequence_list,output_sequence_list)
print("loss=%f"%loss)
plot(input_sequence_list, output_sequence_list, prediction_sequence_list)
test_prediction_sequence_list, test_loss = model.predict(test_input_sequence_list,test_output_sequence_list)
print("test_loss=%f"%test_loss)
plot(test_input_sequence_list, test_output_sequence_list, test_prediction_sequence_list)
100%|████████████████████████████████████████████████████████████████████████████████| 100/100 [00:42<00:00, 2.35it/s]
loss=20.049381

100%|████████████████████████████████████████████████████████████████████████████████| 100/100 [00:42<00:00, 1.80it/s]
test_loss=19.180296

5量子ビットでもそこそこの精度がでていることがわかる。古典のRNNやリザーバ計算の設定を変えてみて比較してみるとより理解が深まるだろう。
量子リザーバコンピューティングの性能について¶
リザーバコンピューティングを古典から量子に拡張するうえで最も気になる点は、量子リザーバコンピューティングは予測モデルとして古典リザーバコンピューティングよりも優れているか、という点である。二つの異なる学習モデルの性能を一つの軸で比較することは容易ではないが、いくつかの量子リザーバコンピューティングが古典よりも優れていると思われる点と、そうではない点がある。
量子リザーバコンピューティングが古典リザーバコンピューティングに対して持つ大きな利点は、量子リザーバコンピューティングは\(n\) qubitの量子状態が\(2^n\)次元のヒルベルト空間を自由に動ける点である。これは\(n\)個の実数値が動くことができる\(n\)次元の空間よりも広い。このことから、量子リザーバコンピューティングは同じビット数でも古典より高い表現能力を持つことが期待できる。これはいわゆるNISQでの量子機械学習が高い性能を持つと期待される理由と同じである。 なおNISQの代表格であるVQEなどの量子機械学習ではパラメータの最適化においてback propagationのような効率的な勾配計算が行えないという欠点があるが、量子リザーバコンピューティングは非線形系については最適化を一切行わないので、学習の低速さという欠点が自然に回避されている。
量子リザーバコンピューティングが古典リザーバコンピューティングに対して持つ欠点はクロックの速度と技術的なスケーラビリティである。現状の量子計算機はエラーを回避するためにクロックと系のスケールに制約がついてしまう。このため、非常に高次元な入力データを入力するリザーバコンピューティングを量子系で行うことは現状困難である。
また、古典リザーバコンピューティングは必ずしもアナログな系で実現する必要はなく、デジタル計算機上でシミュレートすることも可能である。デジタル計算機上で行われるリザーバーコンピューティングと比較すると、量子リザーバコンピューティングはノード間の相互作用としてハミルトニアンに従った時間発展しか許されないという欠点を持つ。デジタルな計算機上でリザーバコンピューティングをシミュレートする場合は二つのノード間のどのような相互作用も許されるので、好きな非線形関数をデザインすることができる。しかし、量子リザーバコンピューティングの枠組みでは量子力学で許された相互作用しか行うことができない。
5-3. Quantum Approximate Optimazation Algorithm (QAOA): 量子近似最適化アルゴリズム¶
概要¶
この節では、NISQアルゴリズムの一つと考えられる Quantum Approximate Optimazation Algorithm (QAOA; 量子近似最適化アルゴリズム)を学ぶ。QAOAは、量子アニーリングと同様に、組み合わせ最適化問題の解を求めるためのアルゴリズムである。
問題設定¶
QAOAでは、\(z = z_{1}z_{2}\cdots z_{n} \: (z_i =0,1)\) という\(n\)桁のビット列\(z\)に関して、コスト関数\(C(z) = \sum_\alpha C_\alpha(z)\)が最小になるような\(z\)を探す問題を考える。\(C_\alpha(z)\)はビット列\(z\)を引数にとる何らかの関数で、ここでは特に、イジングモデル的な\(C_\alpha(z) = z_i\cdot z_j\)といった項を考えれば良い。
この最小化問題を解くために、\(n\)ビットの量子系を用いる。そして、\(\beta = (\beta^{(1)}, \cdots \beta^{(p)}), \gamma = (\gamma^{(1)}, \cdots \gamma^{(p)})\) をパラメータとして、次のような量子状態を考える。
ここで \(|+\rangle=\frac{1}{\sqrt{2}}(|0\rangle+|1\rangle)\)は\(X\)演算子の固有状態\(X|+\rangle=|+\rangle\)であり、 \(U_C(\gamma), U_X(\beta)\) は次のように定義される。
そして、\(F(\beta, \gamma) = \langle{\bf \gamma, \,\beta}|C(Z)|{\bf \gamma, \,\beta}\rangle\) を最小にするような\(\beta,\gamma\)を探索することで、元々の最適化問題の答えを探そうとするのが、QAOAアルゴリズムである。
QAOAアルゴリズムの手順¶
具体的なQAOAアルゴリズムの手順は以下の通りである。
量子コンピュータ上で重ね合わせ状態\(|s\rangle = |+\rangle^{\otimes n}\)を作る。
パラメータ\(\beta, \gamma\)に応じて、量子状態に\(U_C(\gamma^{(i)}),U_X(\beta^{(i)})\)をかけていき、状態\(|\beta, \gamma \rangle\)を得る。
量子コンピュータを用いて \(\langle \beta, \gamma |C(Z)|\beta, \gamma \rangle\) を測定する。
古典コンピュータで、\(\langle \beta, \gamma |C(Z)|\beta, \gamma \rangle\) がより小さくなるようにパラメータ \(\beta, \gamma\) をアップデートする。
1〜4を繰り返し、最適な \(\beta^*, \gamma^*\) を得る。
状態 \(|\beta^*, \gamma^* \rangle\) に対して、\(z\)方向の射影測定を複数回実行し、得られた(良さそうな)測定結果 \(z_1\cdots z_n\) を元々の最適化問題の解として採用する。(注:測定結果 \(z_1\cdots z_n\) は古典ビット)
少々ややこしいので、具体例を実装しながら確認していこう。
実装:Maxcut問題をQAOAで解く¶
(図の出典:Wikipedia カット_(グラフ理論))
この問題をQAOAで扱えるような最適化問題に帰着させるには、以下のようにする。 頂点を2つのグループに分けた時、片方のグループに属する頂点に+1、もう一方のグループに-1を付与するとすれば、コスト関数
は (グループ分けによって分割される辺の数) \(\times (-1)\) を表す。 ゆえに、\(C(z)\)を最小化するようなビット列\(z=z_1\cdots z_n\)を見つければ、分割する辺の数を最大化するような頂点の分け方を見つけたことになる。
以下では、長方形(頂点が4つの図形)のmaxcut問題を解いてみよう。
この場合、\(C(Z)\)は
となる。第二項は定数だから、以下では
とおく。
\(p=1\)の場合¶
まずは\(p=1\)の場合の実装をやってみよう。この時、\(|\beta, \gamma \rangle = U_X(\beta^{(1)}) U_C(\gamma^{(1)}) |s\rangle\) である。
\(U_C(\gamma^{(1)}) = \prod_{i=0}^3 e^{-i\gamma^{(1)} Z_i Z_{i+1} }\) を実装するには、4-2節でも用いた関係
を使えば良い。(行列に直して計算すると、合っていることがわかる。)
以上を踏まえて、\(|\beta, \gamma \rangle\) を構成して \(\langle \beta, \gamma | C(Z) |\beta, \gamma \rangle\) を測定し、それを最小化する工程を実装するとこのようになる。
[ ]:
## Google Colaboratoryの場合・Qulacsがインストールされていないlocal環境の場合のみ実行してください
!pip install qulacs
[1]:
#必要なライブラリをインポートする
from qulacs import QuantumState, QuantumCircuit, Observable, PauliOperator
from qulacs.gate import H, CNOT, RX, RZ
from scipy.optimize import minimize
import numpy as np
## 頂点の数
n = 4
## C(Z)をqulacs.Observableとして定義
cost_observable = Observable(n)
for i in range(n):
cost_observable.add_operator( PauliOperator("Z {:} Z {:}".format(i, (i+1)%n), 0.5) )
# circuit に U_C(gamma) を加える関数
def add_U_C(circuit, gamma):
for i in range(n):
j = (i+1) % n
circuit.add_CNOT_gate(i, j)
circuit.add_gate(RZ(j, -2*gamma)) ## qulacsでは RZ(theta)=e^{i*theta/2*Z}
circuit.add_CNOT_gate(i, j)
return circuit
# circuit に U_X(beta) を加える関数
def add_U_X(circuit, beta):
for i in range(n):
circuit.add_gate(RX(i, -2*beta))
return circuit
# p=1 の |beta, gamma> を作って <beta, gamma| C(Z) |beta, gamma> を返す関数
# x = [beta, gamma]
def QAOA_output_onelayer(x):
beta, gamma = x
circuit = QuantumCircuit(n)
## 重ね合わせを作るため、アダマールゲートをかける
for i in range(n):
circuit.add_H_gate(i)
## U_C, U_Xをかける
circuit = add_U_C(circuit, gamma)
circuit = add_U_X(circuit, beta)
## |beta, gamma>を作る
state = QuantumState(n)
state.set_zero_state()
circuit.update_quantum_state(state)
return cost_observable.get_expectation_value(state)
## 初期値
x0 = np.array( [0.1, 0.1 ])
## scipy.minimize を用いて最小化
result = minimize(QAOA_output_onelayer, x0, options={'maxiter':500}, method='powell')
print(result.fun) # 最適化後の値
print(result.x) # 最適化後の(beta, gamma)
-0.999999999499185
[1.17809152 0.39269362]
[2]:
# 最適なbeta, gammaを使って |beta, gamma> をつくる
beta_opt, gamma_opt = result.x
circuit = QuantumCircuit(n)
## 重ね合わせを作るため、アダマールゲートをかける
for i in range(n):
circuit.add_H_gate(i)
## U_C, U_Xをかける
circuit = add_U_C(circuit, gamma_opt)
circuit = add_U_X(circuit, beta_opt)
## |beta, gamma>を作る
state = QuantumState(n)
state.set_zero_state()
circuit.update_quantum_state(state)
## z方向に観測した時の確率分布を求める. (状態ベクトルの各成分の絶対値の二乗=観測確率)
probs = np.abs(state.get_vector())**2
print(probs)
[0.01562503 0.01562568 0.01562568 0.0781236 0.01562568 0.26562503
0.0781236 0.01562568 0.01562568 0.0781236 0.26562503 0.01562568
0.0781236 0.01562568 0.01562568 0.01562503]
[5]:
# プロットする
import matplotlib.pyplot as plt
%matplotlib inline
## z方向に射影測定した時に得られる可能性があるビット列
z_basis = [format(i,"b").zfill(n) for i in range(probs.size)]
plt.figure(figsize=(10, 5))
plt.xlabel("states")
plt.ylabel("probability(%)")
plt.bar(z_basis, probs*100)
plt.show()

つまり、\(z\)方向の射影測定を行うと、0101
か 1010
が測定される確率が高いことが分かった。これらのビット列は頂点1と頂点3、頂点2と頂点4が同じグループになるということを意味するから、以下のような分割を表している。
この時、図形を分割する曲線が横切る辺の数は4本であり、この図形を分割する時に通る辺の数の最大値である。
そこで、回路をより複雑にした\(p=2\)の場合に結果がどう変わるか見てみよう。
※ ちなみに、\(|\beta, \gamma\rangle\)に100回の測定を行ってビット列\(z\)を100通り得て、それぞれについて\(C(z)\)を古典コンピュータで計算してみて最も良かったものを採用する、といった戦略を用いれば、このような問題は生じないかもしれない。
\(p=2\)の場合¶
[8]:
#必要なライブラリをインポートする
from qulacs import QuantumState, QuantumCircuit, Observable, PauliOperator
from qulacs.gate import H, CNOT, RX, RZ
from scipy.optimize import minimize
import numpy as np
## 頂点の数
n = 4
## C(Z)をqulacs.Observableとして定義
cost_observable = Observable(n)
for i in range(n):
cost_observable.add_operator( PauliOperator("Z {:} Z {:}".format(i, (i+1)%n), 0.5) )
# circuit に U_C(gamma) を加える関数
def add_U_C(circuit, gamma):
for i in range(n):
j = (i+1) % n
circuit.add_CNOT_gate(i, j)
circuit.add_gate(RZ(j, -2*gamma)) ## qulacsでは RZ(theta)=e^{i*theta/2*Z}
circuit.add_CNOT_gate(i, j)
return circuit
# circuit に U_X(beta) を加える関数
def add_U_X(circuit, beta):
for i in range(n):
circuit.add_gate(RX(i, -2*beta))
return circuit
# p=2 の |beta, gamma> を作って <beta, gamma| C(Z) |beta, gamma> を返す関数
# x = [beta0, beta1, gamma0, gamma1]
def QAOA_output_twolayer(x):
beta0, beta1, gamma0, gamma1 = x
circuit = QuantumCircuit(n)
## 重ね合わせを作るため、アダマールゲートをかける
for i in range(n):
circuit.add_H_gate(i)
## U_C, U_Xをかける
circuit = add_U_C(circuit, gamma0)
circuit = add_U_X(circuit, beta0)
circuit = add_U_C(circuit, gamma1)
circuit = add_U_X(circuit, beta1)
## |beta, gamma>を作る
state = QuantumState(n)
state.set_zero_state()
circuit.update_quantum_state(state)
return cost_observable.get_expectation_value(state)
## 初期値
x0 = np.array( [0.1, 0.1, 0.2, 0.3 ])
## scipy.minimize を用いて最小化
result = minimize(QAOA_output_twolayer, x0, options={'maxiter':500}, method='powell')
print(result.fun) # 最適化後の値
print(result.x) # 最適化後の[beta0, beta1, gamma0, gamma1]
## 最適化後の状態を測定した時の確率分布を調べる
beta0, beta1, gamma0, gamma1 = result.x
circuit = QuantumCircuit(n)
## 重ね合わせを作るため、アダマールゲートをかける
for i in range(n):
circuit.add_H_gate(i)
## U_C, U_Xをかける
circuit = add_U_C(circuit, gamma0)
circuit = add_U_X(circuit, beta0)
circuit = add_U_C(circuit, gamma1)
circuit = add_U_X(circuit, beta1)
## |beta, gamma>を作る
state = QuantumState(n)
state.set_zero_state()
circuit.update_quantum_state(state)
## 状態ベクトルの各成分の絶対値の二乗=観測確率
probs = np.abs(state.get_vector())**2
print(probs)
## z方向に射影測定した時に得られる可能性があるビット列
z_basis = [format(i,"b").zfill(n) for i in range(probs.size)]
import matplotlib.pyplot as plt
plt.figure(figsize=(10, 5))
plt.xlabel("states")
plt.ylabel("probability(%)")
plt.bar(z_basis, probs*100)
plt.show()
-1.9999996223652372
[1.01150163 1.11872823 0.45213228 0.55937865]
[1.10913271e-15 9.96406007e-09 9.96406007e-09 2.72762241e-08
9.96406007e-09 4.99999906e-01 2.72762241e-08 9.96406007e-09
9.96406007e-09 2.72762241e-08 4.99999906e-01 9.96406007e-09
2.72762241e-08 9.96406007e-09 9.96406007e-09 1.10913271e-15]

\(p=1\)の時に比べ、圧倒的に大きい確率で真の解\(|0101\rangle, |1010\rangle\)が得られる確率が高いことが分かる。また、コスト関数の値も正しく−2に近づいている。
このように、QAOAを用いる際には、変分量子回路の複雑さ\(p\)の大きさにも注意しながら実装する必要があるだろう。
参考文献¶
[1] E. Farhi, J. Goldstone, and S. Gutmann, “A Quantum Approximate Optimization Algorithm”, arXiv:1411.4028 (2014).
[2] Eddie Farhi: A Quantum Approximate Optimization Algorithm, https://www.youtube.com/watch?v=J8y0VhnISi8
第6章 量子化学計算¶
この章では、NISQデバイスの最も重要で有望な応用先の一つと考えられている、量子化学計算について学ぶ。まずは量子化学計算に便利なライブラリであるOpenFermionの扱い方について学び、量子化学の問題を量子コンピュータで解ける形に変換する手法を習得する(6-1節)。その後、5-1節で学んだvariational quantum eigensolver をQulacsを用いて実装する(6-2節)。6-3節では、基底状態だけではなく励起状態も求められるsubspace-variational quantum eigensolver (SS-VQE)という手法について学ぶ。
※ この章は量子化学の前提知識が必要になるので、分からないところは他の文献を見たり飛ばしたりしながら読み進めてほしい。
6-1. OpenFermionの使い方¶
この節では量子化学計算用のPythonライブラリである、OpenFermion [1] を用いて、相互作用する電子系のハミルトニアンを、量子コンピュータ上で扱いやすい形に変換する方法を紹介する。OpenFermion には量子化学計算のオープンソースライブラリである Psi4 および PySCF との接続が用意されており、これらのライブラリの詳細な使い方を理解しなくても、分子の構造を入力するだけで、量子化学計算において現れる電子系のハミルトニアンを得られるようになっている。ここでは PySCF を使用する。
[ ]:
## 各種ライブラリがインストールされていない場合は実行してください
## Google Colaboratory上で実行する場合'You must restart the runtime in order to use newly installed versions.'と出ますが無視してください。
## runtimeを再開するとクラッシュします。
!pip install qulacs pyscf openfermion openfermionpyscf
[1]:
#必要なライブラリのインポート
from openfermion.hamiltonians import MolecularData
from openfermionpyscf import run_pyscf
from openfermion.transforms import get_fermion_operator, jordan_wigner, bravyi_kitaev
from openfermion.utils import eigenspectrum
from openfermion.transforms import get_sparse_operator
from openfermion.ops import FermionOperator
from pyscf import fci
import numpy as np
import matplotlib.pyplot as plt
水素分子を計算してみる¶
openfermion では、分子を記述するデータを MolecularData というクラスに入力する。
[2]:
#define constants
basis = "sto-3g" #basis set
multiplicity = 1 #spin multiplicity
charge = 0 #total charge for the molecule
distance = 0.65
geometry = [("H",(0,0,0)),("H", (0,0,distance))] #xyz coordinates for atoms
description = str(distance) #description for the psi4 output file
molecule = MolecularData(geometry, basis, multiplicity, charge, description)
変数の説明¶
以下で上記のコード内で現れている変数の意味を説明する。
basis: 基底関数¶
分子軌道を表現するための基底関数を設定する。sto-3g, 6-31G などいろいろな基底関数系がある。
ここで使った sto-3g (Slater Type Orbital - 3 gaussian) は Slater type orbital を 3つのgaussianで近似した基底関数である。
Slater type orbital とは、水素原子の解に似せた軌道であり、動径方向の関数として
を使用し、角度方向は球面調和関数\(Y_{lm}(\theta,\phi)\)を使用するものである。sto-3g では、この動径方向の波動関数\(R_{nl}(r)\)を、3つのgaussianで近似した関数を用いる。
multiplicity: スピン多重度¶
電子はスピン1/2を持っているので、1つの電子が孤立して存在しているときスピン多重度は2である。しかし水素分子の場合、基底状態では電子はsingletを組み、全体ではスピン0になっていると考えられる。スピン0は1状態のみなので、この場合ではスピン多重度は1とする。
charge: 全電荷¶
全体の電荷を入力する。イオンを考える場合は + になったり − になったりする。
geometry: 原子核配置¶
原子種とその座標を x,y,z で指定する。
description¶
pyscf が計算した出力結果は openfermion のライブラリが保存されているディレクトリ内に保存される。そのファイルの名前を決めるための変数である。
PySCF による計算¶
上記で設定した MolecularData を関数 run_pyscf
に投げて PySCFによる量子化学計算を行ってみよう。数秒で終わるはずである。
[3]:
molecule = run_pyscf(molecule,run_scf=1,run_fci=1)
HF & Full-CI energy¶
PySCF の計算によって求まった Hartree-Fock エネルギーと Full-CI エネルギー (=厳密な基底エネルギー) を見てみよう。(1 Hartree = 27.2116 eV)
[4]:
print("HF energy: {} (Hartree)".format(molecule.hf_energy))
print("FCI energy: {} (Hartree)".format(molecule.fci_energy))
HF energy: -1.1129965456691682 (Hartree)
FCI energy: -1.1299047843229137 (Hartree)
1 電子積分 \(h_{ij}\)・2電子積分 \(h_{ijkl}\)¶
1 電子積分や 2 電子積分といった量も MolecularData クラスに保存されている。
[ ]:
print(molecule.one_body_integrals)
[[-1.30950987e+00 1.98461056e-17]
[ 1.12429268e-16 -4.10026381e-01]]
[ ]:
print(molecule.two_body_integrals)
[[[[ 6.91904405e-01 -1.29088971e-16]
[-1.33947330e-16 1.76318452e-01]]
[[-1.33947330e-16 1.76318452e-01]
[ 6.79683914e-01 -2.19293917e-16]]]
[[[-1.29088971e-16 6.79683914e-01]
[ 1.76318452e-01 -2.28497801e-17]]
[[ 1.76318452e-01 -2.28497801e-17]
[-2.19293917e-16 7.14671111e-01]]]]
第二量子化形式のハミルトニアン¶
openfermionはこれらの積分値から第二量子化形式のハミルトニアン
を計算してくれる(第二量子化については、例えば参考文献[2]を参照)。 get_molecular_hamiltonian
メソッドを呼ぶことでハミルトニアンが計算できる。
表示は (3,1)が \(c_3^\dagger\), (1,0)が \(c_1\) といった具合。
[ ]:
print(molecule.get_molecular_hamiltonian())
() 0.8141187860307693
((0, 1), (0, 0)) -1.309509868464871
((1, 1), (1, 0)) -1.309509868464871
((2, 1), (2, 0)) -0.4100263808117837
((3, 1), (3, 0)) -0.4100263808117837
((0, 1), (0, 1), (0, 0), (0, 0)) 0.34595220261490217
((0, 1), (0, 1), (2, 0), (2, 0)) 0.0881592258051036
((0, 1), (1, 1), (1, 0), (0, 0)) 0.34595220261490217
((0, 1), (1, 1), (3, 0), (2, 0)) 0.0881592258051036
((0, 1), (2, 1), (0, 0), (2, 0)) 0.0881592258051036
((0, 1), (2, 1), (2, 0), (0, 0)) 0.33984195696523056
((0, 1), (3, 1), (1, 0), (2, 0)) 0.0881592258051036
((0, 1), (3, 1), (3, 0), (0, 0)) 0.33984195696523056
((1, 1), (0, 1), (0, 0), (1, 0)) 0.34595220261490217
((1, 1), (0, 1), (2, 0), (3, 0)) 0.0881592258051036
((1, 1), (1, 1), (1, 0), (1, 0)) 0.34595220261490217
((1, 1), (1, 1), (3, 0), (3, 0)) 0.0881592258051036
((1, 1), (2, 1), (0, 0), (3, 0)) 0.0881592258051036
((1, 1), (2, 1), (2, 0), (1, 0)) 0.33984195696523056
((1, 1), (3, 1), (1, 0), (3, 0)) 0.0881592258051036
((1, 1), (3, 1), (3, 0), (1, 0)) 0.33984195696523056
((2, 1), (0, 1), (0, 0), (2, 0)) 0.3398419569652304
((2, 1), (0, 1), (2, 0), (0, 0)) 0.0881592258051036
((2, 1), (1, 1), (1, 0), (2, 0)) 0.3398419569652304
((2, 1), (1, 1), (3, 0), (0, 0)) 0.0881592258051036
((2, 1), (2, 1), (0, 0), (0, 0)) 0.0881592258051036
((2, 1), (2, 1), (2, 0), (2, 0)) 0.3573355555190683
((2, 1), (3, 1), (1, 0), (0, 0)) 0.0881592258051036
((2, 1), (3, 1), (3, 0), (2, 0)) 0.3573355555190683
((3, 1), (0, 1), (0, 0), (3, 0)) 0.3398419569652304
((3, 1), (0, 1), (2, 0), (1, 0)) 0.0881592258051036
((3, 1), (1, 1), (1, 0), (3, 0)) 0.3398419569652304
((3, 1), (1, 1), (3, 0), (1, 0)) 0.0881592258051036
((3, 1), (2, 1), (0, 0), (1, 0)) 0.0881592258051036
((3, 1), (2, 1), (2, 0), (3, 0)) 0.3573355555190683
((3, 1), (3, 1), (1, 0), (1, 0)) 0.0881592258051036
((3, 1), (3, 1), (3, 0), (3, 0)) 0.3573355555190683
量子コンピュータの扱いやすい演算子に変換する¶
量子コンピュータ上で一番扱いやすいのは、 Pauli 演算子 \(I, X, Y, Z\) とそのテンソル積である。そこで、普通電子のハミルトニアンを量子コンピュータで扱うには、第二量子化形式のハミルトニアン
を、
の形に変換する。様々な変換方法が提案されているが、ここでは Jordan-Wigner 変換と呼ばれている一番簡単なものを使う。Jordan-Wigner 変換では、分子軌道 \(i\) を \(i\) 番目の qubit に対応させ、その分子軌道を電子が占有しているという状況を \(|1\rangle\), そうでないときには \(|0\rangle\) で表すという約束をする。
このような約束の下で、fermion の生成消滅演算子の反交換関係
を満たすようにパウリ演算子を構成すると、
という対応関係を得る。
Jordan-Wigner 変換以外の変換方式については、[2][3] などを参照されたい。
openfermion では Jordan-Wigner 変換が実装されている。jordan_wigner
関数に FermionOperator
を渡すことで、その演算子の Jordan-Wigner 変換に対応する QubitOperator
を返してくれる。以下では、上で作り出した水素分子の MolecularData
から FermionOperator
を作り出し、Jordan-Wigner 変換することで水素分子のハミルトニアンを量子コンピュータの扱いやすい形に変換している。
[5]:
jw_hamiltonian = jordan_wigner(get_fermion_operator(molecule.get_molecular_hamiltonian()))
print(jw_hamiltonian)
(0.03775110394645719+0j) [] +
(-0.04407961290255181+0j) [X0 X1 Y2 Y3] +
(0.04407961290255181+0j) [X0 Y1 Y2 X3] +
(0.04407961290255181+0j) [Y0 X1 X2 Y3] +
(-0.04407961290255181+0j) [Y0 Y1 X2 X3] +
(0.1860164888623058+0j) [Z0] +
(0.17297610130745106+0j) [Z0 Z1] +
(0.12584136558006342+0j) [Z0 Z2] +
(0.16992097848261523+0j) [Z0 Z3] +
(0.18601648886230565+0j) [Z1] +
(0.16992097848261523+0j) [Z1 Z2] +
(0.12584136558006342+0j) [Z1 Z3] +
(-0.26941693141632106+0j) [Z2] +
(0.17866777775953419+0j) [Z2 Z3] +
(-0.26941693141632106+0j) [Z3]
このハミルトニアンから、Hartree-Fock (HF) エネルギーを計算してみよう。Jordan-Wigner 変換では、qubitの\(\left|0\right\rangle, \left|1\right\rangle\)と軌道の占有数が 1対1 対応していることから、HFエネルギーを計算するには、下から電子数分だけを詰めていった \(\left|1100\right\rangle\) に対する期待値をとれば良い。
[8]:
#テンソル積を計算するための関数
def kron_N(*ops):
tmp = ops[0]
for op in ops[1:]:
tmp = np.kron(tmp,op)
return tmp
bra0 = np.array([[1,0]])
bra1 = np.array([[0,1]])
HFbra = kron_N(bra1, bra1, bra0, bra0)
HFket = HFbra.T
print(HFbra)
jw_matrix = get_sparse_operator(jw_hamiltonian)
print(np.real(HFbra.dot(jw_matrix.dot(HFket))), molecule.hf_energy)
[[0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0]]
[[-1.11299655]] -1.1129965456691682
pyscf の計算と殆ど一致していることが確認できる。
次にハミルトニアンを対角化して、その結果がFull-CI (厳密解) エネルギーと一致することを確かめてみよう。
[9]:
from scipy.sparse.linalg import eigs
eigenenergies, eigenvecs = eigs(jw_matrix)
print(eigenenergies[0], molecule.fci_energy)
(-1.1299047843229122+8.66058178452165e-18j) -1.1299047843229137
これも殆ど一致していることが確認できる。基底状態の波動関数 \(\left|\psi_g\right\rangle\) は
[10]:
print(eigenvecs[:,0])
[ 1.80390379e-16+3.83799405e-19j -1.44425855e-16-2.44300624e-16j
2.11047727e-17-5.26747266e-17j -8.90209542e-02+3.44604214e-02j
-6.87721816e-18+8.01380721e-18j -1.32249539e-16-9.52872766e-17j
1.31086550e-16+2.60415224e-16j -2.50025424e-17+1.94665148e-17j
2.96573140e-17-1.12134985e-19j -5.80783492e-17-1.38210176e-16j
9.90624805e-17-1.03376284e-16j -1.67301469e-17+4.96855838e-17j
9.28307030e-01-3.59351927e-01j -3.98483320e-17+1.69508813e-16j
1.44476911e-16-2.91500912e-16j 9.81968448e-17-4.33266880e-17j]
すなわち \(\left|\psi_g \right\rangle \approx 0.995\left|1100\right\rangle - 0.0955 \left|0011\right\rangle\)。Hartree-Fock解 \(\left|1100\right\rangle\) に対して少し二電子励起状態 \(\left|0011\right\rangle\) が混ざっていることがわかる。
参考文献¶
6-2. Qulacs を用いた variational quantum eigensolver (VQE) の実装¶
この節では、OpenFermion・PySCF を用いて求めた量子化学ハミルトニアンについて、Qulacs を用いてシミュレータ上で variational quantum eigensolver (VQE) を実行し、基底状態を探索する例を示す。
必要なもの
qulacs
openfermion
openfermion-pyscf
pyscf
scipy
numpy
必要なパッケージのインストール・インポート¶
[ ]:
## 各種ライブラリがインストールされていない場合は実行してください
## Google Colaboratory上で実行する場合'You must restart the runtime in order to use newly installed versions.'と出ますが無視してください。
## runtimeを再開するとクラッシュします。
!pip install qulacs pyscf openfermion openfermionpyscf
[1]:
import qulacs
from openfermion.transforms import get_fermion_operator, jordan_wigner
from openfermion.transforms import get_sparse_operator
from openfermion.hamiltonians import MolecularData
from openfermionpyscf import run_pyscf
from scipy.optimize import minimize
from pyscf import fci
import numpy as np
import matplotlib.pyplot as plt
ハミルトニアンを作る¶
前節と同様の手順で、ハミルトニアンを PySCF によって計算する。
[2]:
basis = "sto-3g"
multiplicity = 1
charge = 0
distance = 0.977
geometry = [["H", [0,0,0]],["H", [0,0,distance]]]
description = "tmp"
molecule = MolecularData(geometry, basis, multiplicity, charge, description)
molecule = run_pyscf(molecule,run_scf=1,run_fci=1)
n_qubit = molecule.n_qubits
n_electron = molecule.n_electrons
fermionic_hamiltonian = get_fermion_operator(molecule.get_molecular_hamiltonian())
jw_hamiltonian = jordan_wigner(fermionic_hamiltonian)
ハミルトニアンを qulacs ハミルトニアンに変換する¶
Qulacs では、ハミルトニアンのようなオブザーバブルは Observable
クラスによって扱われる。OpenFermion のハミルトニアンを Qulacs の Observable
に変換する関数 create_observable_from_openfermion_text
が用意されているので、これを使えば良い。
[3]:
from qulacs import Observable
from qulacs.observable import create_observable_from_openfermion_text
qulacs_hamiltonian = create_observable_from_openfermion_text(str(jw_hamiltonian))
ansatz を構成する¶
Qulacs 上で量子回路を構成する。ここでは、量子回路は超伝導量子ビットによる実験 (A. Kandala et. al. , “Hardware-efficient variational quantum eigensolver for small molecules and quantum magnets“, Nature 549, 242–246) で用いられたものを模して作った。
[4]:
from qulacs import QuantumState, QuantumCircuit
from qulacs.gate import CZ, RY, RZ, merge
depth = n_qubit
[5]:
def he_ansatz_circuit(n_qubit, depth, theta_list):
"""he_ansatz_circuit
Returns hardware efficient ansatz circuit.
Args:
n_qubit (:class:`int`):
the number of qubit used (equivalent to the number of fermionic modes)
depth (:class:`int`):
depth of the circuit.
theta_list (:class:`numpy.ndarray`):
rotation angles.
Returns:
:class:`qulacs.QuantumCircuit`
"""
circuit = QuantumCircuit(n_qubit)
for d in range(depth):
for i in range(n_qubit):
circuit.add_gate(merge(RY(i, theta_list[2*i+2*n_qubit*d]), RZ(i, theta_list[2*i+1+2*n_qubit*d])))
for i in range(n_qubit//2):
circuit.add_gate(CZ(2*i, 2*i+1))
for i in range(n_qubit//2-1):
circuit.add_gate(CZ(2*i+1, 2*i+2))
for i in range(n_qubit):
circuit.add_gate(merge(RY(i, theta_list[2*i+2*n_qubit*depth]), RZ(i, theta_list[2*i+1+2*n_qubit*depth])))
return circuit
VQE のコスト関数を定義する¶
5-1節で説明した通り、VQE はパラメータ付きの量子回路 \(U(\theta)\) から出力される状態 \(|\psi(\theta)\rangle = U(\theta)|0\rangle\) に関するハミルトニアンの期待値
を最小化することで、近似的な基底状態を得る。以下にこのハミルトニアンの期待値を返す関数を定義する。
[6]:
def cost(theta_list):
state = QuantumState(n_qubit) #|00000> を準備
circuit = he_ansatz_circuit(n_qubit, depth, theta_list) #量子回路を構成
circuit.update_quantum_state(state) #量子回路を状態に作用
return qulacs_hamiltonian.get_expectation_value(state) #ハミルトニアンの期待値を計算
VQE を実行する¶
準備ができたので、VQE を実行する。最適化には scipy に実装されている BFGS 法を用い、初期パラメータはランダムに選ぶ。数十秒で終わるはずである。
[7]:
cost_history = []
init_theta_list = np.random.random(2*n_qubit*(depth+1))*1e-1
cost_history.append(cost(init_theta_list))
method = "BFGS"
options = {"disp": True, "maxiter": 50, "gtol": 1e-6}
opt = minimize(cost, init_theta_list,
method=method,
callback=lambda x: cost_history.append(cost(x)))
実行結果をプロットしてみると、正しい解に収束していることが見て取れる。
[8]:
plt.rcParams["font.size"] = 18
plt.plot(cost_history, color="red", label="VQE")
plt.plot(range(len(cost_history)), [molecule.fci_energy]*len(cost_history), linestyle="dashed", color="black", label="Exact Solution")
plt.xlabel("Iteration")
plt.ylabel("Energy expectation value")
plt.legend()
plt.show()

興味のある読者は、水素原子間の距離 distance
を様々に変えて基底状態を計算し、水素分子が最も安定になる原子間距離を探してみてほしい。(ansatzの性能にもよるが、およそ0.74オングストロームになるはずである)
6-3. 励起状態の探索手法 (subspace-search variational quantum eigensolver)¶
5-1節で説明した通り、variational quantum eigensolver (VQE) は量子系の基底状態を探すためのアルゴリズムである。基底状態は様々な物質の性質を決める重要な状態であるが、光応答など、励起状態 を使わなければ記述が困難な現象も存在する。
そこで、量子コンピュータ上に励起状態の波動関数を作り出すためのアルゴリズムも盛んに研究されている。その中でも、ここでは 2018 年に提案された新しい手法である部分空間探索 VQE (Subspace-Search VQE, SSVQE) [1] を紹介したい。
アルゴリズム¶
SSVQE の手続きを以下に示す。
互いに直交する \(k\) 個の初期状態 \(\{|\varphi_i\rangle\}_{i=0}^{k-1}\) を準備する。
そのそれぞれについて、適当な量子回路 \(U(\theta)\) を作用させ試行状態 \(\{|\psi_i(\theta)\rangle \}_{i=0}^{k-1}\) を生成する。(注:\(|\psi_i(\theta)\rangle = U(\theta)|\varphi_i\rangle\)である)
コスト関数 \(L(\theta) = \sum_i w_i \langle\psi_i(\theta)|H|\psi_i(\theta)\rangle\) を最小化するように \(\theta\) を調整する。ここで \(w_i\) は正かつ \(i>j\) のとき \(w_i<w_j\) となるように選ぶ。
\(U(\theta)\) が十分な表現能力を持っているとき、収束した \(\theta\) において \(|\psi_i\rangle\) は \(i\) 番目の励起状態となる。これは、ハミルトニアンの第 \(i\) 励起状態を \(|E_i\rangle\) とするとき。コスト関数の大域的な最小点が \(|\psi_i\rangle = |E_i\rangle\) となっているからである。(詳細は原論文[1]を参照のこと)
以下では、SSVQE を Qulacs を用いてシミュレートする例を示す。
SSVQE の実装¶
水素分子の基底状態と第一励起状態を探索する SSVQE を実装する。水素分子を sto-3g
の minimal basis set で扱うと、4 qubit のハミルトニアンが得られる。そこで SSVQE に必要な互いに直交する初期状態 \(\{|\varphi_i\rangle\}_{i=0}^{1}\) として、一つの qubit がフリップした \(|0000\rangle\) と \(|0001\rangle\) の二つを用いる。
量子化学へ詳しい人向けの注:ここでいう第一励起状態とは電子数を考慮しない水素分子のハミルトニアンの第一励起状態のことであり、化学の言葉だと水素分子イオンの基底状態のことである。
ハミルトニアンを作る¶
前回と同様の手順で、ハミルトニアンを PySCF + OpenFermion によって計算する。ただし SSVQE では励起状態も問題となるので、scipy.sparse.linalg.eigsh
を用いて励起状態の厳密解を求めておく。
[ ]:
## 各種ライブラリがインストールされていない場合は実行してください
## Google Colaboratory上で実行する場合'You must restart the runtime in order to use newly installed versions.'と出ますが無視してください。
## runtimeを再開するとクラッシュします。
!pip install qulacs pyscf openfermion openfermionpyscf
[4]:
import qulacs
from openfermion.transforms import get_fermion_operator, jordan_wigner
from openfermion.transforms import get_sparse_operator
from openfermion.hamiltonians import MolecularData
from openfermionpyscf import run_pyscf
from scipy.optimize import minimize
from pyscf import fci
import numpy as np
import matplotlib.pyplot as plt
[5]:
basis = "sto-3g"
multiplicity = 1
charge = 0
distance = 0.977
geometry = [["H", [0,0,0]],["H", [0,0,distance]]]
description = "tmp"
molecule = MolecularData(geometry, basis, multiplicity, charge, description)
molecule = run_pyscf(molecule,run_scf=1,run_fci=1)
n_qubit = molecule.n_qubits
n_electron = molecule.n_electrons
fermionic_hamiltonian = get_fermion_operator(molecule.get_molecular_hamiltonian())
jw_hamiltonian = jordan_wigner(fermionic_hamiltonian)
hamiltonian_matrix = get_sparse_operator(jw_hamiltonian)
from scipy.sparse.linalg import eigsh
eigval, eigvec = eigsh(hamiltonian_matrix, k=2, which="SA")
from qulacs import Observable
from qulacs.observable import create_observable_from_openfermion_text
qulacs_hamiltonian = create_observable_from_openfermion_text(str(jw_hamiltonian))
ansatz を構成する¶
Qulacs 上で量子回路を構成する。前回と同様に、量子回路は超伝導量子ビットによる実験 (A. Kandala et. al. , “Hardware-efficient variational quantum eigensolver for small molecules and quantum magnets“, Nature 549, 242–246) で用いられたものを模したものを使用する。
[6]:
from qulacs import QuantumState, QuantumCircuit
from qulacs.gate import CZ, RY, RZ, merge
depth = n_qubit
[7]:
def he_ansatz_circuit(n_qubit, depth, theta_list):
"""he_ansatz_circuit
Returns hardware efficient ansatz circuit.
Args:
n_qubit (:class:`int`):
the number of qubit used (equivalent to the number of fermionic modes)
depth (:class:`int`):
depth of the circuit.
theta_list (:class:`numpy.ndarray`):
rotation angles.
Returns:
:class:`qulacs.QuantumCircuit`
"""
circuit = QuantumCircuit(n_qubit)
circuit.add_gate(RY(0, theta_list[-2]))
circuit.add_gate(RZ(0, theta_list[-1]))
for d in range(depth):
for i in range(n_qubit):
circuit.add_gate(merge(RY(i, theta_list[2*i+2*n_qubit*d]), RZ(i, theta_list[2*i+1+2*n_qubit*d])))
for i in range(n_qubit//2):
circuit.add_gate(CZ(2*i, 2*i+1))
for i in range(n_qubit//2-1):
circuit.add_gate(CZ(2*i+1, 2*i+2))
for i in range(n_qubit):
circuit.add_gate(merge(RY(i, theta_list[2*i+2*n_qubit*depth]), RZ(i, theta_list[2*i+1+2*n_qubit*depth])))
return circuit
SSVQE のコスト関数を定義する¶
[8]:
def get_exp(state, theta_list):
circuit = he_ansatz_circuit(n_qubit, depth, theta_list) #量子回路を構成
circuit.update_quantum_state(state) #量子回路を状態に作用
return qulacs_hamiltonian.get_expectation_value(state)
def cost(theta_list):
state0 = QuantumState(n_qubit) #|00000> を準備
state1 = QuantumState(n_qubit); state1.set_computational_basis(1) #|00001> を準備
return get_exp(state0, theta_list)+0.5*get_exp(state1, theta_list)
init_theta_list = np.random.random(2*n_qubit*(depth+1)+2)*1e-1
cost(init_theta_list)
[8]:
0.211562756558141
SSVQE を実行する¶
準備ができたので、SSVQE を実行する。最適化には scipy に実装されている BFGS 法を用い、初期パラメータはランダムに選ぶ。数十秒で終わるはずである。
[10]:
exp_history0 = []
exp_history1 = []
def callback(theta_list):
state0 = QuantumState(n_qubit) #|0000> を準備
state1 = QuantumState(n_qubit); state1.set_computational_basis(1) #|0001> を準備
exp_history0.append(get_exp(state0, theta_list))
exp_history1.append(get_exp(state1, theta_list))
init_theta_list = np.random.random(2*n_qubit*(depth+1)+2)*1e-1
method = "BFGS"
options = {"disp": True, "maxiter": 50, "gtol": 1e-6}
opt = minimize(cost, init_theta_list,
method=method,
callback=callback)
実行結果をプロットしてみると、正しい解に収束していることが見て取れる。
[20]:
plt.rcParams["font.size"] = 18
plt.plot(exp_history0, label=r"input $|0000\rangle$")
plt.plot(exp_history1, label=r"input $|0001\rangle$")
plt.plot(range(len(exp_history0)), [molecule.fci_energy]*len(exp_history0), linestyle="dashed", color="black", label="Exact ground state energy")
plt.plot(range(len(exp_history1)), [eigval[1]]*len(exp_history1), linestyle="-.", color="black", label="Exact 1st excited state energy")
plt.xlabel("Iteration")
plt.ylabel("Energy expectation value")
plt.legend(bbox_to_anchor=(1.05, 1), loc='upper left', borderaxespad=0, fontsize=18)
plt.show()

参考文献¶
[1] K. M Nakanishi, K. Mitarai, and K. Fujii, “Subspace-search variational quantum eigensolver for excited states”, Phys. Rev. Research 1, 033062 (2019), https://arxiv.org/abs/1810.09434
第7章 量子位相推定アルゴリズムの発展¶
第7章では、最も重要な量子アルゴリズムの一つである「量子位相推定アルゴリズム」と、それをサブルーチンとして用いて連立一次方程式を高速に解く Harrow-Hassidim-Lloyd (HHL) アルゴリズムについて解説する(量子位相推定アルゴリズムそのものは、既に2-4節で簡単に紹介している)。 さらに、HHLアルゴリズムを実際の問題に応用するときに必要になる量子ランダムアクセスメモリー(qRAM)や、HHLアルゴリズムをポートフォリオ最適化という金融工学の問題に適用した例も紹介する。本章の内容は、量子コンピュータを用いた機械学習の高速化や大規模分子の高精度エネルギー計算など幅広い分野への応用が期待されているため、読者の方には参考文献などにも当たりつつ興味を広げて頂きたい。 (なお、この章で紹介するアルゴリズムは long-term アルゴリズム(量子誤り訂正が実装された量子コンピュータでないと動作しないであろうアルゴリズム)である。)
7-1. 量子位相推定アルゴリズム詳説:水素分子への応用を例として¶
この節では、量子位相推定アルゴリズム (Quantum Phase Estimation, QPE) の復習をするとともに、QPEを用いて量子多体系のハミルトニアン(水素分子)の固有値を求めてみる。その過程で、QPEを実際の問題に応用する際の流れやテクニックを詳しく学んでいく。
位相推定の復習¶
2-4. 位相推定アルゴリズム(入門編)で既に学んだように、QPEは、適当なユニタリ行列 \(U\) が与えられたときにその固有値 \(e^{i \lambda}\) をビット列として取り出すアルゴリズムである。 QPEをサブルーチンとして用いることで、素因数分解や量子多体系のエネルギー計算といった(ユニタリ行列の固有値問題に帰着できる)多くの問題を、古典コンピュータと比べて非常に高速に解けるようになることが期待されている。
具体的にQPEの変換を思い出してみよう。\(U\) の \(i\)番目の固有値 \(e^{i \lambda_i}\) に対応する固有ベクトルを \(| u_i \rangle\) とする (\(U | u_i \rangle = e^{i \lambda_i} | u_i \rangle\))。この時、制御ユニタリ演算 \(\Lambda (U)\) と量子フーリエ変換を用いて、QPEは次の変換を実現する。
ここで、 \(| 0 \rangle{}^{\otimes{t}}\) は \(t\) 個の補助量子ビットであり、\(\tilde{\lambda_i}=j_1j_2 \ldots j_t\) は \(\lambda_i\) を \(t\) 桁目まで2進展開したビット列
である。 ( \((t+1)\) 桁目以降は省略した)
このQPEを実用的な問題に対して実行するには、誤り訂正機能のある量子コンピュータが必要であると考えられている (long-term アルゴリズム)。主な理由としては、
補助ビットの数
制御ユニタリゲート \(\Lambda (U^{2^{k-1}})\) (\(k =1, ..., t\)) の複雑さ
といった点が挙げられる。まず補助ビットの数についてだが、例えば現在の古典コンピュータで使われているような倍精度実数の精度で \(\lambda_i\) を求めるには50個以上の補助ビットが必要になる 。 そして、そのような多数の補助ビット(つまり大きな \(t\) )を用いる場合、制御ユニタリゲート \(\Lambda (U^{2^{k-1}})\) (\(k =1, ..., t\)) を \(U\) の非常に大きなべき乗に対して用意する必要が生じる。 このような制御ゲートを用意する難しさは \(U\) としてどのような行列を考えるかに依存するが、一般には簡単でない: 後で見るように、QPEの応用が最も期待されている問題の一つである「エルミート行列 \(H\) の固有値問題」の場合は、\(U\) を \(H\) による時間発展演算子、つまり\(U = e^{-iH\tau}\) (\(\tau\)は定数) とおくので、\(U\) の大きなべきは \(H\) による(精度の逆数に対して指数関数的に)長時間の時間発展をシミュレーションすることを意味する。これは非常に難しい。
反復的位相推定¶
実は、\(U\) の固有値のみに興味があり固有状態が必要ないのであれば、QPEの補助ビットを減らすことができる。 これは反復的位相推定法 (Iterative Quantum Phase Estimation (IQPE), https://journals.aps.org/pra/abstract/10.1103/PhysRevA.76.030306) と呼ばれており、すでに2-4節の「はじめに:アダマールテストを改良する」の箇所で紹介した方法と等価なのだが、ここに改めて記しておく。
IQPEは固有値を2進展開したビット列を、各桁ごとに決定論的に求める方法である。必要な補助ビットは1つで、1イテレーションごとに固有値の2進小数表示 \(0.j_1...j_t\) の1つの桁の値 (\(j_k\)) を求める。 手順は以下の通りである (\(k = t, t-1, \ldots, 1\)とイテレーションする):
\(k = t\) のとき¶
補助ビットにアダマールゲートをかける
補助ビットに \(\Lambda (U^{2^{t-1}})\) をかける
補助ビットにアダマールゲートをかけて測定する
測定結果 \(j_t\) を、蓄積位相 \(\Phi(t)\) に反映させる: \(\Phi(t) = \pi \cdot \frac{j_t}{2} = \pi 0. j_t\)
\(k = t-1, t-2, \ldots, 1\) のとき¶
補助ビットにアダマールゲートをかける
補助ビットにZ回転ゲート \(R_Z ( \Phi(k+1) )\) (ここで \(R_Z(\theta)=e^{i\theta/2 Z}, \Phi(k+1) = \pi 0.j_{k+1} j_{k+2} \ldots j_{t}\)となっている) をかける
補助ビットに \(\Lambda (U^{2^{k-1}})\) をかける
補助ビットにアダマールゲートをかけて測定する
測定結果 \(j_k\) を 蓄積位相 \(\Phi(k)\) に反映させる:
全ての\(j_k\) (\(k = t, t-1, \ldots, 1\)) を測定した後¶
所望の位相
が得られる。
例: 量子位相推定アルゴリズムを用いた水素分子ハミルトニアンの基底状態エネルギーの計算¶
上記の反復的位相推定アルゴリズムを用いて、実際に水素分子の基底状態エネルギーを求めてみよう(以下の内容は論文[1] を参考としている)。 4-1節 や 6-1節 で学んだように、水素分子の基底状態エネルギーとは、水素分子を表すハミルトニアン \(H\) というエルミート行列の最小固有値のことである。 QPEやIQPEでは、ハミルトニアン \(H\) の固有値問題を、その時間発展演算子 \(U = e^{−iH \tau}\) の固有値を求めることで解いている。 ここで\(\tau\)は何らかの定数であり、\(H\) の最小固有値と最大固有値を \(E_{\text{min}}, E_{\text{max}}\) とした時に、\([ E_{\text{min}}, E_{\text{max}}]\) が \([0, 2\pi]\) に収まるようにとっておく(必要であれば、定数シフトも加える)。QPE・IQPEでは \(U\) の固有値の位相には \(2\pi\) の不定性があるが、こうしておけば \(U\) の固有値から \(H\) の固有値が一意に復元できるからだ。
基底エネルギー計算に必要なステップは以下の通りである:
(ハミルトニアンのサイズを対称性などを用いて削減する)
ハミルトニアンの時間発展演算子 \(U=e^{-iH\tau}\) を精度よく近似する
制御時間発展演算子を量子コンピュータで容易に実行可能なゲートセットに分解し実装する
基底状態と十分重なりのある初期状態を準備する
IQPEでエネルギー固有値を測定する
以下、順を追って手法の詳細な説明と実装例を示す。
0. (ハミルトニアンのサイズを対称性などを用いて削減する)¶
このステップは、実際にIQPEをシミュレータや実機で動かすときに、なるべくリソースを減らすために必要な工程であり、理論上必須ではない。 詳しくは文献 [1] に譲るが、水素分子の第二量子化されたハミルトニアン (STO-6G 基底) を Bravyi-Kitaev 変換で qubit 系にmapすると、そのままでは 4qubit のハミルトニアン = \(16 \times 16\) 行列になる。しかし、ハミルトニアンの持つ対称性(電子数保存則など)を用いてハミルトニアンを部分対角化して考えると、基底状態を求める際には 2 qubit で良いことがわかり、さらにそのハミルトニアンは以下のような6種類の項しか持たない。
ここで係数 \(g_i\) は実数で、その値は水素分子の原子間距離(配置)に依存する。 化学に馴染みのない読者は、とにかく水素分子に対応した上記のエルミート行列が存在し、それの最小固有値を求めることが重要であるとだけ理解しておけば問題ない。
1. ハミルトニアンの時間発展演算子 \(U=e^{-iH\tau}\) を精度よく近似する¶
IQPEで用いる制御ユニタリ演算 \(\Lambda (U^{2^k})\) を実装するため、まずは時間発展演算子 \(U = e^{−iH \tau}\) を量子回路に実装する。 まず、定数項 \(g_0 I\) と\(g_3 Z_0 Z_1\) 項がハミルトニアンの他の全ての項と交換することに注意すると、\(U\) は次のようになる。
ここで、\(H_{\text{eff}}\) は
である。\(g_0 I\) と\(g_3 Z_0 Z_1\) 部分の固有値への寄与は後から簡単に加算することができるので、以下では \(H_{\textrm{eff}}\) の固有値を \(U_{\text{eff}} := e^{−i H_{\text{eff}} \, \tau}\) のIQPEを用いて求めることを考える。
\(U_{\text{eff}}\) をトロッター展開 (4-2節) すると、
となる。 \(U_{\text{Trot}}^{(N)}\) に現れる積の各項は multi-qubit Pauli rotation gate、つまりパウリ行列の積の指数関数 \(\exp(i\theta P)\) の形をしているので、簡単な量子ゲートの積として実装することが容易になっている。これで \(U_{\textrm{eff}}\) を近似的に量子回路上へ実装することができた。
この近似のエラーについて調べてみよう。解析的にざっくりと評価すると
となる[2]。この表式によると、欲しい精度 \(\epsilon\) に対して \(N \sim \tau^2/\epsilon\) 程度の大きさの \(N\) をとれば良い事が分かる。 今回取り扱う系は \(4 \times 4\) という小さい行列で表されるので、\(H_{\textrm{eff}}\) を実際に厳密に対角化してみてその最小固有値 \(E_{\textrm{min}}\) を求め、\(U_{\textrm{Trot}}^{(N)}\) の固有値 \(e^{i \lambda_{\textrm{Trot}}\tau}\) の \(\lambda_{\textrm{Trot}}\) と比較してみよう。
まずは \(H_{\textrm{eff}}\) の対角化を行う。エネルギーの単位はハートリー(Ha)というものが使われている。
[1]:
from functools import reduce
import numpy as np
from numpy.linalg import matrix_power, eig
from scipy.sparse.linalg import eigsh
from openfermion.ops import QubitOperator
from openfermion.transforms import get_sparse_operator
from qulacs import QuantumState, Observable, QuantumCircuit
import matplotlib.pyplot as plt
[2]:
def hamiltonian_eff():
"""
distance = 0.70 A
removed 'I' and 'Z0 Z1' terms, which add up to -1.31916027
"""
n_qubits = 2
g_list = [0.3593, 0.0896, -0.4826, 0.0896] ## taken from table 1 of paper [1]
pauli_strings = ['Z0', 'Y0 Y1', 'Z1', 'X0 X1']
hamiltonian = QubitOperator()
for g, h in zip(g_list, pauli_strings):
hamiltonian += g * QubitOperator(h)
sparse_matrix = get_sparse_operator(hamiltonian, n_qubits=n_qubits)
vals, vecs = eigsh(sparse_matrix, k=1, which='SA') ## only smallest eigenvalue and eigenvector are calculated
return sparse_matrix, vals
[3]:
_, eigs = hamiltonian_eff()
exact_eigenvalue = eigs[0]
print('exact_eigenvalue: {:.10f} Ha'.format(exact_eigenvalue)) ## print eigenvalue up to 10 digits after decimal point
exact_eigenvalue: -0.8607602744 Ha
次に \(U_{\textrm{Trot}}^{(N)}\) を対角化してみる。後のステップでは \(U_{\textrm{Trot}}^{(N)}\) を量子回路として具体的に実装するが、ここでは \(H_i^2 = I\) (恒等行列) の時
となる性質を用いて計算してしまう。 そして、\(N = 1, 3, \ldots, 9\) において \(U_{\textrm{Trot}}^{(N)}\) の固有値 \(e^{-i \lambda_{\textrm{Trot}}\tau}\) の \(\lambda_{\textrm{Trot}}\) を求め、 \(E_{\textrm{min}}\) と比較する。
[4]:
def order_n_trotter_approx(t, n_trotter_steps):
"""
ordering: 'Z0', 'Y0 Y1', 'Z1', 'X0 X1'
Returns:
sparse_matrix: trotterized [exp(iHt/n)]^n
args: list of phases of each eigenvalue, exp(i*phase)
"""
n_qubits = 2
g_list = [0.3593, 0.0896, -0.4826, 0.0896]
pauli_strings = ['Z0', 'Y0 Y1', 'Z1', 'X0 X1']
terms = []
for g, h in zip(g_list, pauli_strings):
arg = g * t / n_trotter_steps
qop = complex(np.cos(arg), 0) * QubitOperator('') - complex(0, np.sin(arg)) * QubitOperator(h)
terms += [get_sparse_operator(qop, n_qubits=n_qubits)]
sparse_matrix = reduce(np.dot, terms)
matrix = matrix_power(sparse_matrix.toarray(), n_trotter_steps) ## this is U_{trot}^{(N)}
vals, vecs = eig(matrix) ## e^{i lambda_{trot} }
args = np.angle(vals) ## returns value in [-pi, pi] -> we don't modify output since we know exact value is around -0.86
return sparse_matrix, sorted(args) ## we return sorted values
[5]:
tau = 0.640 ## taken from table 1 of paper [1]
print('N, E_trot, |exact_eig - E_trot|')
for n in range(1, 10, 2):
_, phases = order_n_trotter_approx(tau, n)
e_trotter = phases[0]/tau
print( f"{n}, {e_trotter:.10f}, {abs(exact_eigenvalue - e_trotter):.3e}" )
N, E_trot, |exact_eig - E_trot|
1, -0.8602760326, 4.842e-04
3, -0.8607068561, 5.342e-05
5, -0.8607410548, 1.922e-05
7, -0.8607504700, 9.804e-06
9, -0.8607543437, 5.931e-06
お分かり頂けただろうか? 次数 \(N\) が増えるごとに近似精度が上がっており、真のエネルギー固有値を chemical accuracy ( \(1.6 × 10^{−3}\) Ha) と呼ばれる化学計算で必要な精度で近似するには \(N = 1\) で今回は十分であることが分かる。
2. 制御時間発展演算子を量子コンピュータで容易に実行可能なゲートセットに分解し実装する¶
量子コンピュータ上で制御時間発展演算子 \(\Lambda \left( \left( U_{\textrm{Trot}}^{(N)} \right)^{2^k} \right)\) を実行するためには、これを簡単な量子ゲートに分解する必要がある。 今回の例では、 \(U_{\textrm{Trot}}^{(N)}\) に含まれる
\(\Lambda(R_Z(\theta))\)
\(\Lambda(R_{XX}(\theta))\)
\(\Lambda(R_{YY}(\theta))\)
という制御回転ゲートを分解できれば良い。ここで \(R_Z(\theta) = e^{i\theta/2 Z_x}\) は \(x=0,1\) 番目のqubitに作用する \(Z\) 方向の回転ゲート、\(R_{XX}(\theta) = e^{i\theta/2 X_0 X_1}, R_{YY}(\theta) = e^{i\theta/2 Y_0 Y_1}\) も回転ゲートである。
まず、 \(\Lambda(R_Z(\theta))\) は 制御ビット \(| c \rangle\) と標的ビット \(| t \rangle\) にかかると、
を満たすゲートである \((c=0,1)\) 。 \(\textrm{CNOT} | c \rangle | t \rangle = | c \rangle X^c | t \rangle\)、 \(XZX = -Z\) が成り立つことに注意すると、
と式変形できるから、
が言える。つまり、制御回転ゲート \(\Lambda(R_Z(\theta))\) が CNOTゲートと \(Z\) 回転ゲートという基本的なゲートを使って実装できた。
さらに、
の性質を用いると、\(\Lambda(R_{ZZ}(\theta))\) が実現できる。 そして、\(H Z H = X\) や \(SH Z HS^{\dagger} = Y\) を用いると \(\Lambda(R_{XX}(\theta))\)、\(\Lambda(R_{YY}(\theta))\) がそれぞれ実現できる。詳細は以下の実装も参照してほしい。
以下のコードでは、 Qulacs で制御時間発展演算子 \(\Lambda \left( \left( U_{\textrm{Trot}}^{(N)} \right)^{2^k} \right)\) の量子回路を実装し、IQPEで実行すべき回路を作っている。回路図は以下のようである。
[6]:
def IQPE_circuit(g_list, tau, kickback_phase, k, n_trotter_step=1):
n_qubits = 3 ## 2 for system, 1 for ancillary
a_idx = 2 ## ancilla index
phi = -(tau / n_trotter_step) * g_list ## coefficient for Pauli
circuit = QuantumCircuit(n_qubits)
## Apply Hadamard to ancilla
circuit.add_H_gate(a_idx)
## Apply kickback phase rotation to ancilla bit
circuit.add_RZ_gate(a_idx, kickback_phase)
## controlled time evolution (Apply controll-e^{-iH*tau} for 2^{k-1} times
for _ in range( 2 ** (k-1) ):
for _ in range(n_trotter_step):
# CU(Z0) i.e. controlled exp(i phi[0]*Z_0)
circuit.add_RZ_gate(0, phi[0]) ## note that qulacs' RZ is defined as RZ(theta) = e^{i*theta/2*Z}
circuit.add_CNOT_gate(a_idx, 0)
circuit.add_RZ_gate(0, -phi[0])
circuit.add_CNOT_gate(a_idx, 0)
# CU(Y0 Y1)
circuit.add_Sdag_gate(0)
circuit.add_Sdag_gate(1)
circuit.add_H_gate(0)
circuit.add_H_gate(1)
circuit.add_CNOT_gate(0, 1)
circuit.add_RZ_gate(1, phi[1])
circuit.add_CNOT_gate(a_idx, 1)
circuit.add_RZ_gate(1, -phi[1])
circuit.add_CNOT_gate(a_idx, 1)
circuit.add_CNOT_gate(0, 1)
circuit.add_H_gate(0)
circuit.add_H_gate(1)
circuit.add_S_gate(0)
circuit.add_S_gate(1)
# CU(Z1)
circuit.add_RZ_gate(1, phi[2])
circuit.add_CNOT_gate(a_idx, 1)
circuit.add_RZ_gate(1, -phi[2])
circuit.add_CNOT_gate(a_idx, 1)
# CU(X0 X1)
circuit.add_H_gate(0)
circuit.add_H_gate(1)
circuit.add_CNOT_gate(0, 1)
circuit.add_RZ_gate(1, phi[3])
circuit.add_CNOT_gate(a_idx, 1)
circuit.add_RZ_gate(1, -phi[3])
circuit.add_CNOT_gate(a_idx, 1)
circuit.add_CNOT_gate(0, 1)
circuit.add_H_gate(0)
circuit.add_H_gate(1)
## Apply Hadamard to ancilla
circuit.add_H_gate(a_idx)
return circuit
3. 基底状態と十分重なりのある初期状態を準備する¶
これまでのQPE・IQPEの説明では、簡単のために \(U\) が作用する状態はその固有状態であることを仮定してきた。実は、入力状態が「固有値を知りたい状態に十分近い(重なりがある)状態」であっても、十分高い精度でその固有値を求めることができる。
\(|n \rangle\) をハミルトニアンの固有状態、それに対応する固有値を \(E_n\) 、参照状態を \(|\phi \rangle = \sum_n c_n |n \rangle\) とすると、QPE・IQPEの回路の作用は(\(\tau=-1\)とした)
となる。ここで例えば1番目の補助ビット \(j_1\) の測定が行われると、0が観測される確率は \(\sum_{n \, \textrm{s.t.} \tilde{E_n}^{(1)}=0} |c_n|^2\) 、つまり固有値の2進小数展開の1桁目が0であるような固有値\(E_n\)についての重みの和に比例する。よって、もし \(\{|c_n|^2\}_n\) の値が所望の状態についてのみ十分大きければ、\(j_1\) の測定を繰り返して0か1か多く観測された方を採用すれば、所望の固有値が得られることが言える。(もう少し厳密な取り扱いは、Nielsen-Chuangの Exercise 5.8
も参照)
今回の水素分子の基底エネルギーを求める問題の場合、 Hartree-Fock (HF) 状態 \(|\phi \rangle = |01 \rangle\) が十分に基底状態に近い為、これを参照状態とする。(注:今回の問題では、HF状態が \(|01\rangle\) になっている[1])
4. IQPEでエネルギー固有値を測定する¶
それでは、IQPEを実行してみよう。
以下の実装では、補助ビットなど特定の量子ビットのみを測定しその結果を用いる際に便利な state.get_marginal_probability(bit_list)
を使っている。これは量子状態 state
の特定の量子ビットが特定のビット値を持っている確率を、波動関数の振幅から計算する関数である。 例えば 補助ビット (index=2
) が 0
状態 (0、1番目の量子ビットに関しては測定しない) である 確率は、get_marginal_probability([2, 2, 0])
で得られる (2
は測定しない事を表している)。
[7]:
from qulacs.circuit import QuantumCircuitOptimizer
def iterative_phase_estimation(g_list, tau, n_itter, init_state, n_trotter_step=1, kickback_phase=0.0):
for k in reversed(range(1, n_itter+1)): ## run from n_itter to 1
psi = init_state.copy()
circuit = IQPE_circuit(np.array(g_list), tau, kickback_phase, k, n_trotter_step=n_trotter_step)
## 実行時間短縮のため回路の最適化を行う
opt = QuantumCircuitOptimizer()
max_block_size = 4
opt.optimize(circuit, max_block_size)
## execute circuit
circuit.update_quantum_state(psi)
# partial trace
p0 = psi.get_marginal_probability([2, 2, 0])
p1 = psi.get_marginal_probability([2, 2, 1])
# update kickback phase
#print(f"k={k:2d}, p0={p0:.3f}, p1={p1:.3f}")
kth_digit = 1 if (p0 < p1) else 0
kickback_phase = 0.5 * kickback_phase + np.pi * 0.5 * kth_digit
return 2 * kickback_phase
それでは、位相を何桁まで測定すれば良いか (\(0.j_1 \ldots j_t\) の \(t\) をどこまで大きくとるべきか) というベンチマークを取りつつ、IQPEを実行する。 化学で基底状態エネルギーの計算精度の一つの目安とされる chemical accuracy (\(1.6 \times 10^{-3}\) Ha) の精度が必要な場合、イテレーションの回数を理論的に見積もると[3]
となる。 つまり \(t = 11\) 程度にとれば十分そうであるが、実際に計算してみよう。
[8]:
n_qubits = 3 # 2 for electron configurations and 1 for ancilla
g_list = [0.3593, 0.0896, -0.4826, 0.0896]
# pauli_strings = ['Z 0', 'Y 0 Y 1', 'Z 1', 'X 0 X 1']
hf_state = QuantumState(n_qubits)
hf_state.set_computational_basis(0b001) # |0>|01>
tau = 0.640
e_trotter = -0.8602760325707504 ## exact one of U_{Trot}^{(N)}
print(f"e_trotter={e_trotter:.10f}")
result_list = []
for n_itter in range(1, 12+1): # precission in digit
iqpe_phase = iterative_phase_estimation(g_list, tau, n_itter, hf_state, n_trotter_step=1, kickback_phase=0.0)
e_iqpe = - iqpe_phase/tau ## U=exp(-iH*tau) so the IQPE picks up eigenvalue of -H*tau
print(f"n_itter={n_itter:2d}, e_iqpe={e_iqpe:10f}, error={np.abs(e_iqpe-e_trotter):.5e}")
result_list.append([n_itter, e_iqpe])
#print('e_iqpe = {} Ha, |e_iqpe-e_trotter| = {} Ha'.format(e_iqpe, abs(e_iqpe-e_trotter)))
e_trotter=-0.8602760326
n_itter= 1, e_iqpe= -0.000000, error=8.60276e-01
n_itter= 2, e_iqpe= -0.000000, error=8.60276e-01
n_itter= 3, e_iqpe= -1.227185, error=3.66909e-01
n_itter= 4, e_iqpe= -0.613592, error=2.46684e-01
n_itter= 5, e_iqpe= -0.920388, error=6.01124e-02
n_itter= 6, e_iqpe= -0.920388, error=6.01124e-02
n_itter= 7, e_iqpe= -0.843689, error=1.65866e-02
n_itter= 8, e_iqpe= -0.843689, error=1.65866e-02
n_itter= 9, e_iqpe= -0.862864, error=2.58816e-03
n_itter=10, e_iqpe= -0.862864, error=2.58816e-03
n_itter=11, e_iqpe= -0.858071, error=2.20553e-03
n_itter=12, e_iqpe= -0.860467, error=1.91316e-04
[9]:
## 結果のプロット
result_array = np.array(result_list)
plt.xlabel("# of digit", fontsize=15)
plt.ylabel("Error", fontsize=15)
plt.semilogy(result_array[:,0], np.abs(result_array[:,1] - e_trotter), "bo-")
plt.xlim(0,13)
plt.fill_between([0,13], 1.6e-3, color = "lightgrey") ## fill the chemical accuracy region
[9]:
<matplotlib.collections.PolyCollection at 0x121d55150>

お分かり頂けただろうか? 予想通り n_itter = 12
でようやく chemical accuracy に到達した。
ここで1点注意が必要なのは、ここで紹介したサンプルコードでは(制御)時間発展演算子 \(\Lambda \left( \left( U_{\textrm{Trot}}^{(N)} \right)^{2^k} \right)\) のゲートの深さが \(t\) に関して指数的に増大している事である。つまり、精度を上げるのに指数関数的に多くのゲートを用いなければならない。ここでは単純なトロッター分解に基づく方法を紹介したが、他の方法を使って時間発展演算子を効率的に実装する研究も数多くなされている。興味を持たれた読者は文献[4][5][6]を参照されたい。
参考文献¶
7-2. Harrow-Hassidim-Lloyd (HHL) アルゴリズム¶
この節では、量子位相推定アルゴリズムの重要な応用先の一つであるHarrow-Hassidim-Lloyd (HHL) アルゴリズムについて紹介する。HHLアルゴリズムは(sparseな)連立一次方程式を高速に 「解く」 アルゴリズムであり、連立一次方程式は電磁気・熱流体解析や機械学習などあらゆる科学技術計算で用いられるために、非常に注目されている。 本節の内容は、原論文[1]およびレビュー論文[2]に基づいている。
問題設定¶
HHLアルゴリズムは、sparse (疎)で正則な \(N \times N\) 行列 \(A\) と \(N\) 次元のベクトル \(\bf{b}\) について、連立一次方程式 \(A\mathbf{x}=\mathbf{b}\)の解 \(\mathbf{x}=A^{-1}\mathbf{b}\) を効率的に「計算」するアルゴリズムである(この節では、ベクトルを 太字 で表す):
この式の意味について説明しよう。まず、ケットの中にベクトルが入った \(|\mathbf{x}\rangle, |\mathbf{b}\rangle\) といった状態は、以下のように定義されている:
ここで \(x_i, b_i\) はベクトルの \(i=0,1,\ldots, N-1\) 番目の成分であり、\(|i\rangle\) は\(i\)の2進数表示に対応する計算基底である(例えば、\(|5\rangle = |0\cdots0101\rangle\))。 \(N\) 成分のベクトルを表すのには、 \(\log_2 N\) 個の量子ビットを用意すればよい。 HHLアルゴリズムは、量子位相推定アルゴリズムと補助ビットを駆使して、 入力状態 \(|\mathbf{b}\rangle\) から解状態 \(| A^{-1}\mathbf{b} \rangle\) を精度よく高速に作り出すアルゴリズムなのである。
計算量の詳しい説明はこの節の最後に行うが、HHLアルゴリズムは上記の変換を\(O(\text{poly}(\log N))\)、つまり\(\log N\) の多項式程度の計算量で行うことができる。同様の計算を行う現在のベストな古典アルゴリズムの計算量は\(O(N)\)であるから、HHLアルゴリズムは指数加速を達成している。 ただしいくつかの重要な注意点がある:
行列 \(A\) は、sparseでなければならない。具体的には、各行に含まれる非ゼロの成分の数が \(O(\text{poly}(\log N))\) でなければならない。
与えられた古典データ \(\mathbf{b}\) から、量子コンピュータ上に状態 \(|\mathbf{b}\rangle\) を用意するのは一般には簡単でなく、愚直に入力すると \(O(N)\) の計算量がかかってしまう。上記の \(O(\text{poly}(\log N))\) という計算量は 状態 \(|\mathbf{b}\rangle\) が用意できた前提での話である。 コラム:量子ランダムアクセスメモリ (qRAM)では、この点をもう少し深く解説する。
出力された解状態 \(| A^{-1}\mathbf{b} \rangle\) を古典ベクトル \(A^{-1}\mathbf{b}\) として読みだすのも、愚直に行うと\(O(N)\) の時間がかかってしまい、指数加速が相殺されてしまう。
アルゴリズムの流れ¶
それでは、一番簡単なバージョンを例にとり、HHLアルゴリズムの流れを説明する。全体の回路図は次のとおりである[2]。
以下では簡単のため、 \(A\) はエルミート行列であると仮定する。Aがエルミートでないときは、
とおいて、解 \(\tilde{\mathbf{x}} = (\mathbf{0}, \mathbf{x})^T\) を用いれば良い。また、 \(A\)を適当に定数倍して、 \(A\) の固有値の最大値と最小値の差が \(2\pi\) 以下になるようにしておく(量子位相推定アルゴリズムを用いた時に固有値とビット列が1対1に対応するようにするため)。そして、その定数倍した \(A\) の固有値をシフトしたとき、全固有値が \([0, 2\pi]\) に収まるような定数シフト \(d\) も求めておく(詳細は後述する;とりあえず \(d=0\) としておく)。
1. 入力状態 \(|\mathbf{b}\rangle\) を用意する¶
先ほども述べたように、qRAMなどを使って、与えられた古典データ(ベクトル) \(\mathbf{b}\) から量子コンピュータ上に状態 \(|\mathbf{b}\rangle\) を用意する。 これから補助ビットを複数用いていくので、入力状態に用いる量子ビットには添字 \(I\) をつけて \(|\mathbf{b}\rangle_I\) と表記しておく。
2. ユニタリ演算 \(e^{i A }\) を用いた位相推定アルゴリズムを使って補助クロックビットに \(A\) の固有値を格納する¶
位相推定アルゴリズム用の補助クロック(C)ビットを \(n\) 量子ビット分用意する。
そして、ユニタリ演算 \(e^{iA}\) に対する量子位相推定アルゴリズムを実行し、\(A\) の固有値 \(\{ \lambda_i \}_{i=0}^{N-1}\)を補助クロックビットに格納する。 具体的に言うと、古典ベクトル \(\mathbf{b}\) を \(A\) の固有ベクトル \(\{\mathbf{u}_i \}_{i=0}^{N-1}\) で展開して
となっているとき、量子状態の意味でも
が成り立つことに注意すると、量子位相推定アルゴリズムによって
となることがいえる。ここで \(\tilde{\lambda}\) は \(\lambda\) の2進数表示 \(\lambda = 2\pi 0.j_1 \ldots j_n\) のビット列 \(j_1\ldots j_n\) である。
3. 補助クロックビットを用いた制御回転によって固有値の逆数をかける¶
さらに補助ビットを一個追加し、添字 \(S\) で表そう。
ここで、補助クロックビットを用いた次のような制御回転ゲートを作用させる。
つまり、補助クロックビットの値 \(\tilde{\lambda}\) に応じて、補助ビット \(S\) に 回転角 \(\theta = 2\arctan{( - c/(\lambda \sqrt{1-c^2/{\lambda}^{2} }) )}\) のY回転 \(R_Y(\theta)=e^{i\theta/2Y}\) を行うゲートを作用させる。 \(c\) はこのような制御回転ゲートを可能にするために導入した規格化定数であり、ありうる \(|\lambda|\) の最小値より小さくとっておけば良い: \(|c| \leq \max |\lambda|\)。なお、最初に定義した定数シフト \(d\) が非ゼロの時は、\(\lambda\) と \(\lambda + d\) と置き換えておけばよい。
この制御回転ゲートの構成はかなりテクニカルなので、興味ある読者は以下の注を読んでほしいが、かなりたくさんの補助ビットが必要になることをremarkしておく。
量子回路は古典回路を包含するので、原理的には古典回路によって行えるどのような演算も行うことができる。そこで、回転角を計算する\(\lambda \to 2\arctan( - c/(\lambda \sqrt{1-c^2/\lambda^2}) )\) という古典演算を重ね合わせた \(|\lambda \rangle \to |2\arctan( - c/(\lambda \sqrt{1-c^2/\lambda^2}) ) \rangle\) というゲートも構成可能である。ただし、古典回路でNANDなどの不可逆なゲートが1つ出てくるたびに補助ビットが1つ以上必要であり、この算数を行うためだけにかなりの補助ビットが必要とされる。 そして、\(\theta\) の値に応じた制御回転ゲート \(|\theta\rangle |0\rangle_S \to |\theta\rangle R_Y(\theta) |0\rangle_S\) は量子位相推定アルゴリズムのように単純な制御 \(R_Y\) ゲート \(\Lambda(R_Y)\) を用いることで実装可能だから、所望の補助クロックビットを用いた制御回転ゲートも実装可能である。
4. 量子位相推定の逆演算を行い、補助クロックビットを元に戻す¶
制御回転によって、全体の状態は次のようになっている。
ここに量子位相推定アルゴリズムの逆演算を施すと、
となる。
5. 補助ビット \(S\) を測定する¶
最後に、補助ビット \(S\) を測定する。 1
が得られたとすると、状態は
となる。補助クロックビットも \(|0\cdots0\rangle\) に射影測定してしまえば、状態
が得られる。実はこれが \(| A^{-1}\mathbf{b} \rangle\) になっているのである!
\(\because\) \(A\) の固有値・固有ベクトルが \(\lambda_i, \mathbf{u}_i\) なので、 \(A = \sum_i \lambda_i \mathbf{u}_i {\mathbf{u}_i}^{\dagger}\) と固有値分解できる( \(\dagger\) は転置共役)。よって \(A^{-1} = \sum_i (\lambda_i)^{-1} \mathbf{u}_i {\mathbf{u}_i}^{\dagger}\) であり、 \(A^{-1}\mathbf{b} = \sum_i \beta_i (\lambda_i)^{-1} \mathbf{u}_i\) となる。規格化係数を適当に調整すれば、上記の状態が \(| A^{-1}\mathbf{b} \rangle\) となっていることが分かる。
計算量について¶
以上がHHLアルゴリズムの流れである。最後に計算量について触れておく。
\(s\) を \(A\) の sparsity、つまり各行に入っている非ゼロ要素の個数の最大値
\(\kappa\) を \(A\) の条件数: \(\kappa = |\lambda|_{\textrm{max}} / |\lambda|_{\textrm{min}}\) (固有値の絶対値の最大値と最小値の比)
\(\epsilon\) を出力状態の \(| A^{-1}\bf{b} \rangle\) からの誤差
とすると、現在最も効率的な HHL アルゴリズムでの計算量は、\(O(s \kappa \, \textrm{poly} (\log (s\kappa/\epsilon)) )\) である事が知られている[3]。 \(s=O(\textrm{poly}(\log N))\) と仮定していたので、仮に \(N\) だけに着目すると全体も \(O(\textrm{poly}(\log N))\) となる。 一方、古典アルゴリズムのベストの共役勾配法では計算量は \(O(Ns\kappa \log(1/\epsilon))\) なので[2]、HHL アルゴリズムは行列の次元 \(N\) について指数加速を実現している。
しかし、冒頭でも述べたように、この計算量は入力状態 \(|\mathbf{b}\rangle\) が用意できたと仮定した上での結果であり、出力 \(|A^{-1} \mathbf{b} \rangle\) をどう読み出せば良いかも考慮していない。 この入出力のオーバーヘッドに \(O(N)\) の時間がかかってしまった場合、上記の指数加速が相殺されてしまうので、HHLアルゴリズムは例えば \(\mathbf{x}\) のサンプリングを行うだけで実用上役に立つ、といった状況で用いるべきだと考えられる。
参考文献¶
コラム:量子ランダムアクセスメモリー (qRAM)¶
前項 Harrow-Hassidim-Lloyd (HHL) アルゴリズム の説明でも述べたように、量子コンピュータ上で古典データを扱う際は、そのデータを量子状態としてエンコードする必要がある。とくにバイナリデータの集まり(ベクトル)を量子状態として効率的に読み出すことは、量子機械学習などの応用において極めて重要である。本コラムでは、そうしたQuantum Random Access Memory (qRAM)
について簡単に解説する。
RAM¶
古典コンピュータにおける random access memory (RAM)
とは、メモリアドレスと対応するデータをセットとして格納し、引き出せるようにする装置である。すなわち、RAMにメモリアドレス \(i\) を与えると、バイナリデータ\(x_i\) を引き出すことができる。
同様に、qRAM はあるバイナリデータ \(x_i\) のアドレス \(i\) を記述する量子ビット列 \(|i \rangle\) から、対応するデータを何らかの規則でエンコードした量子状態 \(|x_i \rangle\) を引き出せるようにする装置である。
とくに、メモリアドレスとデータを重ね合わせ状態として引き出せるということは、qRAMのもつべき重要な性質である。すなわちメモリアドレス状態の重ね合わせに対して、qRAMはアドレスとデータがエンタングルした次の状態を与える。
\(N\)はデータの件数である。
ここでの定義では、qRAMは必ずしも量子状態を常に保持する必要がないということに注意されたい。バイナリデータのアドレス達が与えられた時、効率的に上記のような重ね合わせ状態を生成する量子回路が計算され、実際に状態が出力されれば、qRAMとしての役割を果たす。
通常 qRAM はユニタリなプロセスとして実現することが仮定される。 qRAM の仕組みを実現するアーキテクチャにはとくに決まったものはなく、現在も研究途上である。 例えば、[1] などで具体的な実装方法が提案されている。
振幅エンコーディング¶
前項で学んだように、HHLアルゴリズムおよびそれをベースとする機械学習アルゴリズムでは、qRAM上のデータを状態としてではなく、振幅として利用したい場合がある。そのためには、qRAMから読み出したデータに対して次のような変換を行いたい。
この変換をユニタリ変換として効率よく実現する方法はPrakashの博士論文において提案された[2]。具体的な実現方法の解説は[3,4]に詳しい。
参考文献¶
7-3. HHLアルゴリズムを用いたポートフォリオ最適化¶
この節では論文[1]を参考に、過去の株価変動のデータから、最適なポートフォリオ(資産配分)を計算してみよう。 ポートフォリオ最適化は、7-1節で学んだHHLアルゴリズムを用いることで、従来より高速に解けることが期待されている問題の一つである。 今回は具体的に、GAFA (Google, Apple, Facebook, Amazon) の4社の株式に投資する際、どのような資産配分を行えば最も低いリスクで高いリターンを得られるかという問題を考える。
株価データ取得¶
まずは各社の株価データを取得する。
GAFA 4社の日次データを用いる
株価データ取得のためにpandas_datareaderを用いてYahoo! Financeのデータベースから取得
株価はドル建ての調整後終値(Adj. Close)を用いる
[1]:
# データ取得に必要なpandas, pandas_datareaderのインストール
# !pip install pandas pandas_datareader
[2]:
import numpy as np
import pandas as pd
import pandas_datareader.data as web
import datetime
import matplotlib.pyplot as plt
[3]:
# 銘柄選択
codes = ['GOOG', 'AAPL', 'FB', 'AMZN'] # GAFA
# 2017年の1年間のデータを使用
start = datetime.datetime(2017, 1, 1)
end = datetime.datetime(2017, 12, 31)
# Yahoo! Financeから日次の株価データを取得
data = web.DataReader(codes, 'yahoo', start, end)
df = data['Adj Close']
## 直近のデータの表示
display(df.tail())
Symbols | GOOG | AAPL | FB | AMZN |
---|---|---|---|---|
Date | ||||
2017-12-22 | 1060.119995 | 169.869110 | 177.199997 | 1168.359985 |
2017-12-26 | 1056.739990 | 165.559555 | 175.990005 | 1176.760010 |
2017-12-27 | 1049.369995 | 165.588669 | 177.619995 | 1182.260010 |
2017-12-28 | 1048.140015 | 166.054581 | 177.919998 | 1186.099976 |
2017-12-29 | 1046.400024 | 164.258896 | 176.460007 | 1169.469971 |
[4]:
## 株価をプロットしてみる
fig, axes = plt.subplots(nrows=1, ncols=2, figsize=(15, 6))
df.loc[:,['AAPL', 'FB']].plot(ax=axes[0])
df.loc[:,['GOOG', 'AMZN']].plot(ax=axes[1])
[4]:
<matplotlib.axes._subplots.AxesSubplot at 0x114957850>

※ここで、4つの銘柄を2つのグループに分けているのは、株価の値がそれぞれ近くプロット時に見やすいからであり、深い意味はない。
データの前処理¶
次に、取得した株価を日次リターンに変換し、いくつかの統計量を求めておく。
日次リターンへの変換¶
個別銘柄の日次リターン(変化率) \(y_t\) (\(t\)は日付)は以下で定義される。
これは pandas DataFrame
の pct_change()
メソッドで得られる。
[5]:
daily_return = df.pct_change()
display(daily_return.tail())
Symbols | GOOG | AAPL | FB | AMZN |
---|---|---|---|---|
Date | ||||
2017-12-22 | -0.003300 | 0.000000 | -0.001409 | -0.005448 |
2017-12-26 | -0.003188 | -0.025370 | -0.006828 | 0.007190 |
2017-12-27 | -0.006974 | 0.000176 | 0.009262 | 0.004674 |
2017-12-28 | -0.001172 | 0.002814 | 0.001689 | 0.003248 |
2017-12-29 | -0.001660 | -0.010814 | -0.008206 | -0.014021 |
期待リターン¶
銘柄ごとの期待リターン\(\vec R\)を求める。ここでは過去のリターンの算術平均を用いる:
[6]:
expected_return = daily_return.dropna(how='all').mean() * 252 # 年率換算のため年間の営業日数252を掛ける
print(expected_return)
Symbols
GOOG 0.300215
AAPL 0.411192
FB 0.430156
AMZN 0.464567
dtype: float64
分散・共分散行列¶
リターンの標本不偏分散・共分散行列\(\Sigma\)は以下で定義される。
[7]:
cov = daily_return.dropna(how='all').cov() * 252 # 年率換算のため
display(cov)
Symbols | GOOG | AAPL | FB | AMZN |
---|---|---|---|---|
Symbols | ||||
GOOG | 0.023690 | 0.013303 | 0.018382 | 0.021614 |
AAPL | 0.013303 | 0.031119 | 0.016291 | 0.018877 |
FB | 0.018382 | 0.016291 | 0.028855 | 0.023337 |
AMZN | 0.021614 | 0.018877 | 0.023337 | 0.044120 |
ポートフォリオ最適化¶
準備が整ったところで、ポートフォリオ最適化に取り組もう。
まず、ポートフォリオ(i.e., 資産配分)を4成分のベクトル \(\vec{w} = (w_0,w_1,w_2,w_3)^T\) で表す。 これは各銘柄を持つ割合(ウェイト)を表しており、例えば \(\vec{w}=(1,0,0,0)\) であれば Google 株に全資産の100%を投入しするポートフォリオを意味する。
以下の式を満たすようなポートフォリオを考えてみよう。
この式は
「ポートフォリオの期待リターン(リターンの平均値)が\(\mu\) 」
「ポートフォリオに投資するウェイトの合計が1」(\(\vec 1 = (1,1,1,1)^T\))
という条件の下で、
「ポートフォリオのリターンの分散の最小化」
を行うことを意味している。つまり、将来的に \(\mu\) だけのリターンを望む時に、なるべくその変動(リスク)を小さくするようなポートフォリオが最善だというわけである。このような問題設定は、Markowitzの平均分散アプローチと呼ばれ、現代の金融工学の基礎となる考えの一つである。
ラグランジュの未定乗数法を用いると、上記の条件を満たす\(\vec{w}\)は、線形方程式
を解くことで得られる事がわかる。 ここで \(\eta, \theta\) はラグランジュの未定乗数法のパラメータである。 したがって、最適なポートフォリオ \(\vec w\) を求めるためには、連立方程式(1)を \(\vec w\) について解けば良いことになる。 これで、ポートフォリオ最適化問題をHHLアルゴリズムが使える線形一次方程式に帰着できた。
行列Wの作成¶
[8]:
R = expected_return.values
Pi = np.ones(4)
S = cov.values
row1 = np.append(np.zeros(2), R).reshape(1,-1)
row2 = np.append(np.zeros(2), Pi).reshape(1,-1)
row3 = np.concatenate([R.reshape(-1,1), Pi.reshape(-1,1), S], axis=1)
W = np.concatenate([row1, row2, row3])
np.set_printoptions(linewidth=200)
print(W)
[[0. 0. 0.30021458 0.41119151 0.43015563 0.46456748]
[0. 0. 1. 1. 1. 1. ]
[0.30021458 1. 0.02369003 0.01330333 0.01838175 0.0216144 ]
[0.41119151 1. 0.01330333 0.03111917 0.01629131 0.01887668]
[0.43015563 1. 0.01838175 0.01629131 0.02885482 0.02333747]
[0.46456748 1. 0.0216144 0.01887668 0.02333747 0.04412049]]
[9]:
## Wの固有値を確認 -> [-pi, pi] に収まっている
print(np.linalg.eigh(W)[0])
[-2.11207187 -0.10947986 0.01121933 0.01864265 0.11919724 2.20027702]
右辺ベクトルの作成¶
以下でポートフォリオの期待リターン \(\mu\) を指定すると、そのようなリターンをもたらす最もリスクの小さいポートフォリオを計算できる。\(\mu\) は自由に設定できる。一般に期待リターンが大きいほどリスクも大きくなるが、ここでは例として10%としておく(GAFA株がガンガン上がっている時期なので、これはかなり弱気な方である)。
[10]:
mu = 0.1 # ポートフォリオのリターン(手で入れるパラメータ)
xi = 1.0
mu_xi_0 = np.append(np.array([mu, xi]), np.zeros_like(R)) ## (1)式の右辺のベクトル
print(mu_xi_0)
[0.1 1. 0. 0. 0. 0. ]
量子系で扱えるように行列を拡張する¶
\(W\) は6次元なので、3量子ビットあれば量子系で計算可能である (\(2^3 = 8\))。 そこで、拡張した2次元分を0で埋めた行列とベクトルも作っておく。
[11]:
nbit = 3 ## 状態に使うビット数
N = 2**nbit
W_enl = np.zeros((N, N)) ## enl は enlarged の略
W_enl[:W.shape[0], :W.shape[1]] = W.copy()
mu_xi_0_enl = np.zeros(N)
mu_xi_0_enl[:len(mu_xi_0)] = mu_xi_0.copy()
以上で、連立方程式(1)を解く準備が整った。
HHLアルゴリズムを用いた最小分散ポートフォリオ算出¶
それでは、HHL アルゴリズムを用いて、連立一次方程式(1)を解いていこう。 先ずはその下準備として、
古典データ \(\mathbf{x}\) に応じて、量子状態を \(|0\cdots0\rangle \to \sum_i x_i |i \rangle\) と変換する量子回路を返す関数
input_state_gate
(本来は qRAM の考え方を利用して作るべきだが、シミュレータを使っているので今回は non-unitary なゲートとして実装してしまう。また、規格化は無視している)制御位相ゲートを返す関数
CPhaseGate
量子フーリエ変換を行うゲートを返す関数
QFT_gate
を用意する。
[12]:
# Qulacs のインストール
# !pip install qulacs
[13]:
import numpy as np
from qulacs import QuantumCircuit, QuantumState, gate
from qulacs.gate import merge, Identity, H, SWAP
def input_state_gate(start_bit, end_bit, vec):
"""
Making a quantum gate which transform |0> to \sum_i x[i]|i>m where x[i] is input vector.
!!! this uses 2**n times 2**n matrix, so it is quite memory-cosuming.
!!! this gate is not unitary (we assume that the input state is |0>)
Args:
int start_bit: first index of qubit which the gate applies
int end_bit: last index of qubit which the gate applies
np.ndarray vec: input vector.
Returns:
qulacs.QuantumGate
"""
nbit = end_bit - start_bit + 1
assert vec.size == 2**nbit
mat_0tox = np.eye(vec.size, dtype=complex)
mat_0tox[:,0] = vec
return gate.DenseMatrix(np.arange(start_bit, end_bit+1), mat_0tox)
def CPhaseGate(target, control, angle):
"""
Create controlled phase gate diag(1,e^{i*angle}) with controll. (Qulacs.gate is requried)
Args:
int target: index of target qubit.
int control: index of control qubit.
float64 angle: angle of phase gate.
Returns:
QuantumGateBase.DenseMatrix: diag(1, exp(i*angle)).
"""
CPhaseGate = gate.DenseMatrix(target, np.array( [[1,0], [0,np.cos(angle)+1.j*np.sin(angle)]]) )
CPhaseGate.add_control_qubit(control, 1)
return CPhaseGate
def QFT_gate(start_bit, end_bit, Inverse = False):
"""
Making a gate which performs quantum Fourier transfromation between start_bit to end_bit.
(Definition below is the case when start_bit = 0 and end_bit=n-1)
We associate an integer j = j_{n-1}...j_0 to quantum state |j_{n-1}...j_0>.
We define QFT as
|k> = |k_{n-1}...k_0> = 1/sqrt(2^n) sum_{j=0}^{2^n-1} exp(2pi*i*(k/2^n)*j) |j>.
then, |k_m > = 1/sqrt(2)*(|0> + exp(i*2pi*0.j_{n-1-m}...j_0)|1> )
When Inverse=True, the gate represents Inverse QFT,
|k> = |k_{n-1}...k_0> = 1/sqrt(2^n) sum_{j=0}^{2^n-1} exp(-2pi*i*(k/2^n)*j) |j>.
Args:
int start_bit: first index of qubits where we apply QFT.
int end_bit: last index of qubits where we apply QFT.
bool Inverse: When True, the gate perform inverse-QFT ( = QFT^{\dagger}).
Returns:
qulacs.QuantumGate: QFT gate which acts on a region between start_bit and end_bit.
"""
gate = Identity(start_bit) ## make empty gate
n = end_bit - start_bit + 1 ## size of QFT
## loop from j_{n-1}
for target in range(end_bit, start_bit-1, -1):
gate = merge(gate, H(target)) ## 1/sqrt(2)(|0> + exp(i*2pi*0.j_{target})|1>)
for control in range(start_bit, target):
gate = merge( gate, CPhaseGate(target, control, (-1)**Inverse * 2.*np.pi/2**(target-control+1)) )
## perform SWAP between (start_bit + s)-th bit and (end_bit - s)-th bit
for s in range(n//2): ## s runs 0 to n//2-1
gate = merge(gate, SWAP(start_bit + s, end_bit - s))
## return final circuit
return gate
まずはHHLアルゴリズムに必要なパラメータを設定する。 クロックレジスタ量子ビット数 reg_nbit
を 7
とし、行列 \(W\) のスケーリングに使う係数 scale_fac
を1
とする(つまり、スケールさせない)。 また、制御回転ゲートに使う係数 \(c\) は、reg_nbit
ビットで表せる非ゼロの最も小さい数の半分にとっておく。
[14]:
# 位相推定に使うレジスタの数
reg_nbit = 7
## W_enl をスケールする係数
scale_fac = 1.
W_enl_scaled = scale_fac * W_enl
## W_enl_scaledの固有値として想定する最小の値
## 今回は射影が100%成功するので, レジスタで表せる最小値の定数倍でとっておく
C = 0.5*(2 * np.pi * (1. / 2**(reg_nbit) ))
HHLアルゴリズムの核心部分を書いていく。今回は、シミュレータ qulacs を使うので様々な簡略化を行なっている。 HHLアルゴリズムがどのように動作するのかについての感覚を知る実装と思っていただきたい。
入力状態 \(|\mathbf{b}\rangle\) を用意する部分は簡略化
量子位相推定アルゴリズムで使う \(e^{iA}\) の部分は、 \(A\) を古典計算機で対角化したものを使う
逆数をとる制御回転ゲートも、古典的に行列を用意して実装
補助ビット \(|0 \rangle{}_{S}\) への射影測定を行い、測定結果
0
が得られた状態のみを扱う (実装の都合上、制御回転ゲートの作用の定義を7-1節と逆にした)
[15]:
from functools import reduce
## 対角化. AP = PD <-> A = P*D*P^dag
D, P = np.linalg.eigh(W_enl_scaled)
#####################################
### HHL量子回路を作る. 0番目のビットから順に、Aの作用する空間のbit達 (0番目 ~ nbit-1番目),
### register bit達 (nbit番目 ~ nbit+reg_nbit-1番目), conditional回転用のbit (nbit+reg_nbit番目)
### とする.
#####################################
total_qubits = nbit + reg_nbit + 1
total_circuit = QuantumCircuit(total_qubits)
## ------ 0番目~(nbit-1)番目のbitに入力するベクトルbの準備 ------
## 本来はqRAMのアルゴリズムを用いるべきだが, ここでは自作の入力ゲートを用いている.
## qulacsではstate.load(b_enl)でも実装可能.
state = QuantumState(total_qubits)
state.set_zero_state()
b_gate = input_state_gate(0, nbit-1, mu_xi_0_enl)
total_circuit.add_gate(b_gate)
## ------- レジスターbit に Hadamard gate をかける -------
for register in range(nbit, nbit+reg_nbit): ## from nbit to nbit+reg_nbit-1
total_circuit.add_H_gate(register)
## ------- 位相推定を実装 -------
## U := e^{i*A*t), その固有値をdiag( {e^{i*2pi*phi_k}}_{k=0, ..., N-1) )とおく.
## Implement \sum_j |j><j| exp(i*A*t*j) to register bits
for register in range(nbit, nbit+reg_nbit):
## U^{2^{register-nbit}} を実装.
## 対角化した結果を使ってしまう
U_mat = reduce(np.dot, [P, np.diag(np.exp( 1.j * D * (2**(register-nbit)) )), P.T.conj()] )
U_gate = gate.DenseMatrix(np.arange(nbit), U_mat)
U_gate.add_control_qubit(register, 1) ## control bitの追加
total_circuit.add_gate(U_gate)
## ------- Perform inverse QFT to register bits -------
total_circuit.add_gate(QFT_gate(nbit, nbit+reg_nbit-1, Inverse=True))
## ------- conditional rotation を掛ける -------
## レジスター |phi> に対応するA*tの固有値は l = 2pi * 0.phi = 2pi * (phi / 2**reg_nbit).
## conditional rotationの定義は (本文と逆)
## |phi>|0> -> C/(lambda)|phi>|0> + sqrt(1 - C^2/(lambda)^2)|phi>|1>.
## 古典シミュレーションなのでゲートをあらわに作ってしまう.
condrot_mat = np.zeros( (2**(reg_nbit+1), (2**(reg_nbit+1))), dtype=complex)
for index in range(2**reg_nbit):
lam = 2 * np.pi * (float(index) / 2**(reg_nbit) )
index_0 = index ## integer which represents |index>|0>
index_1 = index + 2**reg_nbit ## integer which represents |index>|1>
if lam >= C:
if lam >= np.pi: ## あらかじめ[-pi, pi]内に固有値をスケールしているので、[pi, 2pi] は 負の固有値に対応
lam = lam - 2*np.pi
condrot_mat[index_0, index_0] = C / lam
condrot_mat[index_1, index_0] = np.sqrt( 1 - C**2/lam**2 )
condrot_mat[index_0, index_1] = - np.sqrt( 1 - C**2/lam**2 )
condrot_mat[index_1, index_1] = C / lam
else:
condrot_mat[index_0, index_0] = 1.
condrot_mat[index_1, index_1] = 1.
## DenseGateに変換して実装
condrot_gate = gate.DenseMatrix(np.arange(nbit, nbit+reg_nbit+1), condrot_mat)
total_circuit.add_gate(condrot_gate)
## ------- Perform QFT to register bits -------
total_circuit.add_gate(QFT_gate(nbit, nbit+reg_nbit-1, Inverse=False))
## ------- 位相推定の逆を実装(U^\dagger = e^{-iAt}) -------
for register in range(nbit, nbit+reg_nbit): ## from nbit to nbit+reg_nbit-1
## {U^{\dagger}}^{2^{register-nbit}} を実装.
## 対角化した結果を使ってしまう
U_mat = reduce(np.dot, [P, np.diag(np.exp( -1.j* D * (2**(register-nbit)) )), P.T.conj()] )
U_gate = gate.DenseMatrix(np.arange(nbit), U_mat)
U_gate.add_control_qubit(register, 1) ## control bitの追加
total_circuit.add_gate(U_gate)
## ------- レジスターbit に Hadamard gate をかける -------
for register in range(nbit, nbit+reg_nbit):
total_circuit.add_H_gate(register)
## ------- 補助ビットを0に射影する. qulacsでは非ユニタリゲートとして実装されている -------
total_circuit.add_P0_gate(nbit+reg_nbit)
#####################################
### HHL量子回路を実行し, 結果を取り出す
#####################################
total_circuit.update_quantum_state(state)
## 0番目から(nbit-1)番目の bit が計算結果 |x>に対応
result = state.get_vector()[:2**nbit].real
x_HHL = result/C * scale_fac
HHL アルゴリズムによる解 x_HHL
と、通常の古典計算の対角化による解 x_exact
を比べると、概ね一致していることが分かる。(HHLアルゴリズムの精度を決めるパラメータはいくつかある(例えばreg_nbit
)ので、それらを変えて色々試してみて頂きたい。)
[16]:
## 厳密解
x_exact = np.linalg.lstsq(W_enl, mu_xi_0_enl, rcond=0)[0]
print("HHL: ", x_HHL)
print("exact:", x_exact)
rel_error = np.linalg.norm(x_HHL- x_exact) / np.linalg.norm(x_exact)
print("rel_error", rel_error)
HHL: [ 0.09580738 -0.04980738 2.36660125 0.09900883 -0.47774813 -0.98438791 0. 0. ]
exact: [ 0.15426894 -0.07338059 2.29996915 0.17711988 -0.66526695 -0.81182208 0. 0. ]
rel_error 0.11097291393510306
実際のウェイトの部分だけ取り出すと
[17]:
w_opt_HHL = x_HHL[2:6]
w_opt_exact = x_exact[2:6]
w_opt = pd.DataFrame(np.vstack([w_opt_exact, w_opt_HHL]).T, index=df.columns, columns=['exact', 'HHL'])
w_opt
[17]:
exact | HHL | |
---|---|---|
Symbols | ||
GOOG | 2.299969 | 2.366601 |
AAPL | 0.177120 | 0.099009 |
FB | -0.665267 | -0.477748 |
AMZN | -0.811822 | -0.984388 |
[18]:
w_opt.plot.bar()
[18]:
<matplotlib.axes._subplots.AxesSubplot at 0x1147add90>

※重みが負になっている銘柄は、「空売り」(株を借りてきて売ること。株価が下がる局面で利益が得られる手法)を表す。今回は目標リターンが10%と、GAFA株(単独で30〜40%の期待リターン)にしてはかなり小さい値を設定したため、空売りを行って全体の期待リターンを下げていると思われる。
Appendix: バックテスト¶
過去のデータから得られた投資ルールを、それ以降のデータを用いて検証することを「バックテスト」と呼び、その投資ルールの有効性を測るために重要である。 ここでは以上のように2017年のデータから構築したポートフォリオに投資した場合に、翌年の2018年にどの程度資産価値が変化するかを観察する。
[19]:
# 2018年の1年間のデータを使用
start = datetime.datetime(2017, 12, 30)
end = datetime.datetime(2018, 12, 31)
# Yahoo! Financeから日次の株価データを取得
data = web.DataReader(codes, 'yahoo', start, end)
df2018 = data['Adj Close']
display(df2018.tail())
Symbols | GOOG | AAPL | FB | AMZN |
---|---|---|---|---|
Date | ||||
2018-12-24 | 976.219971 | 144.656540 | 124.059998 | 1343.959961 |
2018-12-26 | 1039.459961 | 154.843475 | 134.179993 | 1470.900024 |
2018-12-27 | 1043.880005 | 153.838562 | 134.520004 | 1461.640015 |
2018-12-28 | 1037.079956 | 153.917389 | 133.199997 | 1478.020020 |
2018-12-31 | 1035.609985 | 155.405045 | 131.089996 | 1501.969971 |
[20]:
## 株価をプロットしてみる
fig, axes = plt.subplots(nrows=1, ncols=2, figsize=(15, 6))
df2018.loc[:,['AAPL', 'FB']].plot(ax=axes[0])
df2018.loc[:,['GOOG', 'AMZN']].plot(ax=axes[1])
[20]:
<matplotlib.axes._subplots.AxesSubplot at 0x1173c5cd0>

[21]:
# ポートフォリオの資産価値の推移
pf_value = df2018.dot(w_opt)
pf_value.head()
[21]:
exact | HHL | |
---|---|---|
Date | ||
2017-12-29 | 1368.986737 | 1257.159152 |
2018-01-02 | 1393.124262 | 1279.864457 |
2018-01-03 | 1418.828873 | 1304.724242 |
2018-01-04 | 1423.832841 | 1308.934869 |
2018-01-05 | 1443.032292 | 1326.138623 |
[22]:
# exact と HHLで初期金額が異なることがありうるので、期初の値で規格化したリターンをみる。
pf_value.exact = pf_value.exact / pf_value.exact[0]
pf_value.HHL = pf_value.HHL / pf_value.HHL[0]
print(pf_value.tail())
exact HHL
Date
2018-12-24 0.801548 0.749625
2018-12-26 0.828918 0.766234
2018-12-27 0.841539 0.781597
2018-12-28 0.821053 0.756478
2018-12-31 0.805599 0.735876
[23]:
pf_value.plot(figsize=(9, 6))
[23]:
<matplotlib.axes._subplots.AxesSubplot at 0x11475b8d0>

2018年はAmazon以外のGAFA各社の株式が軟調だったので、およそ-20%もの損が出ているが、exact解の方は多少マシであるようだ。。 ちなみに、元々行ったのはリスク最小化なので、この一年間のリスクも計算してみると、exact解の方が小さい結果となった。
[24]:
pf_value.pct_change().std() * np.sqrt(252) ## 年率換算
[24]:
exact 0.402005
HHL 0.501925
dtype: float64
参考文献¶
[1] P. Rebentrost and S. Lloyd, “Quantum computational finance: quantum algorithm for portfolio optimization“, https://arxiv.org/abs/1811.03975
ここで、量子線形システム問題にまつわる少し発展的な話題を紹介する。これらのコラムの内容は本編の理解に必須ではないので、難しくて分からない場合は適宜飛ばして次の章に進んでほしい。
コラム:低ランク行列に対する高速な特異値分解とサンプリング(量子-inspiredアルゴリズム)¶
[1]:
import sys
import time
from collections import Counter
import numpy as np
import sklearn
from sklearn.decomposition import TruncatedSVD
import matplotlib
import matplotlib.pyplot as plt
print("python: %s"%sys.version)
print("numpy: %s"%np.version.version)
print("matplotlib: %s"%matplotlib.__version__)
print("scikit-learn: %s"%sklearn.__version__)
python: 3.7.5 (v3.7.5:5c02a39a0b, Oct 14 2019, 18:49:57)
[Clang 6.0 (clang-600.0.57)]
numpy: 1.17.3
matplotlib: 3.1.1
scikit-learn: 0.21.3
概要¶
低ランクなハミルトニアン \(H\) によるダイナミクス \(e^{-iHt}\) は量子計算機で効率的にシミュレートすることができる。 ハミルトニアンの効率的なシミュレートは量子位相推定アルゴリズムやHHLアルゴリズムを行うための前提条件であるから、量子計算機は低ランクな行列に対して種々の高速なアルゴリズムを自然に実行できる。 量子推薦システム (Quantum recommendation system) はそのような応用の一つで、量子計算機は低ランクな行列の任意の行ベクトルに対し、Top-\(k\)特異値空間へ射影したベクトルからのL2サンプリングを行えるというものである。このサンプリング操作は、例えばWebサービスにおける購買履歴に基づくユーザへのアイテム推薦の操作に対応しており、、量子計算機は高速な推薦システムとして活用することができる。 (注:ユーザへの推薦は、行がユーザ・列がアイテム・要素が指定ユーザが指定アイテムを好きかどうか、という嗜好行列(通常は低ランクだと仮定される)に基づいて行われる)
しかし、一般に問題に低ランク近似可能性などの強い近似が加わった場合、通常の古典計算機での計算も高速になる点に注意しなければならない。近年、上記の量子推薦システムが行う操作は、種々のデータ構造やアルゴリズムを用いることで通常の計算機でも多項式時間で実現可能だということがわかった。 このコラムでは、低ランクな行列に対する打ち切り特異値分解、および特異値空間に射影されたベクトルからのサンプリングが、通常の計算機だけでも行列のサイズに対して従来より指数的に高速になるという点について解説する。
この一連の研究は主にEwin Tangによって行われたものである[1][2][3]。 この研究は以下の個別の主要要素をつなげたものである。
セグメント木
確率的操作を用いた打ち切り特異値分解
棄却サンプリング
内積の推定
まず、問題設定を提示し、そののち上記の三つについてそれぞれ解説し、これを組みあわせて低ランク近似した特徴量からのサンプリングが可能であることをコードを実行しながら概観する。
問題設定¶
低ランク近似からの特異値空間の射影サンプリングは以下のような問題として定義される。
初期状態として、\(2^n, 2^m\)の大きさの行列\(A\)を考える。行列\(A\)は全ての要素が0である。 この時、以下の二つの操作がランダムな順序で\(q\)個要求される。
- 値の更新\(A\)の\(i\)行\(j\)列の要素を\(v\)に更新する。
- 射影サンプリング\(A\)の\(i\)行目の成分を\(A\)のTop-\(k\)特異値空間に射影したベクトル\(v\)を得る。 ベクトル\(v\)の\(j\)番目の要素を\(\frac{v_j^2}{\sum_l v_l^2}\)の確率で出力する。 ただし、\(k\)は高々\(O(n,m)\)である。
上記に\(\epsilon\)の誤差を許容した時、高速に処理を行いたい。 自明な上記のアルゴリズムは\(2^n, 2^m\)の配列をスパース形式で確保し、挿入を\(O(n,m)\)で行い、射影サンプリングは特異値分解を通して\({\rm poly}(2^n,2^m)\)で行う方法である。 これを改善し、\({\rm poly}(q,n,m,\epsilon^{-1})\)の計算量で行いたい。
量子計算機を用いると、上記の計算を\({\rm poly}(q,n,m,\epsilon^{-1})\)で行うことができる。 これは、HHLアルゴリズムの一部を変更し、Quantum projectionというプロトコルを実施することで可能となる。 近年のEwin Tangによる論文は、この計算が古典計算機でも同じく\({\rm poly}(q,n,m,\epsilon^{-1})\)で可能であるというものである。
和のセグメント木¶
通常、我々がプログラムで配列を確保すると、メモリ上の連続的なアドレスに指定した容量の数列が確保される。 ランダムアクセスメモリでは読み出しと書き込みはO(1)で完了できるため、通常はこの方法で確保される。 一方、配列に対する要求が読み出しと書き込みだけではなく挿入や削除、サンプリングなどがある場合、 こうしたデータの保持の仕方は必ずしも高速とは限らない。 従って、データに対する要求が単なる読み出しと書き込みに限らない場合、その目的に適した特殊なデータの保持方法が用いられる。これをデータ構造と呼ぶ。 和に関するセグメント木はデータ構造の一種であり、データの更新、区間和の計算やサンプリングを高速に行うことができる。
対象となるデータの大きさを\(N=2^n\)とする。2の累乗でない場合は、累乗になるまで末尾を0で埋める。、 まず、\(2N\)の長さの配列を確保する。最初は\(2N\)個の要素は0で埋まっている。 和のセグメント木では以下の4つの操作を行える。
値の更新: \(i\)番目のデータに値\(v\)を書き込む。
値の取得: \(i\)番目のデータを取り出す。
総和の取得: 全データの和を取り出す。
サンプリング: \(i\)番目のデータを\(\frac{a_i}{\sum_j a_j}\)の確率で取り出す。
上記を通常の配列で愚直に行うと以下のようなコードになる。
[2]:
class VectorSampler():
def __init__(self,size):
self.size = size
self.array = np.zeros(size)
self.sum = 0
self.index_list = np.arange(self.size)
def update(self,index,value):
self.sum += value - self.array[index]
self.array[index] = value
def get_element(self,index):
return self.array[index]
def get_sum(self):
return self.sum
def sampling(self):
normalized_array = self.array / self.sum
result = np.random.choice(self.index_list, p = normalized_array)
return result
ランダムな読み出し/書き込み等を行って、上記の関数をベンチマークしてみよう。
[3]:
n_list_vec = [10**2,10**3,10**4,10**5,10**6,10**7,10**8]
update_time_vec = []
get_element_time_vec = []
get_sum_time_vec = []
sampling_time_vec = []
for n in n_list_vec:
data_structure = VectorSampler(n)
start_time = time.time()
rep = 0
while time.time()-start_time<0.1:
index = np.random.randint(n)
value = np.random.rand()
data_structure.update(index,value)
rep+=1
elapsed = (time.time()-start_time)/rep
update_time_vec.append(elapsed)
start_time = time.time()
rep = 0
while time.time()-start_time<0.1:
index = np.random.randint(n)
data_structure.get_element(index)
rep+=1
elapsed = (time.time()-start_time)/rep
get_element_time_vec.append(elapsed)
start_time = time.time()
rep = 0
while time.time()-start_time<0.1:
data_structure.get_sum()
rep+=1
elapsed = (time.time()-start_time)/rep
get_sum_time_vec.append(elapsed)
start_time = time.time()
rep = 0
while time.time()-start_time<0.1:
data_structure.sampling()
rep+=1
elapsed = (time.time()-start_time)/rep
sampling_time_vec.append(elapsed)
plt.plot(n_list_vec, update_time_vec,label="update")
plt.plot(n_list_vec, get_element_time_vec,label="get_element")
plt.plot(n_list_vec, get_sum_time_vec,label="get_sum")
plt.plot(n_list_vec, sampling_time_vec,label="sampling")
plt.xlabel("size")
plt.ylabel("time (sec)")
plt.xscale("log")
plt.yscale("log")
plt.legend()
plt.show()

他の関数の時間が定数でスケールする中、samplingだけが線形に時間がかかっていることがわかる。
セグメント木では4つの命令を以下のように実装する。(図でイメージするとわかりやすい;例えばこのブログを参照)
- \(i\)番目の値の更新\(a_{2^n + i}=v\)と更新した上で(\(N=2^n\)で配列の長さは\(2N\))、さらに全ての\(j=1,2,\ldots\)について \(k = \lfloor \frac{2^n + i}{2^j} \rfloor, \: a_k \leftarrow a_k-a_i+v\) という更新を行う。 この処理は\(O(n)\)で終わる。
- \(i\)番目の値の取得\(a_{2^n + i}\)を返す。 この処理は\(O(1)\)で終わる。
- 総和の取得\(a_0\)を返す。 この処理は\(O(1)\)で終わる。
- サンプリング\(i=1\)とする。\(b=0\)が\(\frac{a_i}{a_i + a_{i+1}}\)、\(b=1\)が\(\frac{a_{i+1}}{a_i+a_{i+1}}\)の確率で得られるサンプリングを行う。 \(b=0\)となった場合、\(i \leftarrow i+2^k\)と更新する。 \(b=1\)となった場合、\(i \leftarrow i+2^{k+1}\)と更新する。 \(k \leftarrow k+1\)とする。 \(i\geq 2^n\)となったら終了し、\(i-2^n\)を返す。 この処理は\(O(n)\)で終わる。
従って、長さ\(n\)のベクトルに関し、上記四種の命令が\(q\)個与えられる場合、その実施を高々\(O(q \log N)\)で行うことができる。
実装は以下のようになる。
[4]:
class SegmentTree(object):
def __init__(self, size):
_size = 1
while _size<size:
_size*=2
self.size = _size*2
self.array = np.zeros(_size*2)
def update(self,index, value):
position = self.size//2 + index
difference = value - self.array[position]
while position>0:
self.array[position]+=difference
position //= 2
def get_element(self,index):
return self.array[self.size//2 + index]
def get_sum(self):
return self.array[1]
def sampling(self):
current_node = 1
while current_node<self.size//2:
left_children = self.array[current_node*2]
right_children = self.array[current_node*2+1]
left_weight = left_children / (left_children + right_children)
if np.random.rand() < left_weight:
current_node = current_node*2
else:
current_node = current_node*2+1
return current_node - self.size//2
セグメント木をベンチマークしてみよう。
[5]:
n_list_seg = [10**2,10**3,10**4,10**5,10**6,10**7,10**8]
update_time_seg = []
get_element_time_seg = []
get_sum_time_seg = []
sampling_time_seg = []
for n in n_list_seg:
data_structure = SegmentTree(n)
start_time = time.time()
rep = 0
while time.time()-start_time<0.1:
index = np.random.randint(n)
value = np.random.rand()
data_structure.update(index,value)
rep+=1
elapsed = (time.time()-start_time)/rep
update_time_seg.append(elapsed)
start_time = time.time()
rep = 0
while time.time()-start_time<0.1:
index = np.random.randint(n)
data_structure.get_element(index)
rep+=1
elapsed = (time.time()-start_time)/rep
get_element_time_seg.append(elapsed)
start_time = time.time()
rep = 0
while time.time()-start_time<0.1:
data_structure.get_sum()
rep+=1
elapsed = (time.time()-start_time)/rep
get_sum_time_seg.append(elapsed)
start_time = time.time()
rep = 0
while time.time()-start_time<0.1:
data_structure.sampling()
rep+=1
elapsed = (time.time()-start_time)/rep
sampling_time_seg.append(elapsed)
plt.plot(n_list_seg, update_time_seg,label="update")
plt.plot(n_list_seg, get_element_time_seg,label="get_element")
plt.plot(n_list_seg, get_sum_time_seg,label="get_sum")
plt.plot(n_list_seg, sampling_time_seg,label="sampling")
plt.xlabel("size")
plt.ylabel("time (sec)")
plt.xscale("log")
plt.yscale("log")
plt.legend()
plt.show()

先ほどの素朴なデータ構造の時間と比較してみよう。
[6]:
cmap = plt.get_cmap("tab10")
plt.plot(n_list_vec, update_time_vec,label="vector update",color=cmap(0),linestyle="--")
plt.plot(n_list_seg, update_time_seg,label="segtree update",color=cmap(0))
plt.plot(n_list_vec, get_element_time_vec,label="vector get_element",color=cmap(1),linestyle="--")
plt.plot(n_list_seg, get_element_time_seg,label="segtree get_element",color=cmap(1))
plt.plot(n_list_vec, get_sum_time_vec,label="vector get_sum",color=cmap(2),linestyle="--")
plt.plot(n_list_seg, get_sum_time_seg,label="segtree get_sum",color=cmap(2))
plt.plot(n_list_vec, sampling_time_vec,label="vector sampling",color=cmap(3),linestyle="--")
plt.plot(n_list_seg, sampling_time_seg,label="segtree sampling",color=cmap(3))
plt.xlabel("size")
plt.ylabel("time (sec)")
plt.xscale("log")
plt.yscale("log")
plt.legend()
plt.show()

updateの関数はsegtreeの方が多少遅いものの、samplingのコストが圧倒的に減っていることがわかる。
なお、本稿では登場しないが、和のセグメント木は与えられた区間に関する和を求める操作も\(O(\log N)\)で行うことができる。これは通常のベクトルでは最悪で\(O(N)\)かかる。 また、セグメント木は二つの要素間の演算がモノイド、すなわちゼロ元を持ち結合則が成り立てば様々な対象に対し適用することができる。例えば\(f(a,b)=\min(a,b)\)とすることで区間最小値を求めるセグメント木を構築できる。
セグメント木はベクトルに対するデータ構造だが、多少の拡張をすることで行列をセグメント木の集合として表すことができる。下記のような\(m \times n\)行列を考えよう。
\(i\)行目の長さ\(n\)のベクトル\(T_i\)をセグメント木\(S_i\)として保持する。
\(i\)行目のベクトルの総和を\(|T_i|\)を並べた長さ\(m\)のベクトル\(C = \{|T_i|\}\)をセグメント木\(S'\)として保持する。
上記のようなデータ構造は、以下の操作を全て\({\rm poly}(\log n, \log m)\)で行う。
値の更新: \((i,j)\)要素を\(v\)に更新する。
値の取得: \((i,j)\)要素を取得する。
行ノルムの取得: 与えられた行添え字\(i\)に対し、\(i\)行目のノルム\(|T_i|\)を取得する。
行ベクトルのサンプリング: 与えられた行添え字\(i\)に対し、列添え字\(j\)を\(\frac{T_{ij}}{\sum_j{ T_{ij}}}\)の確率で得る。
総和の取得: 行列の全要素の和\(\sum_{ij} T_{ij}\)を取得する。
行ノルムのサンプリング: 行添え字\(i\)を\(\frac{|T_i|}{\sum_i |T_i|}\)の確率で得る。
実装は以下のようになる。
[7]:
class SegmentTreeMatrix(object):
def __init__(self, height, width):
_height = 1
while _height<height:
_height*=2
_width = 1
while _width<width:
_width*=2
self.width = _width
self.height = _height
self.column_norm_segtree = SegmentTree(self.height)
self.row_segtree_list = []
for row_index in range(self.height):
self.row_segtree_list.append(SegmentTree(self.width))
def update(self,i,j, value):
self.row_segtree_list[i].update(j,value)
self.column_norm_segtree.update(i, self.row_segtree_list[i].get_sum())
def get_element(self,i,j):
return self.row_segtree_list[i].get_element(j)
def get_row_norm(self,i):
return self.row_segtree_list[i].get_sum()
def sampling_column_in_row(self,i):
return self.row_segtree_list[i].sampling()
def get_sum(self):
return self.column_norm_segtree.get_sum()
def sampling_row(self):
return self.column_norm_segtree.sampling()
確率的な打ち切り特異値分解¶
特異値分解とは、正方行列とは限らない行列\(A\)について、\(A=UDV^{\dagger}\)という分解を行う行列操作である。 この時、\(U,V\) はユニタリ行列であり、\(D\)は実対角行列である。また、\(\dagger\)はエルミート共役(転置の複素共役)を表す。 \(D\)の対角要素を\(A\)の特異値と呼ぶ。行列\(A\)の幅と高さの小さいほうの値が\(n\)である場合、特異値は\(n\)個あり、分解の計算量は\(O(n^3)\)である。\(U\)と\(V\)の\(i\)番目の列ベクトルを、\(A\)の\(i\)番目の特異値の左特異値ベクトル、右特異値ベクトルと呼ぶ。
特異値分解はnumpyに標準で搭載されている。
[8]:
n = 3
m = 4
A = np.random.rand(n,m)
U,singular_value_list,Vd = np.linalg.svd(A)
D = np.zeros( (n,m) )
np.fill_diagonal(D,singular_value_list)
print("A=\n{}\n".format(A))
print("U=\n{}\n".format(U))
print("D=\n{}\n".format(D))
print("V^d=\n{}\n".format(Vd))
print("UDV^d=\n{}\n".format(U@D@Vd))
A=
[[0.28518719 0.33357639 0.22698753 0.89912291]
[0.23855553 0.95448547 0.54962802 0.81592368]
[0.98739535 0.84929664 0.7374283 0.98272594]]
U=
[[-0.3954486 0.23336956 -0.88834625]
[-0.55539766 0.70956965 0.43364081]
[-0.7315421 -0.66486807 0.15098541]]
D=
[[2.40625414 0. 0. 0. ]
[0. 0.48352874 0. 0. ]
[0. 0. 0.41263792 0. ]]
V^d=
[[-0.40211518 -0.53333002 -0.38835626 -0.6348558 ]
[-0.86998319 0.39387582 -0.09786635 0.28002369]
[-0.00197572 0.59568912 0.35876134 -0.71863821]
[-0.28535719 -0.45340424 0.84314405 0.04586912]]
UDV^d=
[[0.28518719 0.33357639 0.22698753 0.89912291]
[0.23855553 0.95448547 0.54962802 0.81592368]
[0.98739535 0.84929664 0.7374283 0.98272594]]
計算コストは以下のようになる。
[9]:
n_list = np.arange(100,1500,100)
elapsed_times_svd = []
for n in n_list:
matrix = np.random.rand(n,n)
start_time = time.time()
s,d,v = np.linalg.svd(matrix)
elapsed = time.time()-start_time
elapsed_times_svd.append(elapsed)
plt.plot(n_list,elapsed_times_svd)
plt.xlabel("size")
plt.ylabel("time (sec)")
plt.show()

打ち切り特異値分解とは、定数\(k\)について、特異値の集合のうち大きいものから\(k\)個の特異値と特異値ベクトルを取り出す操作である。 定数\(k=n\)である場合は通常の特異値分解と一致するため、通常は\(k\)が\(n\)よりはるかに小さい場合をしばしば考える。
最も素朴な打ち切り特異値分解は、下記の\(O(n^2 k)\)のアルゴリズムである。
各要素が \(N(0,1)\) の独立な正規分布に従う\(n \times k\)の行列\(B\)を作成する。計算コストは\(O(nk)\)。
\(C = AB\)を計算する。計算コストは\(O(n^2 k)\)。
\(C\)の行ベクトルを直交化し、行列\(D\)を得る。計算コストは\(O(n^2 k)\)。
\(E=DA\)を計算する。計算コストは\(O(n^2 k)\)。
\(E\)を特異値分解し、\(E=U'DV^{\dagger}\)を得る。計算コストは\(O(n^2 k)\)。
\(U = D^{\rm T}U'\)を計算する。計算コストは\(O(n^2 k)\)。
結果として得られるU,D,Vは高い確率で\(A\)の特異値分解と一致する。 こうした特異値分解はscikit-learnに実装されている。
[10]:
k = 10
n_list_tsvd = np.arange(100,5000,100)
elapsed_times_tsvd = []
for n in n_list_tsvd:
matrix = np.random.rand(n,n)
tsvd = TruncatedSVD(n_components=k)
start_time = time.time()
tsvd.fit(matrix)
elapsed = time.time()-start_time
elapsed_times_tsvd.append(elapsed)
plt.plot(n_list,elapsed_times_svd,label="SVD")
plt.plot(n_list_tsvd,elapsed_times_tsvd,label="truncated SVD")
plt.legend()
plt.xlabel("size")
plt.ylabel("time (sec)")
plt.show()

全ての特異値を得るFull SVDに比べ、圧倒的に高速であることがわかる。
さらに、FKVアルゴリズムというものが存在し、いくつかの条件が満たされると、Truncated SVDからさらなる高速化が可能になる。手順は以下の通りである。
行列\(A\)から行ノルムを重みとして\(p\)個の行をサンプリングした\(B\)を作成する。行添え字\(i\)を行ノルムの重みづけで得るサンプリングのコストを\(S_1(A)\)として、計算コストは\(O(S_1(A) p)\)。
行列\(B\)から「ランダムに行を選び、選ばれた行から要素を重みとして列をサンプリングする」操作を\(p\)回行い\(C\)を作成する。行ベクトルからの要素のサンプリングコストを\(S_2(A)\)として、計算コストは\(O(S_2(A)p)\)。
\(C\)を特異値分解して\(C=U'DV'^{\dagger}\)を得る。計算コストは\(O(p^3)\)。
\(V = B^T U' D^+\)を計算する。計算コストは\(O(np^2)\)。
\(V = V^{\dagger}D^+A\)を計算する。計算コストは\(O(n^2 p)\)。
\(p\)を\(\log n\)の多項式としても、結果として得られる\(U,D,V\)は高い確率で\(A\)の特異値分解の良い近似となる。手順3. までで得られる、積を取れば\(U\)が得られる\((B,V',D)\)のセットのことを\(V\)の簡潔表現と呼ぶ。従って、\(V\)の簡潔表現を得るための計算量は\({\rm poly}(S_1(A), S_2(A), \log n)\)である。
行列の表現としてセグメント木行列を用いると、\(S_1(A), S_2(A)\)はどちらも\({\rm poly}(\log n)\)である。従って、FKVアルゴリズムは行列がセグメント木の形式で保持されている場合は効率的に行うことができる。
FKVアルゴリズムの実装は以下のようになる。
[11]:
def singular_value_decomposition_FKV(segtree_matrix, k, subsample_count):
# row sampling
## sampled row index list
row_index_list = []
## normalization factor for each sampled row index
row_norm_list = []
matrix_sum = segtree_matrix.get_sum()
for _ in range(subsample_count):
# choose row indices, where its weight is norm of row
row_index = segtree_matrix.sampling_row()
row_index_list.append(row_index)
row_chosen_prob = segtree_matrix.get_row_norm(row_index) / matrix_sum
row_norm_list.append(subsample_count * row_chosen_prob)
# column sampling
## sampled column index list
col_index_list = []
## normalization factor for each sampled column index
col_norm_list = []
## uniformly choose row index from sampled index for sampling column
col_sample_row = np.random.choice(row_index_list,size=subsample_count)
for row_index in col_sample_row:
# choose column indices, where its weight is element at chosen row/column.
col_index = segtree_matrix.sampling_column_in_row(row_index)
col_index_list.append(col_index)
col_chosen_prob = 0.
# compute sum of element in chosen column in row-sampled matrix
for row_index_for_ave in row_index_list:
col_chosen_prob += segtree_matrix.get_element(row_index_for_ave, col_index) / segtree_matrix.get_row_norm(row_index_for_ave)
col_norm_list.append(col_chosen_prob)
# pick normalized element from original matrix
subsample_matrix = np.zeros( (subsample_count, subsample_count) )
for sub_row in range(subsample_count):
for sub_col in range(subsample_count):
org_row = row_index_list[sub_row]
org_col = col_index_list[sub_col]
element = segtree_matrix.get_element(org_row, org_col)
norm_factor = row_norm_list[sub_row] * col_norm_list[sub_col]
subsample_matrix[sub_row,sub_col] = element / norm_factor
# perform SVD for sampled matrix
## since segtree matrix contains squared values as elements, take sqrt for svd
u,d,_ = np.linalg.svd(np.sqrt(subsample_matrix))
# take top-k matrix
d = d[:k]
u = u[:,:k]
return (row_index_list, d, u)
下のコードで示すように、量子状態のボルンの規則と合わせるため、量子状態の値はL2-normでサンプリングされる。セグメント木は和に対して定義されるため、総和がL2-normの二乗となるよう、セグメント木行列の要素には嗜好行列の要素の事情の値が格納される。従って、サブ行列を作成した後に特異値分解する際には、全要素のsqrtを取る必要がある。
このことを踏まえ、早速FKV algorithmを試してみよう。
[12]:
n = m = 200
k = 10
p = 50
dense_matrix = np.zeros( (m,n) )
seg_tree = SegmentTreeMatrix(height = m, width = n)
for y in range(m):
for x in range(n):
val = np.random.rand()
seg_tree.update(y,x,val**2)
dense_matrix[y,x] = val
sampled_row_index_list, d, u = singular_value_decomposition_FKV(seg_tree, k, p)
print("FKV SVD\n",d)
_, d , _ = np.linalg.svd(dense_matrix)
print("Full SVD\n",d[:k])
tsvd = TruncatedSVD(n_components=k, n_iter=1)
tsvd.fit(dense_matrix)
print("Trunc SVD\n",tsvd.singular_values_)
FKV SVD
[100.72014493 17.81097382 16.70538197 15.54469501 14.7001673
14.34469146 13.3012425 12.74757961 12.0585889 11.34122011]
Full SVD
[100.23396543 7.90274002 7.79754658 7.74914095 7.50160583
7.48295602 7.40332841 7.34452346 7.29000909 7.23951272]
Trunc SVD
[100.2339545 7.55592118 7.34886176 7.19372274 7.05660533
7.01074137 6.93760024 6.77137369 6.6663421 6.4976206 ]
簡単な検証のため、\(200 \times 200\)の行列から\(50 \times 50\)のサブ行列を作成し、Top-10の特異値を表示している。最大特異値はおおむねあっているものの、以降の特異値でずれがひどいことが分かる。
次に\(200 \times 200\)の行列から\(1000 \times 1000\)のサブ行列(というのか分からないが)を作成し、Top-10の特異値を表示している。当たり前だが、この計算自体は元の行列より大きな行列をサンプリングしているため有用ではない操作である。
[13]:
n = m = 200
k = 10
p = 1000
dense_matrix = np.zeros( (m,n) )
seg_tree = SegmentTreeMatrix(height = m, width = n)
for y in range(m):
for x in range(n):
val = np.random.rand()
seg_tree.update(y,x,val**2)
dense_matrix[y,x] = val
sampled_row_index_list, d, u = singular_value_decomposition_FKV(seg_tree, k, p)
print("FKV SVD\n",d)
_, d , _ = np.linalg.svd(dense_matrix)
print("Full SVD\n",d[:k])
tsvd = TruncatedSVD(n_components=k, n_iter=1)
tsvd.fit(dense_matrix)
print("Trunc SVD\n",tsvd.singular_values_)
FKV SVD
[100.96870017 9.44054781 9.11840302 8.99198792 8.63975411
8.3835851 8.1716375 8.02057196 7.81662004 7.7810329 ]
Full SVD
[100.94752093 8.02086558 7.80305145 7.77010671 7.60381687
7.56895073 7.42212447 7.33142897 7.30323491 7.20036441]
Trunc SVD
[100.94751363 7.57918791 7.35764553 7.22657673 7.13785688
7.01050535 6.86475243 6.82990868 6.66014474 6.50609105]
まだ十分とは言えないが、正しい値に漸近的に近づいていることが分かる。実は、FKVで必要となるサンプリング数\(p\)は\(10^7 \times \max \left(\frac{k^{5.5}}{\epsilon^{17}}, \frac{k^4}{\epsilon^{24}} \right)\)というとんでもない多項式になっている。従って、\(k=10, \epsilon=0.1\)に対して理論的な精度保証を得るためには、\(p=10^{29.5}\)というとんでもない数のサイズの行列が要請される。これが操作として意味を持つには\(n,m > p\)である必要があり、その行列のサイズは各要素が1bitだとしても\(10^{59}\)byteとなり、行ですら\(10^{14}\)PBという到底保持できないサイズとなる。もちろん、上記の多項式や係数は理論的な証明のためのオーバーヘッドを含むため、実際にはそれほど大きくしなくともそこそこの結果は得られると思われるが、いずれにせよ無条件に多項式だから有用だと言える手法ではないことは念頭に入れておきたい。
棄却サンプリング¶
一般に確率分布は高速にサンプリング可能とは限らない。 例えば\(2^n\)個の要素のうち\(i\)番目の要素が\(p_i\)の確率で得られる分布で、\(i\)に対して\(p_i\)が効率的に計算できるとしても、サンプリングには一般に指数的な時間を要する。
棄却サンプリングは上記のような効率的にサンプリングできるとは限らない分布\(P\)があるとき、 \(P\)と類似している効率的にサンプリング可能な分布\(Q\)を用いて\(P\)からのサンプリングを効率的に行う手法である。この時、最終的にサンプリングしたい分布\(P\)を目的分布、目的分布に近いと期待される効率的にサンプリング可能な分布\(Q\)を提案分布と呼ぶ。
具体的な手続きは以下である。
\(M \geq \max_i \left( \frac{p_i}{q_i} \right)\)となる\(M\)を求める。
\(Q\)からサンプリングを行い、\(i\)を得る。
\(t = \frac{p_i}{Mq_i}\)を計算する。
[0,1]の区間の一様乱数 \(r\) を得る。\(r<t\)なら\(i\)を出力する。そうでなければ1.からやり直す。
上記の手続きで得られるサンプリング操作は\(Q\)からのサンプリング操作と一致する。 上記の操作に必要な計算量は\(Q\)からのサンプリングの操作が効率的なら\(O(M)\)の期待値で終了する。
効率の最良のケースは確率分布\(P\)と\(Q\)が一致している場合であり、\(M=1\)となる。ただしこの場合はそもそも棄却サンプリングを行う意味がない。最悪のケースは\(p_i > 0\)でかつ\(q_i=0\)となるような\(i\)が存在する場合である。この場合、\(M = \infty\)となり棄却サンプリングはそもそも実行できない。
例として、一様分布に十分近い\(n\)個のシンボルについての確率分布\(\{p_i\}\)を考えよう。この分布は全てのindexについて、おおよそ\(p_i < 1.1 (1/n)\)であることが保障されているとする。\(\{p_i\}\)からサンプリングするには最悪で\(O(n)\)が必要になるが、一様分布は\(\log n\)の速度で高速にサンプリングすることができる。
このことを利用して、\(\{p_i\}\)からのサンプリングを一様分布のサンプリングを用いて棄却サンプリングしてみよう。(以下の実装では規格化のために微妙に\(M=1.1\)からずれているが、おおむね\(M=1.1\)となる。)
[14]:
def direct_sampling(prob_dist,index_list):
index = np.random.choice(index_list, p = prob_dist)
return index
def rejection_sampling(prob_dist, M, n):
sampling_count = 0
while True:
index = np.random.randint(n)
sampling_count+=1
thr = prob_dist[index]/(M /n)
r = np.random.rand()
if r<thr:
return index, sampling_count
まず、棄却サンプリングからサンプリングされる分布が、オリジナルの分布を再現しているかを検証してみよう。
[15]:
n = 10
prob_dist = (1./n) * (1.+ (np.random.rand(n)-0.5)*0.1)
prob_dist /= np.sum(prob_dist)
M = np.max(prob_dist / (1./n))
index_list = np.arange(n)
sample_count = 10**6
sample_rejection = []
for _ in range(sample_count):
sample, _ =rejection_sampling(prob_dist, M, n)
sample_rejection.append(sample)
counter = Counter(sample_rejection)
rejection_probs = []
for i in index_list:
rejection_probs.append(counter[i]/sample_count)
plt.plot(index_list, prob_dist, label="target")
plt.plot(index_list, [1/n]*n, label="uniform",linestyle = "--")
plt.errorbar(index_list, rejection_probs, yerr=1./np.sqrt(sample_count), label="rejection")
plt.legend()
plt.xlabel("synbols")
plt.ylabel("probability")
plt.show()

確かに棄却サンプリングから得られる分布と目的分布が一致していることがわかる。次に、通常のサンプリングとの速度比較のベンチマークを取ってみよう。
[16]:
n_list = [10,10**2,10**3,10**4,10**5,10**6,10**7]
direct_sampling_time = []
rejection_sampling_time = []
sampling_count_list = []
M_list = []
for n in n_list:
prob_dist = (1./n) * (1.+ (np.random.rand(n)-0.5)*0.1)
prob_dist /= np.sum(prob_dist)
M = np.max(prob_dist / (1./n))
index_list = np.arange(n)
start_time = time.time()
rep = 0
while time.time()-start_time < 0.1:
index = direct_sampling(prob_dist,index_list)
rep+=1
elapsed_direct = (time.time()-start_time)/rep
start_time = time.time()
rep = 0
sampling_count_average = 0
while time.time()-start_time < 0.1:
index, sampling_count = rejection_sampling(prob_dist, M, n)
sampling_count_average += sampling_count
rep+=1
elapsed_rejection = (time.time()-start_time)/rep
sampling_count_average /= rep
direct_sampling_time.append(elapsed_direct)
rejection_sampling_time.append(elapsed_rejection)
M_list.append(M)
sampling_count_list.append(sampling_count_average)
plt.plot(n_list, direct_sampling_time,label="direct")
plt.plot(n_list, rejection_sampling_time,label="rejection")
plt.legend()
plt.xlabel("size")
plt.ylabel("time (sec)")
plt.xscale("log")
plt.yscale("log")
print("M={}".format(np.mean(M_list)))
print("average repetition in rejection sampling = {}".format(np.mean(sampling_count_list)))
M=1.049485039045078
average repetition in rejection sampling = 1.050053426899406

良いサンプラーを持っている場合、直接的なサンプリングに比べ、棄却サンプリングは圧倒的に高速であることがわかる。また、\(M\)の値とサンプリング回数がおおむね一致している。
棄却サンプリングを行うにあたって注意しなければならない点が二つある。
一つ目は、棄却サンプリングを用いる代わりに、目的分布の確率ベクトルに関する累積和やセグメント木などのデータ構造を用いることでも高速にサンプリングが可能になるという可能性が存在する点である。例えばセグメント木を与えられたベクトルで初期化する手続きには\(O(n)\)が必要となる。サンプリングの回数を\(s\)とした場合、データ構造を用いて以降のサンプリングを効率化した場合の計算量は\(O(n + s \log n)\)であり、棄却サンプリングを行った場合は\(O(M s \log n)\)である。従って、棄却サンプリングを行う利点がある場合は
の場合である。従って、\(n\)に対して\(M\)や\(s\)が非常に大きいような場合は最初にデータ構造を作ってしまうべきである。一方、小さい\(M\)が保障されておりサンプリングの回数が大きくない場合は棄却サンプリングは有効である。
二つ目は\(M\)の計算コストである。上記では\(M\)の計算コストは無視した。しかしながら、一般に二つの分布が与えられたとき、その間の\(M\)がどの程度に収まるかを計算するのは\(O(n)\)の時間が必要となる。もし\(n\)の時間をかけて\(M\)を計算したとすると、棄却サンプリングのコストは\(O(n + Ms\log n)\)となり常にセグメント木を用いるアプローチより低速である。従って、棄却サンプリングを有効に用いるには、目的分布の既知の性質から近いと確信が持てる提案分布が構成できなければならない。
内積の推定¶
二つの長さ\(n\)のベクトル\(x,y\)があるとき、この二つの内積\(x\cdot y\)を求める計算は\(O(n)\)を要する。一方、\(x,y\)についていくつか追加の操作が許されているとき、ある誤差の範囲内でより高速に内積の値を推定することができる。
サンプリング可能でノルムが既知のベクトル\(x\)と、一般のベクトル\(y\)がある場合、確率\(p_i = \frac{x_i^2}{\left|x\right|^2}\)の確率で値\(z_i = \frac{y_i}{x_i}|x|^2\)をとる乱数\(z\)を考える。この時、\(z\)の期待値は\(E[z] = \sum_i p_i z_i = \sum_i x_i y_i = x \cdot y\)となる。また、\(z\)の分散は\(V[z]^2 \leq |x|^2|y|^2\)となる。
[17]:
n = 100
x = np.random.rand(n)
x_square = x**2
x_square_norm = np.sum(x_square)
x_square_normalized = x_square / x_square_norm
y = np.random.rand(n)
inner_product = np.dot(x,y)
index_list = np.arange(n)
avg = 0
sampling_count_list = np.arange(0,1000000,1000)[1:]
estimation_list = []
for sample_index in range(max(sampling_count_list)):
index = np.random.choice(index_list, p = x_square_normalized)
z = y[index]/x[index] * x_square_norm
avg += z
if sample_index+1 in sampling_count_list:
estimation_list.append(avg/(sample_index+1))
plt.plot([min(sampling_count_list), max(sampling_count_list)], [inner_product, inner_product], label = "inner_product")
plt.plot(sampling_count_list, estimation_list,label = "estimation")
plt.legend()
plt.show()

乱数の平均と分散が与えられたとき、median-of-meansと呼ばれるテクニックで精度を保証できるサンプル数を導くことができる。 具体的には、\(\frac{9}{2\epsilon^2}\)個のサンプルについての平均値\(\langle x\cdot y \rangle\)を計算する。この操作を\(6 \log \delta^{-1}\)回行い、その中央値を取り出す。 このとき、得られた中央値が正しい値の\(\epsilon |x||y|\)以内にいる確率は\(1-\delta\)以上であることが保証できる。
従って、\(1-\delta\)以上の信頼度で\(\epsilon|x||y|\)以下の誤差で\(x \cdot y\)を得るために必要なサンプリングは\(O(\epsilon^{-2} \log \delta^{-1})\)である。
ここまでのまとめ¶
ここまでで紹介した要素をいったんまとめよう。
セグメント木行列¶
セグメント木行列は\(m \times n\)の実数行列\(T\)に対し、以下の操作を全て高々\(O(\log nm)\)の時間で実現する。
\(i\)行\(j\)列要素\(T_{ij}\)の読み出し
\(i\)行\(j\)列要素\(T_{ij}\)の更新
\(i\)行目のL1ノルム\(|T_i|\)の計算
\(i\)行目に対し、列添え字\(j\)を\(\frac{T_{ij}}{|T_i|}\)の確率で得るサンプリング
行列要素の総和\(|T|:=\sum_i |T_i|\)の計算
行添え字\(i\)を\(\frac{|T_i|}{\sum_i |T|}\)の確率で得るサンプリング
なお、行列\(S\)に対してL2-normに対するサンプリングを行いたい場合は、\(T_{ij} = S_{ij}^2\)としておけば、上記のノルムはL2-normの二乗に、要素総和はフロベニウスノルムの二乗に、サンプリングは二乗重みでのサンプリングになる。
FKVアルゴリズム¶
ある\(n \times k\)の行列\(V\)が、\(p = {\rm poly}(k)\)を満たす整数\(p\)について、\(n \times p\)のセグメント木行列\(A\)と\(p \times k\)の密行列\(B\)の積で\(V = AB\)と表されるとき、\((A,B)\)を\(V\)の簡潔表現と呼ぶ。
FKVアルゴリズムは、\(m \times n\)のセグメント木行列\(T\)と整数\(k\)と小さい実数\(\epsilon\)に対し、\(T\)のTop-\(k\)右特異値行列\(V_k\)を
と近似する行列 \(T^{\rm FKV}\) の簡潔表現\((A,B)\)を\(k,\epsilon^{-1}\)に対して多項式時間で与える。ただし、\(T^{\rm SVD}\)はSVDを用いて得られる\(T\)に対する最良のrank-\(k\)近似である。\(|T|_{\rm F}\)は行列のフロベニウスノルムである。
棄却サンプリング¶
\(n\)個のシンボルに対する目的分布\(\{p_i\}\)と提案分布\(\{q_i\}\)を考える。これらの分布は以下を満たすとする。
与えられた\(i\)に対して\(p_i\)と\(q_i\)が\(O(\log n)\)で計算可能
添え字\(i\)を\(q_i\)の確率で得るサンプリングが\(O(\log n)\)で可能
任意の\(i\)について\(M \geq \frac{p_i}{q_i}\)を満たす整数\(M\)を計算可能
この時、添え字\(i\)を\(p_i\)の確率で得るサンプリングが\(O(M \log n)\)で可能になる。
内積の推定¶
長さ\(n\)のベクトル\(x,y\)について、以下が満たされるとする。
与えられた添え字\(i\)について、\(x_i,y_i\)が効率的に計算できる。
ノルム\(|x|\)を効率的に計算できる。
添え字\(i\)を\(\frac{x_i}{|x|}\)の確率で得るサンプリングを効率的に行える。
この時、\(x\cdot y\)の値を\(1-\delta\)以上の確率で\(\epsilon |x||y|\)以内の誤差で求める計算を\({\rm poly}(\epsilon^{-1},\log \delta^{-1})\)で行うことができる。
低ランク行列を用いた高速な推薦¶
上記までのテクニックが分かっていれば、後は組み合わせるだけである。Ewin Tangによる論文の殆どは、上記を組み合わせた時に要請される条件が満たされることの証明と、誤差や計算量の解析である。 誤差の評価を行うと記事が非常に長くなるため、ここでは行列であればフロベニウスノルムの差が、確率分布であればtotal variational distanceの差が\(\epsilon\)の比率のエラーで抑えられ、かつ計算時間は\({\rm poly}(\epsilon)\)に抑えられるという結果だけ述べておく。
Ewin Tangの論文は現時点(2019年3月)で3本[1][2][3]からなるが、この記事では最初の二つを紹介する。といっても、二本目の論文の結果は一本目の論文の中間結果として得られるものなので、実質一本目の紹介である。
低ランク行列の主成分分析¶
Ewin Tangのアルゴリズムは全体のアルゴリズムの前半部分として\(i\)行ベクトルの主成分分析を行う。まず、主成分分析とは以下のような手続きである。
行列\(A\)に対して特異値分解\(A = U_k D_k V_k^{\dagger}\)が得られたとき、\(A\)の\(i\)行目ベクトルのTop-\(k\)特異値空間へ次元を減らしたベクトル\(v = A_i V_k\)の要素を\(A_i\)のTop-\(k\)主成分と呼ぶ。この\(k\)主成分を確率\(1-\delta\)以上で\(\epsilon\)以内の誤差で求める操作を\({\rm poly}(\log nm, \epsilon^{-1}, \log \delta^{-1} )\)で行いたい。
行列\(A\)はセグメント行列を用いた行列に格納することで、FKVアルゴリズムを実施できる。FKVアルゴリズムを実施すると、\(V=A_p^{\rm T}UD^+\)となる簡潔表現\((A_p^{\rm T},UD^+)\)を効率的に得ることができる。この時、\(A_p\)は\(p\)個の行が\(A\)からサンプリングされたセグメント木行列表現を持つ。簡潔表現の\(U,D^+\)について、Top-\(k\)個の特異値と特異値ベクトルのみを取り出した行列をそれぞれ\(U_k, D_k^+\)として、我々がやるべきことは主成分ベクトル
を計算することである。主成分の要素数は\(k\)個なので、\(j\)番目の主成分
を求める操作を\(k\)回繰り返せばよい。このとき、\(A_i\)はセグメント木であり、\((A_p^{\rm T} V_k D_k^+)_{kj}\)は\({\rm poly}(k)\)で計算可能である。従って、内積の推定を行う条件を満たしている。\(V_k\)の列ベクトルはユニタリ行列の列を近似したものであるから、ノルムは\(O(1)\)である。したがって、\(v_i\)の誤差は\(\epsilon |A_i|\)である。
低ランク行列の特異値空間への射影¶
行列\(A\)の右特異値行列のTop-\(k\)特異値に相当する行を取り出したものを\(V_k\)とする。行列\(A\)のある行\(A_i\)をTop-\(k\)特異値空間へ射影するとは、
という計算を行うことと等しい。我々の目的は与えられた添え字\(i\)について、上記のように求まる\(A_i'\)から、成分のL2-normの重みでサンプリングを行うことである。一つ前の節で\(A_i V_k\)は得られているため、我々がやるべきことは
からサンプリングすることである。これを棄却サンプリングを用いて行う。 記号を簡略化するため、\(B :=D_k^{+} U_k ^{\dagger}\)、\(w = (A_i')^{\rm T}\)と定義すると、以下のようになる。
まず、\(A_p^{\rm T} B\)の任意の列ベクトルはサンプリング可能であることを示す。\(A_p^{\rm T}B\)の\(j\)行目は\(B\)の\(j\)列目を\(B^{(j)}\)として、\(A_p^{\rm T} B^{(j)}\)である。従って、目的分布は
である。これに対し、
という提案分布を考えると、これは効率的にサンプリング可能であり、かつコーシーシュワルツの不等式と\(V'\)の列は\(V'\)が等長行列であることから列ノルムが1であることを用いると、\(M\)が\(k\)の多項式で抑えられることが示せる。従って、\(A_p^{\rm T} B\)の任意の列ベクトルはサンプリング可能である。
最後に\(A_p^{\rm T}B v^{\rm T}\)は効率的にサンプリング可能であることを示す。\(C=A_p^{\rm T}B\)と置くと前節と同じ形式で
を提案分布として用いる。この提案分布の積の左はセグメント木の性質を用いてサンプリング可能であり、右は直前の棄却サンプリングで可能である。ただし、左部は特定の\(i\)について計算することが難しいため、近似的に以下の値を用いる。
これにより、最終的に得られる分布に\(\epsilon\)のtotal variational distanceの誤差は乗るものの、効率的に棄却サンプリングが可能となる。
参考文献¶
第8章 量子探索アルゴリズム¶
第8章ではデータベース探索など、いわゆる探索問題を解く量子アルゴリズムを紹介する。 量子探索アルゴリズムは古典の探索アルゴリズムより計算量が少なくて高速であると言われているが、ここでいう計算量とはオラクルという関数にアクセスする回数で定義されている。 本章ではまずオラクルの説明を行い、その後に量子探索アルゴリズムの最も重要な例の一つであるグローバーのアルゴリズムについての詳細な説明を行う。
8-1. オラクル¶
本節では、探索問題を一般的に扱い、その計算量を考えるために必要な概念であるオラクルを紹介する[1]。
オラクルとは¶
探索問題とは、一般に \(N\) 個の要素から \(M\) 個の解を見つける問題である。例えば、人名がたくさん入った名簿データベースがあって、その中から佐藤さんだけ取り出したい、といった問題である。 \(N\) 個の要素が \(n\) 桁ビット列の \(x=x_1\ldots x_n\) でラベル付けされているとして、 この探索問題に対応する「古典オラクル」を次のように定義する。
つまり、 \(f\) は要素のラベル \(x\) を与えるとその要素が解であるかを二択で教えてくれる関数である。 人名の例でいうと、名簿\(_0\)=高橋、名簿\(_1\)=佐藤のとき、\(f(0)=0, f(1)=1\) となる。 オラクル (Oracle) は日本語で神託という意味であり、中身はブラックボックスだけれどとにかく答えを教えてくれる抽象的な存在で、それがどのように実装されているかは気にしない(実際に実装が存在する必要もない)。 探索問題を解く古典アルゴリズムの計算量は、\(x\) が解であるかを古典オラクルに尋ねる回数で評価する。こうすることで、問題の子細に依らない統一的な評価が可能になる。
一方、探索問題を解く量子アルゴリズムの計算量は、次の(量子)オラクル \(\mathcal{O}_f\) を呼ぶ回数で評価する。
ここで入力状態 \(|x \rangle\) は入力ビット列 \(x\) を計算基底で表した状態、\(|q \rangle\) は補助ビット、\(\oplus\) はモジュロ2の加算(XOR演算)である。 \(x\) が探索問題の解であるかは、補助ビット \(q\) が反転したかどうかをチェックすればわかる:\(|x \rangle |0 \rangle \xrightarrow{\mathcal{O}_f} |x \rangle | f(x) \rangle\). 他にも、補助ビットを \(|- \rangle = (|0 \rangle - |1 \rangle)/\sqrt{2}\) とセットしておけば、
となるから、\(x\) が探索問題の解の時にのみ状態の位相が反転するようにもできる。
オラクル \(\mathcal{O}_f\) の最も大きな特徴の一つは、入力状態が重ね合わせであってもそのまま動作することである。 例えば、入力状態として全ての状態の重ね合わせ \(N^{-1/2} \sum_x |x\rangle\) をとり、補助ビットを \(|-\rangle\)とすれば、
となる。このような、\(x\)が解の時のみ位相が反転する性質をうまく用いて解を見つけるのが、次節で紹介するグローバーのアルゴリズムである。
参考文献¶
[1] M. A. Nielsen and I. L. Chuang, “Quantum Computation and Quantum Information 10th Anniversary Edition“, University Printing House の 6.1 The quantum search algorithm
8-2. グローバーのアルゴリズム¶
グローバーのアルゴリズムは、整列化されていないデータベースから特定のデータを探索するための量子アルゴリズムである[1]。 グローバーのアルゴリズムは、ソートされていない \(N\) 個のデータに対して、\(O( \sqrt{N})\) 回のクエリ(オラクルを呼ぶこと)で解を見出せる。古典コンピュータでは\(O(N)\)回のクエリが必要であるので、量子アルゴリズムを用いることで二次 (quadratic) の加速が実現されることになる。
オラクルさえ構成できれば、グローバーのアルゴリズムはあらゆる古典アルゴリズムの全探索部分を高速化することができる。例えば、
充足可能性問題(SAT問題)
特定のハッシュ値から元の値を探索する問題
といった応用例が考えられ、後者は実際にビットコインのマイニング高速化として提案論文がある[2]。
この節ではまずグローバーのアルゴリズムの理論的な説明を行い、その後 Qulacs による実装例を紹介する。
アルゴリズムの流れ¶
グローバーのアルゴリズムの流れはシンプルで、以下の通りである。 前節と同様、 \(N\) 個の要素からなるデータベースから \(M\) 個の解を探索する問題を考え、要素のラベルを \(n\) 桁のビット列 \(x = x_1 \ldots x_n\) とする。
全ての状態の重ね合わせ状態 \(|s\rangle = \frac{1}{\sqrt{N}}\sum_x |x\rangle\) を用意する
オラクル \(U_w\) (解に対する反転操作)を作用させる
\(|s\rangle\) を対称軸にした反転操作 \(U_s\) を作用させる
ステップ 2,3 を \(k\) 回繰り返す
測定を行う
各ステップを詳細に見ていこう。
1. 全ての状態の重ね合わせ状態 \(|s\rangle = \frac{1}{\sqrt{N}}\sum_x |x\rangle\) を用意する¶
これは簡単である。初期状態 \(|0\cdots0\rangle\) に対して全ての量子ビットにアダマールゲート \(H\) をかければ良い。
2. オラクル \(U_w\) (解に対する反転操作)を作用させる¶
次に、オラクルを状態 \(|s\rangle\) に作用させる。 ここではオラクルとして、前節の最後で述べたような「入力 \(|x\rangle\) に対して \(x\)が解なら(-1)をかけて位相を反転し、解でないなら何もしない」という演算を考えることにして、補助ビットは省略する。つまり、オラクル \(U_w\) を
と定義する。入力が解である時にだけ位相を反転させるので、オラクル \(U_w\) は「解に対する反転操作」と呼ばれる。
3. \(|s\rangle\) を対称軸にした反転操作 \(U_s\) を作用させる¶
ステップ2では解に対する反転操作を考えたが、ステップ3では全ての状態の重ね合わせ \(|s\rangle\) を対称軸にした反転操作 \(U_s\) を作用させる。
この演算子は、入力状態 \(|\psi\rangle = a|s\rangle + b|s_\perp\rangle\) (\(|s_\perp\rangle\) は \(|s\rangle\) に直交するベクトル)に対して
と作用し、\(|s_\perp\rangle\) に比例する部分の位相だけを反転する。
4. ステップ2,3 を \(k\) 回繰り返す¶
上記の2つの反転操作 \(U_w\) と \(U_s\) を繰り返す。後で述べるが、およそ \(O(\sqrt{N/M})\) 回の繰り返しを行えば、次のステップ5の測定で十分高い確率で解が得られる。つまり、オラクルを呼ぶ回数は \(O(\sqrt{N})\) で良い。
5. 測定を行う¶
ここまでのステップで状態は \((U_s U_w)^k | s \rangle\) となっている。 \(k\) はステップ2,3の繰り返しの回数である。 後述するように実はこの状態は、解 \(w\) に対応する状態 \(|w\rangle\) の係数(の絶対値)のみが非常に大きくなっているので、計算基底で測定を行えば、高い確率で解 \(w\) (ビット列) が得られる。
理屈を抜きにすれば、グローバーのアルゴリズムで行う操作はこれだけで、非常にシンプルである。
幾何学的な説明¶
次に、グローバーのアルゴリズムがなぜ上手くいくのか、幾何学的な説明を行う。(他にも係数の平均操作に着目する説明もあり、例えば[3]を参照してほしい。)
二次元平面の定義¶
まず、次の2つの状態 \(|\alpha\rangle, |\beta\rangle\) で張られる2次元平面を考える。
全ての状態の重ね合わせ状態 \(|s\rangle\) は次のように表せるので、この2次元平面内のベクトルであることがわかる。
特に、\(\cos{\frac{\theta}{2}} = \sqrt{\frac{N-M}{N}}, \sin{\frac{\theta}{2}} = \sqrt{\frac{M}{N}}\) を満たす角 \(\theta\) を用いれば
とかける。これを図示すると、以下のようになる。 (なお、一般に探索問題においては \(N \gg{} M\) であるから、 \(\sqrt{M/N}\) は0に近く、\(\theta\) は0に近い正の数になっていることが多い。)
2回の反転操作 \(U_s U_w\) = 二次元平面内の回転¶
この平面内で考えると、オラクル \(U_w\) は \(|\beta\rangle\) 軸に対する反転操作である(\(U_w|\alpha\rangle =|\alpha\rangle, U_w|\beta\rangle = -|\beta\rangle\))。 よって、\(U_w\) を作用させた後、\(|s\rangle\) を対称軸とした反転 \(U_s\) を作用させると、\(|\alpha\rangle, |\beta\rangle\) 平面内で角度 \(\theta\)だけの回転が行われることになる。(図を見て考えると分かる)
グローバーのアルゴリズムでは \(U_s U_w\) を \(k\) 回繰り返すから、状態は \(k\) 回回転し、測定直前には
となっている。 \(N \gg M\) の時 \(\theta\) は0に近い正の数だったから、\(|s\rangle\) に \(U_s U_w\) を作用させるたびに、\(|\alpha\rangle\) の係数が減って \(|\beta\rangle\) の係数が増えることになる。 \(|\beta\rangle\) は全ての解の状態の重ね合わせでできていたから、これはすなわち \((U_s U_w)^k |s\rangle\) を測定した時に解が出力される確率が高まることを意味する。
以上が、グローバーのアルゴリズムが解を上手く探索できる理由である。
最適な \(k\) の見積もり¶
最後に、\(U_s U_w\) を作用させる回数 \(k\) 、つまりオラクルを呼ぶ回数がどれくらいになるのか評価してみよう。 これが計算量を決めることになる。
\((U_s U_w)^k |s\rangle\) が \(|\beta\rangle\) に最も近くなるのは \(\frac{(2k+1)\theta}{2}\) が \(\frac{\pi}{2}\) に近くなるとき、すなわち\(k\) が
の時である。ここで \(\text{ClosestInteger}(\ldots)\) は \(\ldots\) に最も近い整数を表す。 \(R\)の上限を評価しよう。 \(\theta > 0\) について成り立つ式
を使うと、
となる。つまり、\(R\) は高々 \(O(\sqrt{N/N})\) であり、グローバーのアルゴリズムが \(O(\sqrt{N})\) で動作することが分かった。
実装例¶
グローバーのアルゴリズムを Qulacs を用いて実装してみよう。(実装コードは[4]をほぼそのまま使用している)
[1]:
## ライブラリのインポート
import matplotlib.pyplot as plt
import numpy as np
import time
import random
from qulacs import QuantumState
from qulacs.state import inner_product
from qulacs import QuantumCircuit
from qulacs.gate import to_matrix_gate
from qulacs import QuantumState
from qulacs.gate import Identity, X,Y,Z #パウリ演算子
from qulacs.gate import H
from qulacs.gate import RX,RY,RZ #パウリ演算子についての回転演算
[2]:
## 係数の絶対値の分布をプロットする関数
def show_distribution(state,nqubits):
plt.bar([i for i in range(pow(2,nqubits))], abs(state.get_vector()))
plt.show()
動作の確認¶
まずは 5 qubitでグローバーのアルゴリズムを実装し、動作を確認してみよう。 全ての状態の重ね合わせ状態 \(|s\rangle\) は状態 \(|0\cdots0\rangle\) の全てのビットにアダマールゲートを作用させることで作れる。
[3]:
nqubits = 5
state = QuantumState(nqubits)
state.set_zero_state()
def make_Hadamard(nqubits):
Hadamard = QuantumCircuit(nqubits)
for i in range(nqubits):
Hadamard.add_gate(H(i))
return Hadamard
Hadamard = make_Hadamard(nqubits)
Hadamard.update_quantum_state(state)
show_distribution(state,nqubits)

次にオラクル \(U_w\) を作る。ここでは \(|1\ldots1\rangle\) を解として設定し、\(|1\ldots1\rangle\) のみに位相(-1)をつける演算子を作る。 このような演算子は「0
番目からnqubits-1
番目までの量子ビットがすべて1
の場合にnqubits
番目の量子ビットに \(Z\) ゲートを作用させる演算子」として実装できる。 実装には Qulacs の特殊ゲート to_matrix_gate
を用い、 control_index
とcontrol_with_value
を使用する。
[4]:
def make_U_w(nqubits):
U_w = QuantumCircuit(nqubits)
CnZ = to_matrix_gate(Z(nqubits-1))
# i-th qubitが全て1の場合だけゲートを作用
for i in range(nqubits-1):
control_index = i
control_with_value = 1
CnZ.add_control_qubit(control_index, control_with_value)
U_w.add_gate(CnZ)
return U_w
オラクルの作用を確認すると、確かに最後の成分(\(|1\cdots1\rangle\))だけ位相が反転していることが分かる。
[5]:
hoge = state.copy()
U_w = make_U_w(nqubits)
U_w.update_quantum_state(hoge)
print(hoge.get_vector())
[ 0.1767767+0.j 0.1767767+0.j 0.1767767+0.j 0.1767767+0.j
0.1767767+0.j 0.1767767+0.j 0.1767767+0.j 0.1767767+0.j
0.1767767+0.j 0.1767767+0.j 0.1767767+0.j 0.1767767+0.j
0.1767767+0.j 0.1767767+0.j 0.1767767+0.j 0.1767767+0.j
0.1767767+0.j 0.1767767+0.j 0.1767767+0.j 0.1767767+0.j
0.1767767+0.j 0.1767767+0.j 0.1767767+0.j 0.1767767+0.j
0.1767767+0.j 0.1767767+0.j 0.1767767+0.j 0.1767767+0.j
0.1767767+0.j 0.1767767+0.j 0.1767767+0.j -0.1767767+0.j]
同様に、\(|s\rangle\) を対称軸にした反転 \(U_s\) を作る。以下の式が成り立つことを使う。
[6]:
def make_U_s(nqubits):
U_s = QuantumCircuit(nqubits)
for i in range(nqubits):
U_s.add_gate(H(i))
## 2|0><0| - I の実装
U_s.add_gate(to_matrix_gate(RZ(nqubits-1, 2*np.pi))) ## まず、位相(-1)を全ての状態に付与する。ゲート行列はarrary([[-1,0],[0,-1]])
U_s.add_gate( X(nqubits-1) )
## 全てのi-th qubitが0の場合だけZゲートを作用させる
CnZ = to_matrix_gate(Z(nqubits-1))
for i in range(nqubits-1):
control_index = i
control_with_value = 0
CnZ.add_control_qubit(control_index, control_with_value)
U_s.add_gate( CnZ )
U_s.add_gate( X(nqubits-1) )
for i in range(nqubits):
U_s.add_gate(H(i))
return U_s
それでは、 \(U_s U_w\) を一回だけ作用させて確率分布の変化を見てみよう。全部1の状態(一番右側)の確率が少し大きくなっている。
[7]:
## 初期状態の準備
state = QuantumState(nqubits)
state.set_zero_state()
Hadamard.update_quantum_state(state)
## U_s U_w を作用
U_s = make_U_s(nqubits)
U_w.update_quantum_state(state)
U_s.update_quantum_state(state)
show_distribution(state,nqubits)

これを何回か繰り返してみると
[8]:
## 内積を評価するために 解状態 |1...1> を作っておく
target_state = QuantumState(nqubits)
target_state.set_computational_basis(2**nqubits-1) ## 2**n_qubits-1 は 2進数で 1...1
## グローバーのアルゴリズムの実行
state = QuantumState(nqubits)
state.set_zero_state()
Hadamard.update_quantum_state(state)
for i in range(4):
U_w.update_quantum_state(state)
U_s.update_quantum_state(state)
show_distribution(state,nqubits)
print(np.linalg.norm(inner_product(state, target_state)))

0.5082329989778305

0.7761601777867947

0.9470673343724091

0.9995910741614723
\(k=4\) 回ほどの繰り返しで、ほぼ確率 1 で解の状態を得ることができた。 nqubits
をもう少し大きくして、\(k\) に対する解出力確率の振る舞いをチェックしてみる。
[9]:
nqubits = 10
state = QuantumState(nqubits)
state.set_zero_state()
## 内積を評価するために 解状態 |1...1> を作っておく
target_state = QuantumState(nqubits)
target_state.set_computational_basis(2**nqubits-1) ## 2**n_qubits-1 は 2進数で 1...1
## グローバーのアルゴリズムの実行
Hadamard = make_Hadamard(nqubits)
U_w= make_U_w(nqubits)
U_s = make_U_s(nqubits)
result = []
state = QuantumState(nqubits)
state.set_zero_state()
Hadamard.update_quantum_state(state)
for k in range(30):
U_w.update_quantum_state(state)
U_s.update_quantum_state(state)
#show_distribution(state,nqubits)
result.append(np.linalg.norm(inner_product(state, target_state)))
max_k = np.argmax(result)
print( f"maximal probability {result[max_k]:5e} is obtained at k = {max_k+1}")
plt.plot(np.arange(1, 30+1), result, "o-")
maximal probability 9.997306e-01 is obtained at k = 25
[9]:
[<matplotlib.lines.Line2D at 0x11d3ee990>]

\(k=25\) 回 でほぼ確率1でtarget状態が得られている。また、確率の\(k\)依存性は、「幾何学的な説明」の箇所で見たように、サイン関数になっている。
最後に、解を見つけるのに必要な \(k\) が量子ビット数についてどのように振る舞うか見てみよう。
[10]:
result = []
min_nqubits = 6
max_nqubits = 16
for nqubits in range(min_nqubits, max_nqubits+1, 2):
## 回路の準備
Hadamard = make_Hadamard(nqubits)
U_w= make_U_w(nqubits)
U_s = make_U_s(nqubits)
## 内積を評価するために 解状態 |1...1> を作っておく
target_state = QuantumState(nqubits)
target_state.set_computational_basis(2**nqubits-1) ## 2**n_qubits-1 は 2進数で 1...1
state = QuantumState(nqubits)
state.set_zero_state()
Hadamard.update_quantum_state(state)
## 確率が減少を始めるまで U_s U_w をかける
tmp = 0
flag = 0
num_iter = 0
while flag == 0 and num_iter <= 1000:
num_iter += 1
U_w.update_quantum_state(state)
U_s.update_quantum_state(state)
suc_prob = np.linalg.norm(inner_product(state, target_state))
if tmp < suc_prob:
tmp = suc_prob
else:
flag = 1
result.append( [nqubits, num_iter, suc_prob] )
print(f"nqubits={nqubits}, num_iter={num_iter}, suc_prob={suc_prob:5e}")
nqubits=6, num_iter=7, suc_prob=9.526013e-01
nqubits=8, num_iter=13, suc_prob=9.930691e-01
nqubits=10, num_iter=26, suc_prob=9.963280e-01
nqubits=12, num_iter=51, suc_prob=9.992534e-01
nqubits=14, num_iter=101, suc_prob=9.998851e-01
nqubits=16, num_iter=202, suc_prob=9.999368e-01
[11]:
result_array = np.array(result)
plt.xlim(min_nqubits-1, max_nqubits+1)
plt.xlabel("n, # of qubits", fontsize=15)
plt.ylabel("k, # of iteration", fontsize=15)
plt.semilogy(result_array[:,0], result_array[:,1], "o-", label="experiment")
plt.semilogy(result_array[:,0], 0.05*2**result_array[:,0], "-", label=r"$\propto N=2^n$")
plt.semilogy(result_array[:,0], 2**(0.5*result_array[:,0]), "-", label=r"$\propto \sqrt{N}=2^{n/2}$")
plt.legend(fontsize=10)
[11]:
<matplotlib.legend.Legend at 0x11a5fd410>

繰り返し回数=オラクルを呼ぶ回数 \(k\) が \(O(N)\) ではなく \(O(\sqrt{N})\) に比例していることがわかる。
発展¶
興味のある読者は、グローバーのアルゴリズムを使ってコンビニの配置問題を解く IBM Quantum Challenge 2019 コンテスト問題 に取り組んでほしい。解答例もアップロードされている。
参考文献¶
6.1 The quantum search algorithm
第9章 量子誤り訂正¶
現実的な状況で動作する計算機を構成するデバイスは外部からのノイズに絶えずさらされており、様々な理由で100%正確に動作することはなく有限の確率でエラーを起こしてしまう。 起きたエラーが計算の目的と関係ない箇所だったり、エラーが計算する時間に生じる可能性が無視できるほど小さいなら良いが、一般にはエラーが起きた計算の結果が正しいことを保証することは困難である。
こうした状況で意味のある計算を行うためには、エラー訂正が必要となる。エラー訂正とは、\(n\)ビットの情報を表すのに\(n\)ビットより多いビット数で冗長に表現することで、ビットにある程度のエラーが生じてももとの状態を復元できるようにすることである。\(n\)ビットの情報を冗長な表現にうつすことをエンコード (符号化) 、冗長な情報を読んで元の情報を復元することをデコード(復号化)と呼ぶ。量子計算機上でエラー訂正を行うことを量子誤り訂正と呼ぶ。また、誤り訂正機能を持ったまま、符号化された状態で計算を行うことを、誤り耐性計算と呼ぶ。 1024ビットの素因数分解のような複雑な計算を量子計算で行うには、量子誤り耐性計算を行うことが必須となる。
誤り訂正技術は、量子計算機が考えられる以前から長く用いられてきた。現代でもスーパーコンピュータのような大規模計算、ノイジーな環境での通信、複数のノードを用いた分散システム、金融やインフラにおける高信頼性の確保などのために広く利用されている。こうした歴史ある誤り訂正の理論を用いれば、量子誤り訂正や量子誤り耐性計算を行うことは可能だろうか?答えはNoである。 量子計算機は量子ビットの持つNo cloning theoremなどの特徴から、従来のエラー訂正で前提となるビットの直接観測などが行えない。このことから、量子計算機において誤り訂正を実現するには、量子計算機専用の誤り訂正理論が必要となる。
本章は次のような構成になっている。まず、「古典エラー訂正」の節では古典計算機におけるエラーの仕組みと程度、そして古典エラー訂正の代表的な手法を概説する。「量子エラー」の節では、量子ビットのデバイスごとに生じるエラーの原因と定式化について説明する。特に、量子ビットの寿命に依存するエラーと、量子操作に際するバックアクションに関するエラーについて解説する。そして、古典エラー訂正で用いた線形符号を拡張した量子エラー訂正について学ぶ。さらに、スタビライザー符号・トポロジカル符号といった概念について学び、最後に実現に適していると言われている表面符号を紹介する。
9-1. 古典エラー¶
データ蓄積の方法は時間ともに初期化されてしまう揮発性のものと、原則としてほぼ劣化しない不揮発性のものに分類される。ここでは揮発性のメモリの一種であるDRAMのメモリセルを考える。 現代の計算機は0,1のバイナリ情報を、コンデンサに蓄えらえれた電子の量が多いか少ないかというアナログな連続量で表現する。(通信ならある周波数帯や時間区間に含まれる光子の数で表現する。) ここではエネルギーが蓄えられた状態を1、エネルギーが完全に放出された状態を0とする。
こうしたアナログな量は、外部との相互作用により常に一定値にいることはない。電子は時間とともにトランジスタのリーク電流などによってあるレートで放出される。 従って、1の状態はしばらくたつと0の状態に変化してしまう。この変化は現代のデバイスではおよそ秒未満の無視できないスケールで起きる。 これは「操作の有無に寄らず時間の変化によって一定レートで起きるエラー」である。 こうしたエラーは古典デバイスでは「0,1が判定不能になるより十分前に、一定時間おきに残電子量を読み出し、その数に応じて充電しなおす」ことで訂正されている。 この操作はリフレッシュと呼ばれ、ユーザが意図せずとも定期的に行われる。当然、電源が落ちるとリフレッシュは行われなくなる。このことを指しDRAMを揮発メモリと呼ぶ。
リフレッシュが行われリークによる影響が無視できるとき、次に重要となるエラーの要因は中性子線によるトランジスタの誤動作である。 中性子線は宇宙線の一種であり、金属などを貫通するために防護が困難である。この中性子線がトランジスタを通過するとき、生じた電荷がトランジスタを誤動作させることがある。 従って、中性子線エラーは「何らかの操作がトリガーとなり、単発的に起きるエラー」である。 読み出しタイミングでないときトランジスタが動作しゲートが解放されると、コンデンサの状態はbit lineの状態に合わせて変化し意図しないものになってしまう。 このエラーによってビットが反転される確率は一般のユーザには無視できるほど小さいが、規模が非常に大きな計算では無視できなくなる。 仮に単位ビット当たりに単位時間で中性子線が衝突し誤動作する確率を\(p\)とすると、\(n\)ビットのメモリが\(t\)秒間の間1ビットも変化しない確率は \(q = (1-p)^{nt}\)である。\(n=10^{12}, t=10^{3}\)とし、\(q=0.99\)を実現するためには、\(p\sim 10^{-18}\)でなければならない。
中性子線エラーとリークとの大きな違いは、リークはすぐさま確認すれば復元が可能であるものの、中性子線は生じた後にはもとはどの状態であったか知ることができない点にある。 このため、中性子線エラーが問題となる領域での応用では、誤り訂正機能の付いたECC (Error-correcting code) メモリが利用される。ECCメモリでは後述の誤り訂正を行い、1bitまでのエラーを小さなオーバーヘッドで訂正する。
古典エラー訂正: 多数決¶
最も単純な符号は多数決である。多数決では個々のビットを\(d\)倍にコピーする。この時、\(k\)ビットの情報を\(n:=dk\)ビットで表現することになる。多数決の引き分けを防ぐため、\(d\)は奇数とする。 現実に存在する\(n\)ビットのことを物理ビット、実態として表現している\(k\)ビットのことを論理ビットと呼ぶ。
多数決では以下のように誤りの検知と訂正を行う。個々の論理ビットについて複製した\(d\)ビットの情報を読み出し、0,1のどちらが多いかを数える。そして、頻度が高かった値がその論理ビットの値であったと判定する。初期の値がどうあれ、符号化された状態は全ての値が同じであるから、\(d\)ビットの値が一つでも一致していない場合、何らかのエラーが生じていることが確信できる。従って\(n\)ビットが丸ごと反転しない限り必ず誤りを検知することができる。
誤りを訂正したい場合は半数以上のビットが正しい値を保持していれば正しい結果を得ることができる。それぞれのビットに1より十分小さい確率\(p\)でエラーが生じるとすると、半数以上のビットがエラーを起こす確率はおよそ\(p^{\lfloor d/2 \rfloor+1}\)である。 従って、\(d\)を2増やすごとに、多数決が失敗する確率はオーダーで\(p\)倍小さくなることがわかる。 最も単純な\(k=1\)の場合をシミュレートするコードを書くと以下のようになる。
[1]:
import numpy as np
data = 1
d = 31
p = 0.01
print("original bit: {}".format(data))
state = np.repeat(data, d)
is_flipped = (np.random.rand(d)<p).astype(np.int)
state = (state + is_flipped)%2
if np.sum(state==0) < np.sum(state==1):
majority = 1
else:
majority = 0
print("decoded bit: {}".format(majority))
original bit: 1
decoded bit: 1
上記はランダムに成功不成功が変わるが、成功する確率は確率\(p\)に関する二項分布の累積として知ることができる。 横軸を各ビットのエラー確率、縦軸を復号の成功確率として、様々な奇数の\(d\)についてプロットすると以下のようになる。
[3]:
import numpy as np
d = 31
fail_prob_list = []
p_list = np.linspace(0,1,21)
for p in p_list:
fail_prob = []
binomial = [1]
for m in np.arange(2,d+2):
nbin = np.zeros(m)
for i in range(m):
if i!=0:
nbin[i] += binomial[i-1]*p
if i+1<m:
nbin[i] += binomial[i]*(1-p)
binomial = nbin
if m%2==0:
fail = np.sum(binomial[m//2:])
fail_prob.append(fail)
fail_prob_list.append(fail_prob)
fail_prob_list = np.array(fail_prob_list).T
import matplotlib.pyplot as plt
plt.figure(figsize=(12,8))
plt.subplot(1,2,1)
for index,line in enumerate(fail_prob_list[:6]):
plt.plot(p_list, line, label = "n={}".format(2*index+1))
plt.ylim(0,1)
plt.xlim(0,1)
plt.xlabel("Error probability per each bit")
plt.ylabel("Failure probability")
plt.legend()
plt.subplot(1,2,2)
for index,line in enumerate(fail_prob_list.T[:7]):
if index==0:
continue
distance_list = np.arange((d+1)//2)*2+1
plt.plot(distance_list,line, label = "p={:.3}".format(p_list[index]))
plt.ylim(1e-5,0.5)
plt.yscale("log")
plt.xlabel("distance")
plt.ylabel("Failure probability")
plt.legend()
plt.show()

左のグラフから\(p\)がある一定値(今回は0.5)以下であれば、\(d\)を大きくすればするほど性能が良くなることがわかる。 一方、\(p\)がある値以上の場合、\(d\)を増やすとよくなるどころかむしろ性能が悪化してしまう。 このことから、多数決で誤り訂正を行うには、少なくとも各デバイスがある誤り率以下であることが必要条件となることがわかる。
このふるまいは量子符号を含むスケールする符号にたびたび見られる性質であり、このしきいとなるエラー確率のことをエラーしきい値(error threshold)と呼ぶ。一般の古典誤り訂正はエラーしきい値よりはるかに小さい値で行われるが、量子誤り訂正では現状のエラーがこのしきい値付近であるため、たびたび言及される。
右のグラフは\(p\)をしきい値以下の一定値に固定し、\(d\)を大きくしたときにどの程度復号の失敗確率が減少するかを指数プロットしたものである。\(p\)がしきい値より十分小さい場合、\(d\)を大きくすることで概ね指数的に復号の失敗確率が減少していくことがわかる。
線形符号¶
上記の多数決の枠組みは線形符号という符号の一種である。下記では線形符号の枠組みで多数決を解説する。 ある\(k\)ビットの情報\(v\)を\(n=dk\)ビットの情報\(v'\)に冗長化する操作は、\(k \times n\)の行列\(G\)を以下のように構成し、\(v' = vG\)という計算を行うと言い換えることができる。 (Nielsen-Chuangの 10.4.1 Classical linear codes
も参照)
[5]:
import numpy as np
k = 4
m = 3
n = k*m
v = np.random.randint(2,size=k)
print("original vector: v\n{}".format(v))
G = np.zeros((k,n),dtype=np.int)
for y in range(k):
G[y,y*m:(y+1)*m]=1
print("generator matrix: G\n{}".format(G))
vd = (v@G)%2
print("encoded vector: v' = vG\n{}".format(vd))
original vector: v
[0 1 0 1]
generator matrix: G
[[1 1 1 0 0 0 0 0 0 0 0 0]
[0 0 0 1 1 1 0 0 0 0 0 0]
[0 0 0 0 0 0 1 1 1 0 0 0]
[0 0 0 0 0 0 0 0 0 1 1 1]]
encoded vector: v' = vG
[0 0 0 1 1 1 0 0 0 1 1 1]
この行列\(G\)は生成行列という。
生成される\(2^n\)パターンの\(n\)ビット列のうち、\(vG\)の形で作られる高々\(2^k\)個の\(n\)ビット列を符号語と呼ぶ。符号語の集合を\(W\)としたとき、\(w \neq w'\)となる二つの符号語のハミング距離の最小値を符号の距離(distance)と呼ぶ。今の多数決の構成では、符号の距離は個々の論理ビットを複製する数\(d\)である。距離\(d\)以上の数のビットが一斉に反転してしまうとある符号語から別の符号語に変化してしまうため、現在の符号が正しく生成されたのか、他の符号にエラーが載ったものなのか区別がつかなくなる。このことから、エラーが生じるビット数が距離\(d\)未満であることがエラーが検知できる条件であることが分かる。
次に、mod 2のもとで\(GH_c = 0\)となり、かつ各行ベクトルが独立になるような\(n \times (n-k)\)行列\(H_c\)を考える。今回の場合、3ビットごとに二つの隣接したパリティを見る以下のような\(H_c\)が条件を満たす。
[6]:
Hc = np.zeros((n,n-k),dtype=np.int)
for x in range(n-k):
Hc[x//2*m+x%2:x//2*m+x%2+2,x]=1
print("check matrix: H\n{}".format(Hc))
print("G Hc = \n{}".format( (G@Hc)%2))
check matrix: H
[[1 0 0 0 0 0 0 0]
[1 1 0 0 0 0 0 0]
[0 1 0 0 0 0 0 0]
[0 0 1 0 0 0 0 0]
[0 0 1 1 0 0 0 0]
[0 0 0 1 0 0 0 0]
[0 0 0 0 1 0 0 0]
[0 0 0 0 1 1 0 0]
[0 0 0 0 0 1 0 0]
[0 0 0 0 0 0 1 0]
[0 0 0 0 0 0 1 1]
[0 0 0 0 0 0 0 1]]
G Hc =
[[0 0 0 0 0 0 0 0]
[0 0 0 0 0 0 0 0]
[0 0 0 0 0 0 0 0]
[0 0 0 0 0 0 0 0]]
\(H_c\)の列ベクトルは独立であるため、\(2^n\)パターンの\(n\)ビット列\(w\)のうち、\(w H_c = 0\)を満たすパターンは\(2^n / 2^{n-k} = 2^k\)個である。また、符号語の集合\(W\)に属する符号語\(w\)は、\(w H_c = v G H_c = 0\)である。したがって、符号語の集合\(W\)とは、\(w H_c = 0\)を満たすビット列の集合と言い換えることもできる。同時に\(n\)ビット列\(w\)が符号語ではない\((w \neq W)\)ことは、\(w H_c \neq 0\)であることに等しい。この符号語に属している(\(w \in W\))ときかつその時に限り\(0\)になるベクトル\(s = w H_c\)のことを\(w\)の シンドローム と呼ぶ。
まとめると、符号語の集合\(W\)は二つの行列\(G,H_c\)のいずれでも特徴づけることができ、
である。
符号化した後にエラー\(e\)が生じた場合、エラーが起きた後の符号の状態は\(v'+e\)となった状態である。 この時、\(v'+e\)のシンドローム値\(s = (v'+e)H_c\)がすべて0であるかどうかを調べることで、エラーの検出を行える。 シンドローム値が一つでも0でない場合、何らかのエラーが生じている。シンドローム値がすべて0の場合、エラーは生じていないか、検知不可能な\(d\)ビット以上のエラーが生じていることがわかる。
[7]:
p = 0.05
print("encoded vector: v'\n{}".format(vd))
e = np.random.choice([0,1],size=len(vd),p = [1-p,p])
print("error vector: e\n{}".format(e))
vde = (vd+e)%2
print("encoded vector with noise: v'+e\n{}".format(vde))
s = (vde@Hc)%2
print("syndrome values: s = (v'+e)Hc\n{}".format(s))
encoded vector: v'
[0 0 0 1 1 1 0 0 0 1 1 1]
error vector: e
[0 0 0 0 0 0 0 0 0 0 0 0]
encoded vector with noise: v'+e
[0 0 0 1 1 1 0 0 0 1 1 1]
syndrome values: s = (v'+e)Hc
[0 0 0 0 0 0 0 0]
\(GH_c=0\)という性質から、\(s =(v'+e)H_c = vGH_c + eH_c = eH_c\)となる。つまり、シンドローム値は初期状態のベクトル\(v\)と独立であることが分かる。
\(n\)ビットの多数決は\(v'+e\)を左から\(m\)個ずつ見て多数決を行えばよい。
[8]:
p = 0.1
print("encoded vector: v'\n{}".format(vd))
e = np.random.choice([0,1],size=len(vd),p = [1-p,p])
print("error vector: e\n{}".format(e))
vde = (vd+e)%2
print("encoded vector with noise: v'+e\n{}".format(vde))
s = (vde@Hc)%2
print("syndrome values: s = (v'+e)Hc\n{}".format(s))
def decode(vde,k,m):
for x in range(k):
subset = vde[x*m:(x+1)*m]
val = np.argmax(np.bincount(subset))
vde[x*m:(x+1)*m] = val
return vde
vde_recovery = decode(vde,k,m)
print("recovered vector: \n{}".format(vde_recovery))
encoded vector: v'
[0 0 0 1 1 1 0 0 0 1 1 1]
error vector: e
[0 0 0 0 0 0 0 0 0 0 0 1]
encoded vector with noise: v'+e
[0 0 0 1 1 1 0 0 0 1 1 0]
syndrome values: s = (v'+e)Hc
[0 0 0 0 0 0 0 1]
recovered vector:
[0 0 0 1 1 1 0 0 0 1 1 1]
上記の復号はエラーを大きくしていくと、\(m\)の半数以上が間違えて訂正に失敗することがあることが分かる。
\(e H_c=0\)となる\(0\)でないベクトル\(e\)が生じた場合、そのようなエラーは訂正することができない。すなわち、符号の距離\(d\)は検査行列\(H_c\)に対して
という値だと考えることもできる。(ここで \(w(x)\) はビット列 \(x\) のウェイト、つまり \(0\cdots0\) とのハミング距離である)
水平垂直パリティ検査符号¶
現実の誤り訂正では多数決の手法はあまり性能が良くないためそれほど使われない。ここでは現実の誤り訂正でよく使われるパリティ検査符号の一例として、水平垂直パリティ検査符号の枠組みを学ぶ。
パリティとは、与えられた複数のビットの和を2で割った余り、すなわち1の数の偶奇を意味する。(1,1,1)のパリティは1、(0,1,1)のパリティは0である。 パリティ検査符号は、元の情報のデータについてのいくつかのパリティを保持することで誤りの検知や訂正を可能にする。 元の情報やパリティにエラーが生じた場合、パリティの値と元の情報に整合性が保たれなくなるためエラーを検出することができる。
最もシンプルなパリティ検査符号はチェックサムである。チェックサムは小さなブロック\((x_1,\ldots, x_b)\)ごとに、ブロックに含まれるビット列のパリティ\(p= \sum_i x_i \bmod 2\)をチェックサムとして記入しておく。 もし、伝送の途中でブロック中のビットやチェックサムのビットの記入を高々1bit誤った場合、ブロックに含まれるビット列のパリティを再計算すると保持するパリティ\(p\)と整合性が取れなくなる。 従って、ブロックかパリティのどこかにビットエラーが生じたことがわかる。ただし、この方法はどこでエラーが生じたかを知ることはできないし、2ビットエラーが生じた場合はエラーを検知することもできない。
[9]:
import numpy as np
length = 16
p = 0.05
# set random bitarray
bitstring = np.random.randint(2,size = length)
checksum = np.sum(bitstring)%2
print("original bitarray: {}". format(bitstring))
print("checksum: {}".format(checksum))
# error occurs
error = np.random.choice([0,1], size = length, p = [1-p, p])
print("error: {}". format(error))
bitstring = (bitstring + error)%2
print("noisy bitarray: {}". format(bitstring))
new_checksum = np.sum(bitstring)%2
print("checksum: {}".format(new_checksum))
# verify checksum
if checksum == new_checksum:
print("No error detected")
else:
print("Error detected!")
original bitarray: [0 1 0 1 1 1 1 1 0 1 1 1 0 0 0 0]
checksum: 1
error: [0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1]
noisy bitarray: [0 1 0 1 1 1 1 1 0 1 1 1 0 0 0 1]
checksum: 0
Error detected!
1ビットのビットエラーの検知だけでなく訂正を行うには、どこでエラーが生じたかを分かるようにしなければいけない。 これを実現するナイーブな手法は、水平垂直パリティ検査という手法である。 保護する対象を\(k=16\)ビットをベクトルとし、これを\(4*4\)の行列に配置する。 ここで、各行のベクトルのパリティと各列のベクトルのパリティを\(8\)ビット保存しておく。 この場合、もし\(k\)ビットの中でエラーが生じると、一つの列と行のパリティと整合が取れなくなる。この場合、整合が取れない列と行に一致するデータビットが反転したと判定する。 もし、行のパリティビットにエラーが生じた場合、列のパリティは全て整合が取れているはずである。このように一方のパリティだけにエラーがある場合、パリティが反転したと結論付ける。列のエラーも同様である。 こうすることで、どの場所でビットエラーが生じてもそれが1ビットであれば原因を特定できる。このコードの距離は3ビットであり\([n,k,d] = [24,16,3]\)である。一般には\([w^2+2w,w^2,3]\)である。(ここで、\([n,k,d]\)は符号の特徴を示す3つ組の数字であり、\(n\)は使用するビット数、\(k\)は符号化できるビット数、\(d\)は符号の距離である)
[10]:
import numpy as np
width = 4
height = 4
size = width*height
p = 0.03
def show(bitmatrix, vertical_parity, horizontal_parity):
for y in range(height):
print("{} | {}".format(bitmatrix[y,:], horizontal_parity[y] ))
print("-"*width*2)
print("{}".format(vertical_parity ))
print()
# set random bitarray
bitstring = np.random.randint(2,size=size)
print("original bitarray: {}". format(bitstring))
# check horizontal and vertical parity
bitmatrix = bitstring.reshape( (height,width) )
vertical_parity = np.sum(bitmatrix, axis = 0)%2
horizontal_parity = np.sum(bitmatrix, axis = 1)%2
print("stored as : ")
show(bitmatrix,vertical_parity, horizontal_parity)
# error occurs
encoded_size = size + width + height
error = np.random.choice([0,1], size = encoded_size, p = [1-p, p])
bitmatrix = (bitmatrix + error[:size].reshape((height,width)))%2
vertical_parity = (vertical_parity + error[size:size+width])%2
horziontal_parity = (vertical_parity + error[size+width:])%2
# result
print("result is : ")
show(bitmatrix,vertical_parity, horizontal_parity)
# verify checksum
result_vertical_parity = np.sum(bitmatrix, axis = 0)%2
result_horizontal_parity = np.sum(bitmatrix, axis = 1)%2
vertical_flip_count = np.sum((result_vertical_parity + vertical_parity)%2)
horizontal_flip_count = np.sum((result_horizontal_parity + horizontal_parity)%2)
if vertical_flip_count == 0 and horizontal_flip_count == 0:
print("No error detected")
print("decoded bitarray: ")
show(bitmatrix, result_vertical_parity, result_horizontal_parity)
elif vertical_flip_count == 1 and horizontal_flip_count == 0:
print("Error occurs on vertical parity")
print("decoded bitarray: ")
show(bitmatrix, result_vertical_parity, result_horizontal_parity)
elif vertical_flip_count == 0 and horizontal_flip_count == 1:
print("Error occurs on horizontal parity")
print("decoded bitarray: ")
show(bitmatrix, result_vertical_parity, result_horizontal_parity)
elif vertical_flip_count == 1 and horizontal_flip_count == 1:
print("Error occurs on data bit")
print("decoded bitarray: ")
x = np.argmax((result_vertical_parity + vertical_parity)%2)
y = np.argmax((result_horizontal_parity + horizontal_parity)%2)
bitmatrix[y,x] = (bitmatrix[y,x]+1)%2
show(bitmatrix, vertical_parity, horizontal_parity)
else:
print("Too many error occurs")
original bitarray: [1 0 0 1 0 0 0 1 0 0 1 1 1 0 0 0]
stored as :
[1 0 0 1] | 0
[0 0 0 1] | 1
[0 0 1 1] | 0
[1 0 0 0] | 1
--------
[0 0 1 1]
result is :
[1 1 0 1] | 0
[0 0 0 1] | 1
[0 1 1 1] | 0
[1 0 0 0] | 1
--------
[0 0 1 1]
Too many error occurs
水平垂直パリティ検査符号も多数決と同様に生成行列と検査行列を考えることができる。 簡単のために、\(2 \times 3\)の6ビットに対する水平垂直パリティ検査の場合を例に考える。 水平垂直パリティ検査で符号化するビットの横ベクトルを\(v\)とする。この時、\((1,2,3)\)番目のパリティを観測する計算は、\(1,2,3\)番目のみが1で他が0となっているベクトル\(w = (1,1,1,0,0,0)\)を用意し、ベクトル間の内積\(v w^{\rm T} \bmod 2\)を計算することに等しい。従って、元のデータに追加されるパリティビットは以下のような行列\(A\)をmod 2のもとで作用することで生成できる。なお、以降のバイナリビット間の演算では特に断りが無い限りはmod 2を省略する。
[11]:
A = np.array([
[1 , 0 , 1 , 0 , 0],
[1 , 0 , 0 , 1 , 0],
[1 , 0 , 0 , 0 , 1],
[0 , 1 , 1 , 0 , 0],
[0 , 1 , 0 , 1 , 0],
[0 , 1 , 0 , 0 , 1]
])
print(A)
[[1 0 1 0 0]
[1 0 0 1 0]
[1 0 0 0 1]
[0 1 1 0 0]
[0 1 0 1 0]
[0 1 0 0 1]]
この時、パリティの値を並べたベクトル\(p\)は\(p = vA\)であり、符号化後のバイナリベクトルは\(v' = (v,p)\)である。従って生成行列として\(k \times n\)行列\(G\)を\(G = (I, A)\)と置けば、\(v' = v G\)という形にできる。
符号化したバイナリベクトルにはエラー\(e\)が生じる。この時、\(e\)のうちデータ部に生じたエラーを\(e_v\)、パリティ部に生じたエラーを\(e_p\)とする。エラーが生じた後のバイナリベクトルは\(v'+e = (v+e_v, p+e_p)\)となる。水平垂直パリティ検査では、エラーが生じた後にパリティ値が一致しているかどうかをもとにエラーが生じたかを判断する。このことを式で書くと、
が成り立つか否かが、エラーを検知するかどうかの判定に等しい。\(p=vA\)を用いて上記の等式を整理すると、エラーが検知されない条件は
である。行列\(H_c\)を\(H_c = \left( \begin{matrix} A \\ I \end{matrix} \right)\)と置くと、
である。
[12]:
G = np.hstack( (np.eye(A.shape[0]), A) )
Hc = np.vstack( (A, np.eye(A.shape[1])) )
print("generator matrix: G\n{}\n".format(G))
print("check matrix: Hc \n{}\n".format(Hc))
print("GHc = \n{}\n".format( (G@Hc)%2 ))
generator matrix: G
[[1. 0. 0. 0. 0. 0. 1. 0. 1. 0. 0.]
[0. 1. 0. 0. 0. 0. 1. 0. 0. 1. 0.]
[0. 0. 1. 0. 0. 0. 1. 0. 0. 0. 1.]
[0. 0. 0. 1. 0. 0. 0. 1. 1. 0. 0.]
[0. 0. 0. 0. 1. 0. 0. 1. 0. 1. 0.]
[0. 0. 0. 0. 0. 1. 0. 1. 0. 0. 1.]]
check matrix: Hc
[[1. 0. 1. 0. 0.]
[1. 0. 0. 1. 0.]
[1. 0. 0. 0. 1.]
[0. 1. 1. 0. 0.]
[0. 1. 0. 1. 0.]
[0. 1. 0. 0. 1.]
[1. 0. 0. 0. 0.]
[0. 1. 0. 0. 0.]
[0. 0. 1. 0. 0.]
[0. 0. 0. 1. 0.]
[0. 0. 0. 0. 1.]]
GHc =
[[0. 0. 0. 0. 0.]
[0. 0. 0. 0. 0.]
[0. 0. 0. 0. 0.]
[0. 0. 0. 0. 0.]
[0. 0. 0. 0. 0.]
[0. 0. 0. 0. 0.]]
数値的にも確かに\(GH_c = 0\)となることが分かる。\(H_c\)は\(n \times (n-k)\)行列で、かつ列ベクトルは単位行列の部分行列を持つことから明らかに列ごとに独立であり、確かに検査行列の性質を満たしている。
このとき、シンドローム値\(s\)は\(s = e H_c\)となる。シンドローム値が全て0の時、符号はエラーが生じていないと判定する。もちろん、大きなエラーが生じている場合、エラーが生じているにもかかわらず\(s=0\)となりエラーが無いと判定されることもある。
\(s \neq 0\)である場合、何らかのエラーが起きていることはわかる。この状態でエラーを訂正するには、\((v'+e)\)の値から符号化前の\(v\)を推測しなければならない。今回の場合、冒頭のコードで示したような方法でエラーが一つまでならデータビットとパリティビットのどこにエラーがあるのかを\(O(1)\)で特定することができる。
パリティ検査符号の枠組みをまとめると以下のようになる。
符号:生成行列\(G\)と検査行列\(H_c\)で特徴づけられる。これらは\(GH_c = 0\)を満たす。
符号化前:\(k\)-bitの横ベクトルデータ \(v\) がある。
符号化: \(k \times n\)の生成行列\(G\)を用いて、符号化した状態\(v'\)は\(v'=vG\)となる。
エラー: 符号化後の\(v'\)にランダムに\(n\)-bit列 \(e\) が足される。
エラー検査: \(n \times (n-k)\)の検査行列\(H_c\)を用いてシンドローム\(s = e H_c\)が得られる。\(s \neq 0\)ならばエラーがあると検知できる。\(s=0\)ならば、エラーが生じていないか、検査できる限界以上のエラーが生じている。
エラー訂正: \((v'+e)\)の値に従って、\(k\)-bitの横ベクトルデータ \(v\) を推測する。\(v'+e\)から\(v\)を求めるアルゴリズムを復号アルゴリズムと呼ぶ。
距離: \(e H_c = 0\)となる\(e\)の最小weightが符号の距離\(d\)である。
パリティ検査符号は非常に広い枠組みであり、例えばECCメモリではハミング符号と呼ばれるパリティ検査行列の一種が用いられている。
一般の線形符号のケース¶
上記では多数決、チェックサム、水平垂直検査という特定の例を挙げたが、実際に使用する際には目的に対し最も性能の良い符号がほしい。しかし、一般に与えられた\((n,k,d)\)に対して最適な線形符号\(G,H_c\)を求めることは組み合わせ論の問題となり、一般に困難である。
また、仮に最適な\((G,H_c)\)という符号を与えられても復号に関して問題がある。ナイーブな復号アルゴリズムの例は、シンドローム\(s\)を観測した時、\(s = e'H_c\)を満たすような\(e'\)のうち、最も少ないビットにエラーを起こすものを起きたエラーとみなし、\((v' + e + e')\)が正しい符号語になると期待して復元を試みる最小距離復号である。上記の最適化は
という\(n\)-bitの0,1計画問題となるため、最小距離復号は一般には符号のサイズに対してNP困難である。しかも、この復号は実際には最適な復号とと一致するとは限らない。一方、符号や起きるエラーの種類を限定すると効率的に最小距離復号が実施でき、かつ多くの場合この復号方法は最適に近い復号である。例えば多数決は常に最小距離復号が可能であり、水平垂直パリティ検査符号では生じるエラーを \(w(e)=1\) に限定すれば\(O(1)\)で最小距離復号が可能である。
線形符号上での演算¶
ノイジーな通信路に情報を通す場合はパリティ検査符号で符号化された情報を転送するだけである場合でよい。しかし、計算機自体がノイジーな場合、パリティ検査符号で符号化した状態で計算を行わなければならない。例えば、CPU自体がノイジーであり、符号化した状態のままで計算を行いたい場合は、計算のたびに復号してしまっては符号化の意味がない。
上記の水平垂直パリティ検査を用いる場合、行列の\((i,j)\)要素に対するビット反転操作を行うとすると、この操作がエラーとして検知されないようにするには関連するパリティの値も更新しなくてはいけない。つまり、\(1\)ビットを更新するためには、符号化されたビット列に対して\(3\)ビットの操作が必要となる。
操作のコストを考えると、ある論理ビットを操作するときに実際に操作せねばならないビットの数は少なければ少ないほど良い。 論理ビットの反転のために実際に反転しなければいけないビットの数は高々\(\max_i w(G_i)\)である。ただし、\(G_i\)は\(G\)の\(i\)行ベクトルである。水平垂直パリティ検査では\(G\)の行のweightはすべて3になっている。
低密度パリティ検査符号¶
宇宙との通信など通信できる時間が限られていたり通信路が劣悪にならざるを得ない場合、できるだけ限界に近いレートで大量のデータを送りたい。このことが、符号化や復号にかかる計算コストは大きくとも良いので、符号化するビット数あたりの論理誤り率が最も小さくなる符号を作るモチベーションとなる。
通信路のノイズが既知である場合、ノイズのある通信路で送信可能な情報量のレートの限界はシャノン限界として計算できる。このレートは構成することができる符号の性能の限界を意味する。低密度パリティ検査符号はこのシャノン限界に近い性能を実現する符号である。
低密度パリティ検査符号では、検査行列\(H_c\)が疎行列となるパリティ検査符号の総称である。ここでいう疎行列とは、各列に含まれる\(1\)の数が高々\(O(k)\)程度であることを意味する。すなわち、個々のシンドローム値は\(O(k)\)個のビットのエラーのパリティで表現できるということである。
低密度パリティ検査符号の利点はその性能がシャノン限界に近い点である。一方、低密度パリティ検査符号の欠点は、復号の計算量的な困難さである。この困難を回避するため、信頼伝播法といった近似アルゴリズムが復号のためにしばしば用いられる。
信頼伝播法では、次のようなグラフを考える。符号化後のビットをノードとみなす。あるパリティ値があるデータビットのエラーをパリティの一部に含むとき、対応するパリティビットのノードからデータビットのノードへ無向エッジを張る。こうして、\(n\)頂点が\(nO(k)\)個の辺で繋がれたの無向グラフ\(G(V,E)\)が完成する。信頼伝播法はこのグラフ上での逐次的な最適化を状態が符号後となるまで繰り返す。 信頼伝播法の性能はグラフ\(G\)における最小のループが大きければ大きいほど良いことが知られている。したがって、そのようなグラフを構築するべく、様々な低密度パリティ検査符号の構築方法が提案されている。
9-2. 量子エラー¶
量子ビットに生じるエラーの根本的な要因自体は実は古典ビットとそれほど違いはない。
一つは、外部との環境の相互作用によって一定のレートで外部に情報が漏れ出てしまうことで生じるエラーである。 特に物質を量子ビットとして光やマイクロ波などの電磁波で情報を読み書きする場合、電磁波を注入する経路を確保せねばならず、そこから一定量の情報が漏れ出てしまう。 また、希釈冷凍機で実験をしていてもマイクロ波はエネルギースケールが環境温度と近いため、熱雑音の影響を大きく受けてしまい、これも定常的なノイズの原因となる。 一方、イオンや中性原子のようなトラップを用いて作成する物質の場合、デコヒーレンスに加えて物質がトラップから外れてしまうエラーも問題となる。 光を量子ビットとして用いた場合は、温度スケールの違いから上記のようなデコヒーレンスは問題とならない。 ただし、光量子ビットをスケールする上ではファイバによる誘導かミラー反射によるパスの確保が必要となり、またレーザの揺らぎも存在するため、 これにともなう光子の損失やコヒーレンス時間の経過によるコヒーレンスの消失が実効的には何もしないでも生じる量子ビットの寿命となる。
もう一つは、デバイスに対する操作に際して生じるノイズである。 超伝導量子ビットなどのマイクロ波を用いる操作の場合、操作に際するエラーはマイクロ波の周波数やパワーの揺らぎによるノイズ、また意図しない相互作用項の寄与によるバイアスなどが挙げられる。 こうしたノイズは反転などにより打ち消しが可能なバイアスノイズとそうでない確率的なノイズに分けられる。また、ノイズの結果も最終的に0,1のどちらかに移動してしまうノイズと、0,1のどちらでもない状態に移動してしまうleakageに分けられる。確率的なノイズやleakageのようなノイズはエラー訂正の観点からするとより深刻である。 こうしたエラーは原理的にはパルスのキャリブレーションやキャンセルパルスの印加で回避可能だが、膨大なビットのキャリブレーションは困難を極める。イオンや中性原子の場合も事情は同様である。 光を量子ビットとして用いる場合、光学結晶との相互作用を介して操作を行うが、この場合物質とはまた異なった問題が生じる。 第一に、光量子ビットをユニバーサルに操作するには3次以上の非線形性を有する相互作用が必須であり、こうした高次非線形性は強い吸収をともなうものが多い。 このため、非破壊に3次非線形操作を行うのをあきらめ、エンタングルした光量子ビットに破壊を伴う光子測定とフィードバック操作を行い、ゲートテレポーテーションという形で所望の非線形操作を実現することが多い。こうした操作は単なる非破壊操作に比べ大きなオーバーヘッドを伴う。 また、電磁波を用いた操作は、量子ビットは動かず電磁波が時間とともに送出されるため、システムのサイズは計算を行う時間に依存しないが、 光量子ビットを用いた通常の実験は物質を固定して光学定盤に配置するために、使用する光学機器の数は計算時間に依存して増えてしまう。 これを回避するためには、一度使用した光学装置をループを介して再度利用する時間多重化が必要となる。こうした光学系の時間多重化は現在盛んに研究がおこなわれている。
量子エラー訂正¶
量子ビットは通常のビットを拡張し、状態の重ね合わせを許したものである。量子エラー訂正では量子ビットを冗長化し、古典誤り訂正と同様に実効的に小さなエラー確率の獲得を目指す。この際、量子ビットの性質から多くの古典符号と事情の異なる点がいくつかあり、そのほとんどが量子誤り訂正を困難にする方向に働く。
現在提案されている量子エラー訂正の手法は、原則として線形符号を量子版へと拡張したものである。確認のため、線形符号の枠組みを再掲する。
符号:生成行列\(G\)と検査行列\(H_c\)で特徴づけられる。これらは\(GH_c = 0\)を満たす。
符号化前:\(k\)-bitの横ベクトルデータ \(v\) がある。
符号化: \(k \times n\)の生成行列\(G\)を用いて、符号化した状態\(v'\)は\(v'=vG\)となる。
エラー: 符号化後の\(v'\)にランダムに\(n\)-bit列 \(e\) が足される。
エラー検査: \(n \times (n-k)\)の検査行列\(H_c\)を用いてシンドローム\(s = e H_c\)が得られる。\(s \neq 0\)ならばエラーがあると検知できる。\(s=0\)ならば、エラーが生じていないか、検査できる限界以上のエラーが生じている。
エラー訂正: \((v'+e)\)の値に従って、\(k\)-bitの横ベクトルデータ \(v\) を推測する。\(v'+e\)から\(v\)を求めるアルゴリズムを復号アルゴリズムと呼ぶ。
距離: \(e H_c = 0\)となる\(e\)の最小weightが符号の距離\(d\)である。
量子ビットは\(|0\rangle, |1\rangle\)の二状態のみを用い、重ね合わせを利用しない場合、古典ビットの枠組みに乗せることができる。このことを利用し、上記を古典誤り訂正を量子情報の言葉で書きなおすと以下のようになる。
符号:生成行列\(G\)と検査行列\(H_c\)で特徴づけられる。これらは\(GH_c = 0\)を満たす。
符号化前:\(k\)-qubitの計算基底の量子状態 \(|v\rangle\) がある。
符号化: CNOTとPauli-\(X\)のみからなる\(2^n \times 2^n\)のユニタリ行列\(U_G\)で、\(|vG\rangle = U_G (|\psi\rangle \otimes |0\rangle^{n-k})\)となるものを\(G\)から構成できる。
エラー: 符号化後の\(|vG\rangle\)にランダムなビットフリップ演算\(E = X_1^{e_1} X_2 ^{e_2} \ldots X_n^{e_n}\)が作用し、\(E |vG \rangle = |vG + e \rangle\)となる。
エラー検査: パリティが整合するかを調べる。これは以下の操作に等しい。検査行列\(H_c\)の\(i\)列ベクトルを\(h_i\)とした時、パウリ行列\(P_i = Z_1^{h_{i1}} Z_2^{h_{i2}} \ldots Z_n^{h_{in}}\)というパウリ行列を考え、\(M_0^{(i)} = (I+P_i)/2, M_1^{(i)} = (I-P_i)/2\)というPOVM\(\{M_0^{(i)}, M_1^{(i)}\}\)を考える(POVMは射影測定を一般化したものである。Nielsen-Chuangだと
2.2.6 POVM measurements
)。状態\(E |\psi' \rangle\)を\(n-k\)個のPOVM\(\{\{M_0^{(i)}, M_1^{(i)}\}\}_i\)で測定し、\(n-k\)個のビット\(s = (vG+e)H_c = eH_c\)を得る。この\(s\)が0であるかを調べる。復号: \(|vG+e'\rangle\)を\(Z\)基底で全て測定すると、\(n\)ビット列\((vG+e)\)を得る。\((vG+e)\)の値に従って、\(R E|vG\rangle = |vG\rangle\)となるようなユニタリ操作\(R = X_1^{e'_1} X_2 ^{e'_2} \ldots X_n^{e'_n}\)を構成する。\((vG+e)\)から\(e'\)を求めるアルゴリズムを復号アルゴリズムと呼ぶ。
距離: \(s = 0\)となる\(E\)の最小weightが符号の距離\(d\)である。
上記のプロトコルは、量子ビットで古典誤り訂正を行う枠組みであり、言っていることは等価である。 例として、1bitを3bitに増やして多数決を行う符号を示す。この符号の生成行列は
であり、検査行列は
である。
[120]:
# set code info
repetition = 3
error_probability = 0.2
initial_bit = 1
[121]:
def show_quantum_state(state, eps = 1e-10, round_digit=3):
vector = state.get_vector()
state_str = ""
qubit_count = int( np.log2(len(vector)+eps))
binary_format = "{" + ":0{}b".format(qubit_count) + "}"
for ind in range(len(vector)):
if abs(vector[ind]) > 1e-10:
if len(state_str) > 0:
state_str += " + "
state_str += ("{} |" + binary_format + ">").format(round(vector[ind],round_digit ),ind)
print(state_str)
量子状態を確保し、初期状態を0-thビットに書き込む。
[ ]:
## Google Colaboratoryの場合・Qulacsがインストールされていないlocal環境の場合のみ実行してください
!pip install qulacs
[122]:
import numpy as np
from qulacs import QuantumState
state = QuantumState(repetition)
state.set_computational_basis(initial_bit)
show_quantum_state(state)
(1+0j) |001>
符号化の回路を作成する。
[123]:
from qulacs import QuantumCircuit
encode_circuit = QuantumCircuit(repetition)
for ind in range(1,repetition):
encode_circuit.add_CNOT_gate(0, ind)
encode_circuit.update_quantum_state(state)
show_quantum_state(state)
(1+0j) |111>
シンドローム値を測定する量子回路を作る。現地点では全てのシンドローム値は0である。
[124]:
from qulacs.gate import Instrument
from qulacs.gate import DenseMatrix
def parity_measure_gate(fst,scn,register_pos):
parity_measure_matrix_0 = np.zeros( (4,4) )
parity_measure_matrix_1 = np.zeros( (4,4) )
parity_measure_matrix_0[0,0] = parity_measure_matrix_0[3,3] = 1
parity_measure_matrix_1[1,1] = parity_measure_matrix_1[2,2] = 1
parity_measure_0 = DenseMatrix([fst,scn],parity_measure_matrix_0)
parity_measure_1 = DenseMatrix([fst,scn],parity_measure_matrix_1)
parity_measure = Instrument([parity_measure_0, parity_measure_1],register_pos)
return parity_measure
parity_measure_circuit = QuantumCircuit(repetition)
for ind in range(repetition-1):
parity_measure_circuit.add_gate(parity_measure_gate(ind,ind+1,ind))
parity_measure_circuit.update_quantum_state(state)
show_quantum_state(state)
for ind in range(repetition-1):
print("parity({},{}): {}".format(ind,ind+1,state.get_classical_value(ind)))
(1+0j) |111>
parity(0,1): 0
parity(1,2): 0
ランダムなデータ量子ビットに一つビットエラーを起こす。この時、ビットエラーが起きるとは意図しないPauli-\(X\)操作が入ってしまうことに相当する。量子状態は\(|vG+e\rangle\)となる。
[125]:
# invoke random error
from qulacs.gate import Pauli
def random_X_error(num_qubit, error_probability):
error_array = []
for _ in range(num_qubit):
if np.random.rand() < error_probability:
error_array.append(1)
else:
error_array.append(0)
return Pauli(np.arange(num_qubit), error_array)
error_operator = random_X_error(repetition, error_probability)
error_operator.update_quantum_state(state)
show_quantum_state(state)
(1+0j) |011>
再度パリティ測定を回収する。
[126]:
parity_measure_circuit.update_quantum_state(state)
show_quantum_state(state)
for ind in range(repetition-1):
print("parity({},{}): {}".format(ind,ind+1,state.get_classical_value(ind)))
(1+0j) |011>
parity(0,1): 0
parity(1,2): 1
復号を行う。
[135]:
# decoding process
def compute_recovery_operation(state, repetition):
cand1 = []
cand2 = []
cand1.append(0)
cand2.append(1)
flag = True
for ind in range(repetition-1):
val = state.get_classical_value(ind)
if val == 1:
flag = not flag
if flag:
cand1.append(0)
cand2.append(1)
else:
cand1.append(1)
cand2.append(0)
if np.sum(cand1) < np.sum(cand2):
cand = cand1
else:
cand = cand2
return Pauli(np.arange(repetition), cand)
recovery_operation = compute_recovery_operation(state,repetition)
recovery_operation.update_quantum_state(state)
show_quantum_state(state)
(1+0j) |111>
一連の操作は最初の量子状態が計算基底でなくても行うことができる。
[146]:
# set code info
repetition = 3
error_probability = 0.2
initial_bit = 1
from qulacs.gate import RandomUnitary
state = QuantumState(repetition)
state.set_computational_basis(0)
RandomUnitary([0]).update_quantum_state(state)
print("initial state")
show_quantum_state(state)
encode_circuit.update_quantum_state(state)
print("encode")
show_quantum_state(state)
error_operator = random_X_error(repetition, error_probability)
error_operator.update_quantum_state(state)
print("error")
show_quantum_state(state)
parity_measure_circuit.update_quantum_state(state)
print("parity measurement")
show_quantum_state(state)
recovery_operation = compute_recovery_operation(state,repetition)
recovery_operation.update_quantum_state(state)
print("recovery")
show_quantum_state(state)
initial state
(-0.111+0.987j) |000> + (0.053-0.101j) |001>
encode
(-0.111+0.987j) |000> + (0.053-0.101j) |111>
error
(-0.111+0.987j) |010> + (0.053-0.101j) |101>
parity measurement
(-0.111+0.987j) |010> + (0.053-0.101j) |101>
recovery
(-0.111+0.987j) |000> + (0.053-0.101j) |111>
量子誤り訂正とは、量子状態にビットフリップ(Pauli-\(X\))だけではなく、位相フリップ(Pauli-\(Z\))のエラーが起きても誤り訂正が可能な枠組ということができる。生じるエラーが確率的なパウリ演算子であると仮定した場合の、量子誤り訂正の枠組みは以下である。
符号:\(n\)-qubitに作用する生成ユニタリ \(U_G\) と\(n-k\)個のPOVMの集合\(\{(M_0^{(i)}, M_1^{(i)})\}_i\)で特徴づけられる。これらは任意の\(k\)-qubit状態 \(|\psi\rangle\) に対して、\(\langle \psi,0 | U_G^{\dagger} M_j^{(i)} U_G | \psi,0 \rangle\)を満たす。
符号化前:\(k\)-qubitの計算基底の量子状態 \(|\psi\rangle\) がある。
符号化: ユニタリ行列\(U_G\)で、\(|\psi' \rangle = U_G (|\psi\rangle \otimes |0\rangle^{n-k})\)を生成する。
エラー: 符号化後の\(|\psi' \rangle\)にランダムなパウリ演算\(E = X_1^{e_1} X_2 ^{e_2} \ldots X_n^{e_n} Z_1^{e_{n+1}} Z_2 ^{e_{n+2}} \ldots Z_n^{e_{2n}}\)が作用し、\(E |\psi' \rangle\)となる。
エラー検査: 状態\(E |\psi' \rangle\)を\(n-k\)個のPOVM\(\{\{M_0^{(i)}, M_1^{(i)}\}\}_i\)で測定し、\(n-k\)個のビット\(s\)を得る。この\(s\)が0であるかを調べる。
復号: \(s\)の値に従って、\(R E|vG\rangle = |vG\rangle\)となるようなユニタリ操作\(R = X_1^{e'_1} X_2 ^{e'_2} \ldots X_n^{e'_n} Z_1^{e'_{n+1}} Z_2 ^{e'_{n+2}} \ldots Z_n^{e'_{2n}}\)を構成する。\(s\)から\(R\)を求めるアルゴリズムを量子誤り訂正の復号アルゴリズムと呼ぶ。
距離: \(s = 0\)となる\(E\)の最小weightが符号の距離\(d\)である。
古典誤り訂正から量子誤り訂正の枠組みを構成するにあたっての本質的な変更点がある。 量子誤り訂正では位相反転を生じるエラーが発生する。このため、エラーのパウリ演算子\(E\)はPauli-\(X\)のみでなく、Pauli-\(Z\)を含む形になっている。 この変更は、符号が位相反転に対しても誤り訂正が可能であることを要請する。符号はPauli-\(Z\)のエラーに対しても\(1\)より大きな距離を持たなければならない。古典符号を量子符号の枠組みに直したものはPauli-\(Z\)に対して\(d=1\)の符号である。また、一方の符号によるシンドローム測定が\(k\)-qubitの量子状態を破壊してはならない。このことは、いかなる符号化された状態も \((n-k)\) 個のPOVM測定では区別がつかないということに相当する。従って、全てのPOVMの要素は任意の\(k\)-qubitを符号化した状態 \(|\psi'\rangle = U_B (|\psi\rangle \otimes |0\rangle^{n-k})\) に対して\(\langle \psi' |M_j^{(i)} | \psi' \rangle\)が\(|\psi'\rangle\)によらない値である必要がある。
後述のスタビライザー符号は上記を満たすような\(U_B\)とPOVMを与える枠組みである。
最後に、古典符号では復号にあたって全ての量子状態を\(Z\)基底で測定し\((vG+e)\)を得ることができたが、量子符号では量子状態の\(Z\)基底の測定は情報を内部に埋め込んだ情報を破壊するためこうした測定は行えない。また、訂正に用いる操作もPauli-\(X\)と\(Z\)が混ざったパウリ演算子になっている。したがって、得られたシンドローム値\(s\)のみから復号操作\(R\)を推定するアルゴリズムも量子のための変更が必要になる。
スタビライザー符号¶
パリティ検査符号においてパリティを構成する行列\(H_c\)が与えられたように、量子符号においてはPOVMを構成するスタビライザー符号という枠組みがある。 スタビライザー符号とは、符号空間を直接指定する代わりにスタビライザー演算子という特定の性質を満たすパウリ演算子の集合を用いて符号空間を指定するものである。
スタビライザー演算子の生成元\(S\)は、以下のような性質を満たすパウリ演算子の集合\(S\)である。
\(S\)の各要素は全てに互いに可換である。すなわち、どのような\(s,s' \in S\)についても、\(ss' = s's\)である。
\(S\)の各要素は全て独立である。すなわち、どのような\(s \in S\)についても\(S\setminus \{s\}\)の集合から生成される群に\(s\)は含まれない。
\(S\)から生成される群に\(-I\)が含まれない。
上記が満たされるとき、スタビライザー演算子が指定する論理符号とは、以下のような性質を満たす量子状態の張る部分空間である。
\(|\psi\rangle\)は全ての\(s \in S\)について、固有値+1の固有状態である。
独立なスタビライザーの生成元が\(l\)個あるとき、このような量子状態の張る空間の次元は\(2^{n-l}\)次元である。 従って、\(n\)-qubitの空間に \(k\) 論理量子ビットの空間を構築したいとき、\(l = n-k\)である必要がある。
この符号において、シンドローム値は量子状態を\(s \in S\)で射影測定した場合の測定結果として与えられる。 今計算のために利用されているのは全てのスタビライザー演算子の+1固有空間であるので、スタビライザー演算子の射影測定は興味のある論理量子ビットを破壊することはない。 今、簡単のために、それぞれの量子ビットは確率的にパウリ\(X,Y,Z\)のどれかが起きると仮定する。パウリ\(Y\)は\(X\)と\(Z\)の積なので、パウリの\(X\)と\(Z\)の両方が起きる場合とみなすことができる。
この時、\(i\)番目の量子ビットに\(X\)(\(Z\))エラーが起きているかどうかを、\(e^{(X)}_i\) (\(e^{(Z)}_i\))というバイナリで表すことにすると、 ある量子ビットを\(Z\)で測定するということは\(e^{(X)}_i\)を得ることに相当し、\(X\)で測定する行為は\(e^{(Z)}_i\)を得ることに相当する。 複数の量子ビットに作用するパウリ演算子で測定した場合、我々はそれぞれのビットの結果のXORを得ることになる。すなわち、 \(P=Z_2 X_3 Z_4 Z_5\)であれば、測定結果は\(\left(e^{(X)}_2 + e^{(Z)}_3 + e^{(X)}_4 + e^{(X)}_5\right) \bmod 2\)となる。
もし、\(P\)がパウリ\(Z\)の積のみで、量子ビットに作用するエラーがパウリ\(X\)によるビットフリップのみであれば、これはいくつかのビットのパリティを計算するパリティ検査と同義である。 従って、スタビライザー符号とはパリティ検査符号をパウリ\(Z\)に関するエラーに対しても適用できるようにした、一種の拡張であることがわかる。
あるスタビライザーの生成元が与えられたとき、その符号に対する距離は以下のように考えることができる。 まず、スタビライザー群に対する正規化群(Normalizer)を考える。正規化群とは以下のような性質を満たす群である。
この時、正規化群からグローバル位相の違いを無視してスタビライザー群を除いたものを論理演算子と呼ぶ。 論理演算子とは、シンドロームによってエラーを検知できないが、論理量子状態を変化させる演算の集合である。 従って論理演算子を実行するのに十分な量子ビットにエラーが起きてしまうと、我々には検知できないエラーが生じる。
あるパウリ演算子\(P\)が非自明に作用する(パウリ\(X,Y,Z\)のどれかが作用する)量子ビットの数を\(P\)のweight \(w(P)\)とする。 この時、符号の距離とは論理演算子の集合で最も小さなweight、すなわち
である。
トポロジカル符号¶
トポロジカル符号はトポロジカルな物質の縮退した基底状態を論理量子ビットの空間として用いるというアイデアから生まれた符号である。実験の観点からみると、トポロジカル符号は下記の良い性質を満たすスタビライザー符号の一種である。
検査行列\(H_c\)が疎行列である。こうした符号は低密度パリティ検査符号との類似性から性能が良いことが知られており、しかも個々のシンドロームは\(O(1)\)個のパリティとしてあらわされる。
個々のシンドロームの検査対象のデータ量子ビットが空間的に\(O(1)\)の距離に固まっている。従って、隣接した量子ビットにしかCNOTがかけられない物理系でも効率的にシンドローム測定が可能である。
トポロジカル符号の中でも、二次元平面上に測定量子ビットも含め埋め込むことができる表面符号が実現に最も近いとされている。
トポロジカル符号は低密度パリティ検査符号の欠点も引き継いでおり、一般には復号はNP困難となる。現実にはエラーの性質にいくつかの近似を用いることで、効率的に復号アルゴリズムが解ける形にすることが多い。
スタビライザー符号上での計算¶
量子計算は操作に伴ってエラーが生じるため、計算の間は量子状態を符号化したまま演算を行う必要がある。この計算においても、古典の場合とは大きな違いがある。
パリティ検査符号では1論理ビットに対する反転操作は、少なくとも\(O(d)\)、高々\(O(n)\)ビットに対する反転操作に対応する。これは、明らかに\(O(n)\)回のビットフリップで実現可能であり、並列化すれば1stepで完了できる。
スタビライザー符号では、1論理ビットに対する量子操作は、同様に少なくとも\(O(d)\)量子ビットに対する何らかのユニタリ操作に対応付けられる。ここで、古典の場合と異なるのは、\(O(d)\)量子ビットのユニタリ操作を実現するには、一般に\(O(2^d)\)個のゲートが必要になることである。従って、量子符号上においては1論理ビットに対する操作すら一般に効率的に行うことはできず、誤り訂正操作が新たな誤りを生んでしまい符号上での計算がスケールしなくなってしまう。
一方、符号によっては1論理ビットの操作\(U\)に対応する\(O(d)\)量子ビットの量子操作\(U'\)が\(O(1)\)ステップで完了できるような\(U\)が存在する。こうした符号上でも効率的に実現可能な論理ビットに対する操作をトランスバーサルな操作と呼ぶ。もし、トランスバーサルな操作がユニバーサルなゲートセットを構築すれば効率的に符号上でユニバーサルな量子計算が行えるが、残念ながらそのようなスタビライザー符号が無いことが証明されている。
従って、ユニバーサルな操作のために足りない操作を何らかの形で調達してやる必要がある。表面符号上は\(X, Y, Z, H\)などはトランスバーサルである。CNOTが可能かは非自明であるが、lattice surgeryなどで効率的に行えることが知られている。\(S\),\(T\)などがトランスバーサルではないため、別の符号で\(S\),\(T\)gate状態などを生成し、これをゲートテレポートする魔法状態蒸留という手法を用いることでユニバーサルなセットをそろえることができる。