Source code for quantumaudio.schemes.mqsm

# Copyright 2024 Moth Quantum
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License 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.
# ==========================================================================

from typing import Optional, Union, Callable, Any, Tuple

import numpy as np
import qiskit
from bitstring import BitArray

from quantumaudio import utils
from .base_scheme import Scheme


[docs] class MQSM(Scheme): """Multi-channel Quantum State Modulation (MQSM). MQSM class implements an encoding and decoding scheme where the amplitude of a Digital signal is encoded as qubit states. These states are controlled by qubits of time register that encodes the corresponding time index information. Additionally, another register is used to represent the channel information. """ def __init__( self, qubit_depth: Optional[int] = None, num_channels: Optional[int] = None, ) -> None: """Initialize the MQSM instance. The attributes of `__init__` method are specific to this Scheme which remains fixed and independent of the Data. These attributes give an overview of the Scheme. Attributes: name: Holds the full name of the representation. qubit_depth: Number of qubits to represent the amplitude of an audio signal. (Note: In QSM, this is a variable that depends on the bit depth of audio) num_channels: Number of channels in a 2-dimensional data. For e.g. (2,8) denotes stereo audio of length 8. (Note: MQSM works with at least 2 channels.) n_fold: Term for a fixed number of indexed registers used. labels: Name of the Quantum registers positions: Index position of Quantum registers (In a Qiskit circuit the registers are arranged from Top to Bottom) convert: Function that applies a mathematical conversion of input at Encoding. restore: Function that restores the conversion at Decoding. keys: Reference to essential metadata keys for decoding. Args: qubit_depth: If None, the qubit_depth is adapted to the data. However, the user can specify `qubit_depth` to override it. This is useful in case of real hardware limitations. """ self.name = "Multi-channel Quantum State Modulation" self.qubit_depth = qubit_depth self.num_channels = num_channels self.n_fold = 2 self.labels = ("time", "channel", "amplitude") self.positions = (2, 1, 0) self.convert = utils.quantize self.restore = utils.de_quantize self.keys = ("num_samples", "num_channels", "qubit_shape") print(self.name) # ------------------- Encoding Helpers --------------------------- # ----- Data Preparation -----
[docs] def calculate( self, data: np.ndarray, verbose: Union[int, bool] = True ) -> Tuple[Tuple[int, int], Tuple[int, int, int]]: """Returns necessary information required for Encoding and Decoding: - Number of qubits required to encode Channel, Time and Amplitude information. - Original shape of the data required for decoding. Args: data: Array representing Digital Audio Samples. verbose: Prints the Qubit information if True or int > 0. Returns: A Tuple of (data_shape, qubit_shape) `data_shape` is a Tuple (int, int) consisting of: - `num_samples` - `num_channels` `qubit_shape` is a Tuple (int, int, int) consisting of: - `num_index_qubits` to encode Time Information. - `num_channel_qubits` to encode Channel Information. - `num_value_qubits` to encode Amplitude Information. """ # x-axis num_samples = data.shape[-1] num_index_qubits = utils.get_qubit_count(num_samples) # y-axis num_channels = ( 1 if data.ndim == 1 else data.shape[0] ) # data-dependent channels if self.num_channels: num_channels = self.num_channels # override with pre-set channels data_shape = (num_channels, num_samples) num_channel_qubits = utils.get_qubit_count( max(2, num_channels) ) # apply constraint of minimum 2 channels num_value_qubits = ( utils.get_bit_depth(data) if not self.qubit_depth else self.qubit_depth ) qubit_shape = (num_index_qubits, num_channel_qubits, num_value_qubits) # print if verbose: utils.print_num_qubits(qubit_shape, labels=self.labels) return data_shape, qubit_shape
[docs] def prepare_data( self, data: np.ndarray, num_index_qubits: int, num_channel_qubits: int ) -> np.ndarray: """Prepares the data with appropriate dimensions for encoding: - It pads the length of data with zeros on both dimensions to fit the number of states that can be represented with time and channel registers. - It flattens the array for encoding. The default arrangement of samples is made in an alternating manner using `utils.interleave_channels`. Args: data: Array representing Digital Audio Samples num_index_qubits: Number of qubits used to encode the sample indices. num_channel_qubits: Number of qubits used to encode the channels. Returns: Array with dimensions suitable for encoding. Note: This method should be followed by `convert()` method to convert the values suitable for encoding. """ data = utils.apply_padding( data, (num_channel_qubits, num_index_qubits) ) data = utils.interleave_channels(data) return data
# ----- Circuit Preparation -----
[docs] def initialize_circuit( self, num_index_qubits: int, num_channel_qubits: int, num_value_qubits: int, ) -> qiskit.QuantumCircuit: """Initializes the circuit with Index, Channel and Value Registers. Args: num_index_qubits: Number of qubits used to encode the sample indices. num_channel_qubits: Number of qubits used to encode the channels. num_value_qubits: Number of qubits used to encode the sample values. Returns: Qiskit Circuit with the registers """ index_register = qiskit.QuantumRegister( num_index_qubits, self.labels[0] ) channel_register = qiskit.QuantumRegister( num_channel_qubits, self.labels[1] ) value_register = qiskit.QuantumRegister( num_value_qubits, self.labels[2] ) circuit = qiskit.QuantumCircuit( value_register, channel_register, index_register, name=self.__class__.__name__, ) circuit.h(channel_register) circuit.h(index_register) return circuit
[docs] @utils.with_indexing def value_setting( self, circuit: qiskit.QuantumCircuit, index: int, value: float ) -> None: """Encodes the prepared, converted values to the initialised circuit. This function is used to set a single value at a single index. The decorator `with_indexing` applies the necessary control qubits corresponding to the given index. Args: circuit: Initialized Qiskit Circuit index: position to set the value value: value to be set at the index Note: This method is used in a loop where each value is iterated and set at its corresponding index. """ value_register, channel_register, index_register = circuit.qregs for i, areg_qubit in enumerate(value_register): a_bit = (value >> i) & 1 if a_bit: circuit.mcx( channel_register[:] + index_register[:], areg_qubit )
[docs] def measure(self, circuit: qiskit.QuantumCircuit) -> None: """Adds classical measurements to all registers of the Quantum Circuit if the circuit is not already measured. Args: circuit: Encoded Qiskit Circuit """ if not circuit.cregs: circuit.measure_all()
# ----- Default Encode Function -----
[docs] def encode( self, data: np.ndarray, measure: bool = True, verbose: Union[int, bool] = 1, ) -> qiskit.QuantumCircuit: """Given audio data, prepares a Qiskit Circuit representing it. Args: data: Array representing Digital Audio Samples measure: Adds measurement to the circuit if set True or int > 0. verbose: Level of information to print. - >1: Prints the number of qubits required. - >2: Displays the encoded circuit. Returns: A Qiskit Circuit representing the Digital Audio """ utils.validate_data(data) (num_channels, num_samples), qubit_shape = self.calculate( data, verbose=verbose ) num_index_qubits, num_channel_qubits, num_value_qubits = qubit_shape # prepare data data = self.prepare_data(data, num_index_qubits, num_channel_qubits) values = self.convert(data, num_value_qubits) # prepare circuit circuit = self.initialize_circuit( num_index_qubits, num_channel_qubits, num_value_qubits ) # encode information for i, sample in enumerate(values): self.value_setting(circuit=circuit, index=i, value=sample) # additional information for decoding circuit.metadata = { "num_samples": num_samples, "num_channels": num_channels, "qubit_shape": qubit_shape, "scheme": circuit.name, } # measure if measure: self.measure(circuit) if verbose == 2: utils.draw_circuit(circuit) return circuit
# ------------------- Decoding Helpers ---------------------------
[docs] def decode_components( self, counts: Union[dict, qiskit.result.Counts], qubit_shape: Tuple[int, int, int], ) -> np.ndarray: """The first stage of decoding is extracting the required components from counts. Args: counts: a dictionary with the outcome of measurements performed on the quantum circuit. qubit_shape: Tuple to determine the number of (channels, samples) to get. Returns: 2-D Array of shape (num_channels, num_samples) for further decoding. """ num_samples = 2 ** qubit_shape[0] num_channels = 2 ** qubit_shape[1] num_components = (num_channels, num_samples) data = np.zeros(num_components, int) for state in counts: index_bits, channel_bits, value_bits = utils.split_string( state, qubit_shape ) index = int(index_bits, 2) channel = int(channel_bits, 2) value = BitArray(bin=value_bits).int data[channel][index] = value return data
[docs] def reconstruct_data( self, counts: Union[dict, qiskit.result.Counts], qubit_shape: Tuple[int, int, int], ) -> np.ndarray: """Given counts, Extract components and restore the conversion at encoding stage. Args: counts: a dictionary with the outcome of measurements performed on the quantum circuit. qubit_shape: Tuple to determine the number of (channels, samples) to get. qubit_depth : number of qubits in amplitude register. Return: Array of restored values """ data = self.decode_components(counts, qubit_shape) data = self.restore(data, bit_depth=qubit_shape[-1]) return data
[docs] def decode_counts( self, counts: Union[dict, qiskit.result.Counts], metadata: dict, keep_padding: Tuple[int, int] = (False, False), ) -> np.ndarray: """Given a Qiskit counts object or Dictionary, Extract components and restore the conversion did at encoding stage. Args: counts: a qiskit Counts object or Dictionary obtained from a job result. metadata: metadata required for decoding. keep_padding: Undo the padding set at Encoding stage if set to False. - Dimension 0 for Channels. - Dimension 1 for Time. Return: Array of restored values with original dimensions """ index_position, channel_position, amplitude_position = self.positions # decoding x-axis qubit_shape = metadata["qubit_shape"] num_index_qubits = qubit_shape[0] num_channel_qubits = qubit_shape[1] num_samples = 2**num_index_qubits num_channels = 2**num_channel_qubits num_components = (num_channels, num_samples) original_num_samples = metadata["num_samples"] original_num_channels = metadata["num_channels"] # decoding y-axis qubit_depth = qubit_shape[2] # decoding data data = self.reconstruct_data(counts=counts, qubit_shape=qubit_shape) # reconstruct data = utils.restore_channels(data, num_channels) if not keep_padding[0]: data = data[:original_num_channels] if not keep_padding[1]: data = data[:, :original_num_samples] return data
[docs] def decode_result( self, result: qiskit.result.Result, metadata: Optional[dict] = None, keep_padding: Tuple[int, int] = (False, False), ) -> np.ndarray: """Given a result object. Extract components and restore the conversion did in the encoding stage. Args: result: a qiskit Result object that contains counts along with metadata that was held by the original circuit. metadata: optionally pass metadata as argument. keep_padding: Undo the padding set at Encoding stage if set to False. - Dimension 0 for Channels. - Dimension 1 for Time. Return: Array of restored values with original dimensions """ counts = utils.get_counts(result) metadata = utils.get_metadata(result) if not metadata else metadata data = self.decode_counts( counts=counts, metadata=metadata, keep_padding=keep_padding ) return data
# ----- Default Decode Function -----
[docs] def decode( self, circuit: qiskit.QuantumCircuit, metadata: Optional[dict] = None, keep_padding: Tuple[int, int] = (False, False), execute_function: Callable[ [qiskit.QuantumCircuit, dict], Any ] = utils.execute, **kwargs, ) -> np.ndarray: """Given a qiskit circuit, decodes and returns the Original Audio Array. Args: circuit: A Qiskit Circuit representing the Digital Audio. metadata: optionally pass metadata as argument. keep_padding: Undo the padding set at Encoding stage if set False. execute_function: Function to execute the circuit for decoding. - Defaults to :ref:`utils.execute <execute>` which accepts any additional `**kwargs`. Return: Array of decoded values """ self.measure(circuit) result = execute_function(circuit=circuit, **kwargs) data = self.decode_result( result=result, metadata=metadata, keep_padding=keep_padding ) return data