-
Notifications
You must be signed in to change notification settings - Fork 57
feature: Approximate State Preparation using Sweeping #183
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
906179f
7a4b370
0656cd3
84bcbb0
9d97573
a4852ea
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| # Copyright Amazon.com Inc. or its affiliates. All Rights Reserved. | ||
| # | ||
| # Licensed under the Apache License, Version 2.0 (the "License"). You | ||
| # may not use this file except in compliance with the License. A copy of | ||
| # the License is located at | ||
| # | ||
| # http://aws.amazon.com/apache2.0/ | ||
| # | ||
| # or in the "license" file accompanying this file. This file is | ||
| # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF | ||
| # ANY KIND, either express or implied. See the License for the specific | ||
| # language governing permissions and limitations under the License. | ||
|
|
||
| __all__ = [ | ||
| "generate_brickwall_ansatz", | ||
| "generate_staircase_ansatz", | ||
| "sweep_state_approximation", | ||
| ] | ||
|
|
||
| from braket.experimental.algorithms.sweeping.ansatzes import ( | ||
| generate_brickwall_ansatz, | ||
| generate_staircase_ansatz, | ||
| ) | ||
| from braket.experimental.algorithms.sweeping.sweeping import sweep_state_approximation |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,54 @@ | ||
| import numpy as np | ||
|
|
||
| from braket.experimental.algorithms.sweeping.typing import UNITARY_LAYER | ||
|
|
||
|
|
||
| def generate_staircase_ansatz(num_qubits: int, num_layers: int) -> list[UNITARY_LAYER]: | ||
| """Generate a staircase ansatz with the specified number of qubits and layers. | ||
|
|
||
| Args: | ||
| num_qubits (int): The number of qubits in the ansatz. | ||
| num_layers (int): The number of layers in the ansatz. | ||
|
|
||
| Returns: | ||
| list[UNITARY_LAYER]: The generated staircase ansatz. | ||
| """ | ||
| unitary_layers: list[UNITARY_LAYER] = [] | ||
|
|
||
| for i in range(num_layers): | ||
| unitary_layers.append([]) | ||
|
|
||
| for j in range(num_qubits - 1): | ||
| unitary_layers[i].append(([j, j + 1], np.eye(4, dtype=np.complex128))) | ||
|
|
||
| return unitary_layers | ||
|
|
||
|
|
||
| def generate_brickwall_ansatz(num_qubits: int, num_layers: int) -> list[UNITARY_LAYER]: | ||
| """Generate a brickwall ansatz with the specified number of qubits and layers. | ||
|
|
||
| Args: | ||
| num_qubits (int): The number of qubits in the ansatz. | ||
| num_layers (int): The number of layers in the ansatz. | ||
|
|
||
| Returns: | ||
| list[UNITARY_LAYER]: The generated brickwall ansatz. | ||
| """ | ||
| unitary_layers: list[UNITARY_LAYER] = [] | ||
|
|
||
| if num_qubits % 2 == 0: | ||
| start_1 = 1 | ||
| start_2 = 0 | ||
| else: | ||
| start_1 = 0 | ||
| start_2 = 1 | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is there a reason for this? I cannot see any obvious reason why this should be necessary. |
||
|
|
||
| for i in range(num_layers): | ||
| unitary_layers.append([]) | ||
|
|
||
| for j in range(start_1, num_qubits - 1, 2): | ||
| unitary_layers[i].append(([j, j + 1], np.eye(4, dtype=np.complex128))) | ||
| for j in range(start_2, num_qubits - 1, 2): | ||
| unitary_layers[i].append(([j, j + 1], np.eye(4, dtype=np.complex128))) | ||
|
Comment on lines
+46
to
+52
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This definition appears to treat the set of odd-even and even-odd gates as a single 'layer'. This may cause some confusion (as I think it would be more natural to define this as a pair of layers) and may also cause problems for the optimization (since it is probably desirable to optimize the odd-even gates within a row before moving to the even-odd gates, whereas I believe the current setup would alternate between the them). The current definition would also make it impossible use a brickwall circuit with an odd number of rows (i.e. odd-even, even-odd, odd-even gates) which could be optimal for some cases. |
||
|
|
||
| return unitary_layers | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| Tensor networks are a natural way to interact with quantum circuits and develop algorithms for them. A major application of tensor networks is in performing circuit optimization, | ||
| either via MPS/MPO IRs, or via sweeping. We focus on using tensor network sweeping as an algorithm to optimize arbitrary ansatzes to approximate a target statevector or unitary | ||
| in a smooth, consistently improving manner. The approach is a much better alternative for such cases compared to gradient-based and ML approaches which are prone to local minimas | ||
| and barren plateaus. For a better understanding of the technique see [Rudolph2022](https://arxiv.org/abs/2209.00595). | ||
|
|
||
| <!-- | ||
| [metadata-name]: Sweeping | ||
| [metadata-tags]: Textbook | ||
| [metadata-url]: https://github.com/amazon-braket/amazon-braket-algorithm-library/tree/main/src/braket/experimental/algorithms/sweeping | ||
| --> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,244 @@ | ||
| import numpy as np | ||
| import quimb.tensor as qtn | ||
| from numpy.typing import NDArray | ||
| from tqdm import tqdm | ||
|
|
||
| from braket.circuits import Circuit | ||
| from braket.devices import LocalSimulator | ||
| from braket.experimental.algorithms.sweeping.typing import UNITARY_LAYER | ||
|
|
||
|
|
||
| class StateSweepApproximator: | ||
| """Initialize the StateSweepApproximator class. | ||
|
|
||
| This algorithm performs sweeps over unitary layers representing | ||
| an ansatz to be trained to match the target state to improve | ||
| its approximation. Due to the difficulty in converging to good | ||
| fidelity with pure classical ML approaches, and the time it takes | ||
| to train them, sweeping is a much better alternative, running faster | ||
| and giving smooth improvement. | ||
|
|
||
| This approach is inspired by Rudolph et al. [1] which was used to do | ||
| the same task under Oall approach. | ||
|
|
||
| [1] https://www.nature.com/articles/s41467-023-43908-6 | ||
| """ | ||
|
|
||
| @staticmethod | ||
| def get_tensor_network_from_unitary_layers( | ||
| num_qubits: int, unitary_layers: list[UNITARY_LAYER] | ||
| ) -> qtn.TensorNetwork: | ||
| """Create `qtn.TensorNetwork` from unitary layers. | ||
|
|
||
| Args: | ||
| num_qubits (int): The number of qubits used by target state. | ||
| unitary_layers (list[UNITARY_LAYER]): The unitary layers. | ||
|
|
||
| Returns: | ||
| tensor_network (qtn.TensorNetwork): The tensor network. | ||
| """ | ||
| circuit = qtn.Circuit(N=num_qubits) | ||
| gate_tracker: list[str] = [] | ||
|
|
||
| for i, layer in enumerate(unitary_layers): | ||
| for j, (qubits, unitary) in enumerate(layer): | ||
| circuit.apply_gate_raw( | ||
| unitary.reshape(2 * len(qubits) * (2,)), | ||
| where=qubits, | ||
| contract=False, | ||
| ) | ||
| gate_tracker.append(f"{i}_{j}") | ||
|
|
||
| tensor_network = qtn.TensorNetwork(circuit.psi) | ||
|
|
||
| # We do not want to include the qubits, so we will | ||
| # explicitly control the iteration index | ||
| gate_index = 0 | ||
|
|
||
| for gate in tensor_network: | ||
| # We only update the gates, not the qubits | ||
| if "PSI0" in gate.tags: | ||
| continue | ||
|
|
||
| # Remove existing tags from the gate | ||
| gate.drop_tags(tags=gate.tags) | ||
|
|
||
| # Marshal the gate with the gate tracker | ||
| # This is needed to ensure the gates are properly tagged | ||
| # for updating the unitary layers | ||
| gate.add_tag(gate_tracker[gate_index]) | ||
| gate_index += 1 | ||
|
|
||
| return tensor_network | ||
|
|
||
| @staticmethod | ||
| def sweep_unitary_layers( | ||
| target_mps: qtn.MatrixProductState, | ||
| tensor_network: qtn.TensorNetwork, | ||
| unitary_layers: list[UNITARY_LAYER], | ||
| ) -> list[UNITARY_LAYER]: | ||
| """Sweep the unitary layers to improve the fidelity between | ||
| tensor network created by the unitary layers and the target | ||
| state. | ||
|
|
||
| Args: | ||
| target_mps (qtn.MatrixProductState): The target state represented as a MPS. | ||
| tensor_network (qtn.TensorNetwork): The tensor network created by the unitary layers. | ||
| unitary_layers (list[UNITARY_LAYER]): The unitary layers. | ||
|
|
||
| Returns: | ||
| unitary_layers (list[UNITARY_LAYER]): The unitary layers which have been updated inplace. | ||
| """ | ||
| target_mps_adjoint = target_mps.conj() | ||
|
|
||
| current_tn = qtn.MatrixProductState.from_dense( | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is there a reason why the state is transformed into an MPS? I would think that this would be a relatively expensive operation (especially when performed every iteration) and I do not see any computational advantage over keeping the state as a dense vector (given that no singular values are truncated in the MPS construction). |
||
| tensor_network.to_dense(tensor_network.outer_inds()) | ||
| ) | ||
|
|
||
| for gate in reversed(tensor_network.tensors): | ||
| num_qubits = int(len(gate.inds) / 2) | ||
|
|
||
| if "PSI0" in gate.tags: | ||
| continue | ||
|
|
||
| left_inds = gate.inds[:num_qubits] | ||
| right_inds = gate.inds[num_qubits:] | ||
|
|
||
| # To avoid unnecessary contraction, we "move" | ||
| # through the tensor network by applying the adjoint | ||
| # of the tensor to the tensor network, and applying | ||
| # the updated tensor to MPS | ||
| current_tn = current_tn @ gate.conj() | ||
| environment_tensor: qtn.TensorNetwork = target_mps_adjoint @ current_tn | ||
|
|
||
| u, _, vh = np.linalg.svd(environment_tensor.to_dense((left_inds), (right_inds))) | ||
| u_new = np.dot(u, vh) | ||
|
|
||
| # To avoid unnecessary contraction, we "move" | ||
| # through the tensor network by applying the adjoint | ||
| # of the tensor to the tensor network, and applying | ||
| # the updated tensor to MPS | ||
| new_tensor = qtn.Tensor( | ||
| u_new.reshape(2 * num_qubits * (2,)).conj(), | ||
| inds=gate.inds, | ||
| tags=gate.tags, | ||
| ) | ||
|
|
||
| target_mps_adjoint = target_mps_adjoint @ new_tensor | ||
|
|
||
| gate_tag = list(gate.tags)[0] | ||
| layer_index, block_index = gate_tag.split("_") | ||
|
|
||
| unitary_layers[int(layer_index)][int(block_index)] = ( | ||
| unitary_layers[int(layer_index)][int(block_index)][0], | ||
| u_new.conj(), | ||
| ) | ||
|
|
||
| return unitary_layers | ||
|
|
||
| @staticmethod | ||
| def circuit_from_unitary_layers( | ||
| num_qubits: int, unitary_layers: list[UNITARY_LAYER] | ||
| ) -> Circuit: | ||
| """Create a `Circuit` instance from | ||
| the unitary layers. | ||
|
|
||
| Args: | ||
| num_qubits (int): The number of qubits used by target state. | ||
| unitary_layers (list[UNITARY_LAYER]): The unitary layers. | ||
|
|
||
| Returns: | ||
| circuit (Circuit): The qiskit circuit. | ||
| """ | ||
| circuit = Circuit() | ||
|
|
||
| for layer in unitary_layers: | ||
| for qubits, unitary_matrix in layer: | ||
| circuit.unitary( | ||
| matrix=unitary_matrix, | ||
| targets=qubits, | ||
| ) | ||
|
|
||
| return circuit | ||
|
|
||
| def __call__( | ||
| self, | ||
| target_state: NDArray[np.complex128], | ||
| unitary_layers: list[UNITARY_LAYER], | ||
| num_sweeps: int, | ||
| log: bool = False, | ||
| ) -> Circuit: | ||
| """Approximate a state via sweeping. | ||
|
|
||
| Args: | ||
| target_state (NDArray[np.complex128]): The state we want to approximate. | ||
| unitary_layers (list[UNITARY_LAYER]): The initial unitary layers. | ||
| num_sweeps (int): The number of times to sweep the unitary layers. | ||
| log (bool): Whether to print logs of fidelity or not. | ||
|
|
||
| Returns: | ||
| circuit (Circuit): The circuit. | ||
| """ | ||
| num_qubits = int(np.log2(target_state.shape[0])) | ||
| target_mps = qtn.MatrixProductState.from_dense(target_state) | ||
|
|
||
| for i in tqdm(range(num_sweeps)): | ||
| circuit_tensor_network = self.get_tensor_network_from_unitary_layers( | ||
| num_qubits, unitary_layers | ||
|
Comment on lines
+186
to
+187
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't know how expensive this is but it seems unnecessary to rebuild the network for every sweep (i.e. rather than just building the network once and mutating the tensors within it). |
||
| ) | ||
|
|
||
| if log and i % 20 == 0: | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It would make sense to have a break here if the results are sufficiently converged (i.e. break if (1-fidelity) < tol). |
||
| fidelity = ( | ||
| np.abs( | ||
| np.vdot( | ||
| target_state, | ||
| circuit_tensor_network.to_dense( | ||
| circuit_tensor_network.outer_inds() | ||
| ).reshape(target_state.shape), | ||
| ) | ||
| ) | ||
| ** 2 | ||
| ) | ||
| print(f"Fidelity: {fidelity}") | ||
|
|
||
| unitary_layers = self.sweep_unitary_layers( | ||
| target_mps, circuit_tensor_network, unitary_layers | ||
| ) | ||
|
|
||
| circuit = self.circuit_from_unitary_layers(num_qubits, unitary_layers) | ||
|
|
||
| if log: | ||
| result = LocalSimulator("braket_sv").run(circuit.state_vector(), shots=0).result() | ||
| fidelity = ( | ||
| np.abs( | ||
| np.vdot( | ||
| result.values[0], | ||
| target_state, | ||
| ) | ||
| ) | ||
| ** 2 | ||
| ) | ||
| print(f"Final Fidelity: {fidelity}") | ||
|
|
||
| return circuit | ||
|
|
||
|
|
||
| def sweep_state_approximation( | ||
| target_state: NDArray[np.complex128], | ||
| unitary_layers: list[UNITARY_LAYER], | ||
| num_sweeps: int, | ||
| log: bool = False, | ||
| ) -> Circuit: | ||
| """Approximate a state via sweeping. | ||
|
|
||
| Args: | ||
| target_state (NDArray[np.complex128]): The state we want to approximate. | ||
| unitary_layers (list[UNITARY_LAYER]): The initial unitary layers. | ||
| num_sweeps (int): The number of times to sweep the unitary layers. | ||
| log (bool): Whether to print logs of fidelity or not. | ||
|
|
||
| Returns: | ||
| circuit (Circuit): The circuit. | ||
| """ | ||
| approximator = StateSweepApproximator() | ||
| return approximator(target_state, unitary_layers, num_sweeps, log) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| from typing import TypeAlias | ||
|
|
||
| import numpy as np | ||
| from numpy.typing import NDArray | ||
|
|
||
| UNITARY_BLOCK: TypeAlias = tuple[list[int], NDArray[np.complex128]] | ||
| UNITARY_LAYER: TypeAlias = list[UNITARY_BLOCK] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| import numpy as np | ||
| import pytest | ||
|
|
||
| from braket.devices import LocalSimulator | ||
| from braket.experimental.algorithms.sweeping import ( | ||
| generate_staircase_ansatz, | ||
| sweep_state_approximation, | ||
| ) | ||
|
|
||
|
|
||
| @pytest.mark.parametrize("num_qubits", [2, 3, 4, 5]) | ||
| def compile_with_state_sweep_pass(num_qubits: int) -> None: | ||
| state = np.random.uniform(-1, 1, 2**num_qubits) + 1j * np.random.uniform(-1, 1, 2**num_qubits) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Its usually not a good idea to use randomly generated states for testing (unless you seed them) as it could cause inconsistent failures. |
||
| state /= np.linalg.norm(state) | ||
|
|
||
| compiled_circuit = sweep_state_approximation( | ||
| target_state=state, | ||
| unitary_layers=generate_staircase_ansatz( | ||
| num_qubits=num_qubits, num_layers=int((num_qubits**2) / 2) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can you explain where the value for |
||
| ), | ||
| num_sweeps=100 * num_qubits, | ||
| log=False, | ||
| ) | ||
|
|
||
| result = LocalSimulator("braket_sv").run(compiled_circuit.state_vector(), shots=0).result() | ||
| fidelity = np.abs(np.vdot(state, result.values[0])) | ||
|
|
||
| assert fidelity > 0.99 | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The 'brickwall' version of the ansatz doesn't current appear to be covered by testing.