Repository: qiskit-community/qiskit-machine-learning (language: python)

Issue:
Update setup.py to use requirements.txt
<!--
⚠️ If you do not respect this template, your pull request will be closed.
⚠️ Your pull request title should be short detailed and understandable for all.
⚠️ Also, please add it in the CHANGELOG file under Unreleased section.
⚠️ If your pull request fixes an open issue, please link to the issue.

✅ I have added the tests to cover my changes.
✅ I have updated the documentation accordingly.
✅ I have read the CONTRIBUTING document.
-->

### Summary

Update setup.py to use requirements.txt.
This is discussed in Terra Qiskit/qiskit-terra#1586.



Discussion / hints:

Fix patch:
diff --git a/.pylintdict b/.pylintdict
index cf55f403f..357add023 100644
--- a/.pylintdict
+++ b/.pylintdict
@@ -66,11 +66,13 @@ discretize
 discretized
 discriminative
 distro
+docstring
 dok
 dt
 eigenstate
 eigenstates
 einsum
+einstein
 endian
 entangler
 estimatorqnn
@@ -197,6 +199,7 @@ qiskit's
 qnn
 qsvc
 qsvr
+quantumcircuit
 qubit
 qubits
 rangle
diff --git a/qiskit_machine_learning/connectors/torch_connector.py b/qiskit_machine_learning/connectors/torch_connector.py
index 537d97d76..5bfd2b2f9 100644
--- a/qiskit_machine_learning/connectors/torch_connector.py
+++ b/qiskit_machine_learning/connectors/torch_connector.py
@@ -1,6 +1,6 @@
 # This code is part of a Qiskit project.
 #
-# (C) Copyright IBM 2021, 2023.
+# (C) Copyright IBM 2021, 2024.
 #
 # This code is licensed under the Apache License, Version 2.0. You may
 # obtain a copy of this license in the LICENSE.txt file in the root directory
@@ -15,6 +15,7 @@
 
 from typing import Tuple, Any, cast
 
+from string import ascii_lowercase
 import numpy as np
 
 import qiskit_machine_learning.optionals as _optionals
@@ -24,7 +25,7 @@
 if _optionals.HAS_TORCH:
     import torch
 
-    # imports for inheritance and type hints
+    # Imports for inheritance and type hints
     from torch import Tensor
     from torch.autograd import Function
     from torch.nn import Module
@@ -52,12 +53,51 @@ class Module:  # type: ignore
         pass
 
 
+CHAR_LIMIT = 26
+
+
+def _get_einsum_signature(n_dimensions: int, for_weights: bool = False) -> str:
+    """
+    Generate an Einstein summation signature for a given number of dimensions and return type.
+
+    Args:
+        n_dimensions (int): The number of dimensions for the summation.
+        for_weights (bool): If True, the return signature includes only the
+            last index as the output. If False, the return signature includes
+            all input indices except the last one. Defaults to False.
+
+
+    Returns:
+        str: The Einstein summation signature.
+
+    Raises:
+        RuntimeError: If the number of dimensions exceeds the character limit.
+    """
+    if n_dimensions > CHAR_LIMIT - 1:
+        raise RuntimeError(
+            f"Cannot define an Einstein summation with more than {CHAR_LIMIT - 1:d} dimensions, "
+            f"got {n_dimensions:d}."
+        )
+
+    trace = ascii_lowercase[:n_dimensions]
+
+    if for_weights:
+        return f"{trace[:-1]},{trace:s}->{trace[-1]}"
+
+    return f"{trace[:-1]},{trace:s}->{trace[0] + trace[2:]}"
+
+
 @_optionals.HAS_TORCH.require_in_instance
 class TorchConnector(Module):
-    """Connects a Qiskit (Quantum) Neural Network to PyTorch."""
+    """Connects a Qiskit (Quantum) Neural Network to PyTorch.
+    The dataset dimensionality is limited to 25. Datasets of 26 dimensions or higher
+    raise a `RuntimeError`.
+
+    """
 
     # pylint: disable=abstract-method
     class _TorchNNFunction(Function):
+
         # pylint: disable=arguments-differ
         @staticmethod
         def forward(  # type: ignore
@@ -187,7 +227,9 @@ def backward(ctx: Any, grad_output: Tensor) -> Tuple:  # type: ignore
                         # able to do back-prop in a batched manner.
                         # Pytorch does not support sparse einsum, so we rely on Sparse.
                         # pylint: disable=no-member
-                        input_grad = sparse.einsum("ij,ijk->ik", grad_coo, input_grad)
+                        n_dimension = max(grad_coo.ndim, input_grad.ndim)
+                        signature = _get_einsum_signature(n_dimension)
+                        input_grad = sparse.einsum(signature, grad_coo, input_grad)
 
                         # return sparse gradients
                         input_grad = torch.sparse_coo_tensor(input_grad.coords, input_grad.data)
@@ -205,7 +247,9 @@ def backward(ctx: Any, grad_output: Tensor) -> Tuple:  # type: ignore
                     input_grad = torch.as_tensor(input_grad, dtype=torch.float)
 
                     # same as above
-                    input_grad = torch.einsum("ij,ijk->ik", grad_output.detach().cpu(), input_grad)
+                    n_dimension = max(grad_output.detach().cpu().ndim, input_grad.ndim)
+                    signature = _get_einsum_signature(n_dimension)
+                    input_grad = torch.einsum(signature, grad_output.detach().cpu(), input_grad)
 
                 # place the resulting tensor to the device where they were stored
                 input_grad = input_grad.to(input_data.device)
@@ -226,7 +270,9 @@ def backward(ctx: Any, grad_output: Tensor) -> Tuple:  # type: ignore
                         # w.r.t. each parameter k. The weights' dimension is independent of the
                         # batch size.
                         # pylint: disable=no-member
-                        weights_grad = sparse.einsum("ij,ijk->k", grad_coo, weights_grad)
+                        n_dimension = max(grad_coo.ndim, weights_grad.ndim)
+                        signature = _get_einsum_signature(n_dimension, for_weights=True)
+                        weights_grad = sparse.einsum(signature, grad_coo, weights_grad)
 
                         # return sparse gradients
                         weights_grad = torch.sparse_coo_tensor(
@@ -244,9 +290,9 @@ def backward(ctx: Any, grad_output: Tensor) -> Tuple:  # type: ignore
                         weights_grad = weights_grad.todense()
                     weights_grad = torch.as_tensor(weights_grad, dtype=torch.float)
                     # same as above
-                    weights_grad = torch.einsum(
-                        "ij,ijk->k", grad_output.detach().cpu(), weights_grad
-                    )
+                    n_dimension = max(grad_output.detach().cpu().ndim, weights_grad.ndim)
+                    signature = _get_einsum_signature(n_dimension, for_weights=True)
+                    weights_grad = torch.einsum(signature, grad_output.detach().cpu(), weights_grad)
 
                 # place the resulting tensor to the device where they were stored
                 weights_grad = weights_grad.to(weights.device)
diff --git a/releasenotes/notes/fix-716-mismatch-dimension-pytorch-ba01bea90eba1435.yaml b/releasenotes/notes/fix-716-mismatch-dimension-pytorch-ba01bea90eba1435.yaml
new file mode 100644
index 000000000..c64e30f1d
--- /dev/null
+++ b/releasenotes/notes/fix-716-mismatch-dimension-pytorch-ba01bea90eba1435.yaml
@@ -0,0 +1,6 @@
+---
+fixes:
+  - |
+    Fixes the dimension mismatch error in the `torch_connector` raised when using other-than 3D datasets.
+    The updated implementation defines the Einstein summation signature dynamically based on the number of
+    dimensions `ndim` of the input data (up to 26 dimensions).


Test patch:
diff --git a/test/connectors/test_torch_connector.py b/test/connectors/test_torch_connector.py
index eeb526dc5..f0f67d3d1 100644
--- a/test/connectors/test_torch_connector.py
+++ b/test/connectors/test_torch_connector.py
@@ -1,6 +1,6 @@
 # This code is part of a Qiskit project.
 #
-# (C) Copyright IBM 2021, 2023.
+# (C) Copyright IBM 2021, 2024.
 #
 # This code is licensed under the Apache License, Version 2.0. You may
 # obtain a copy of this license in the LICENSE.txt file in the root directory
@@ -12,7 +12,7 @@
 
 """Test Torch Connector."""
 import itertools
-from typing import cast
+from typing import cast, Union, List, Tuple, Any
 
 from test.connectors.test_torch import TestTorch
 
@@ -25,6 +25,7 @@
 from qiskit_machine_learning import QiskitMachineLearningError
 from qiskit_machine_learning.connectors import TorchConnector
 from qiskit_machine_learning.neural_networks import SamplerQNN, EstimatorQNN
+from qiskit_machine_learning.connectors.torch_connector import _get_einsum_signature
 
 
 @ddt
@@ -241,3 +242,178 @@ def test_estimator_qnn(self, num_qubits, observables):
         self._validate_forward(model)
         self._validate_backward(model)
         self._validate_backward_automatically(model)
+
+    def _create_convolutional_layer(
+        self, input_channel: int, output_channel: int, num_qubits: int, num_weight: int
+    ):
+        import torch
+        from qiskit.circuit import Parameter
+
+        class ConvolutionalLayer(torch.nn.Module):
+            """
+            Quantum Convolutional Neural Network layer implemented using Qiskit and PyTorch.
+
+            Args:
+                input_channel (int): Number of input channels.
+                output_channel (int): Number of output channels.
+                num_qubits (int): Number of qubits to represent weights.
+                num_weight (int): Number of weight parameters in the quantum circuit.
+                kernel_size (int, optional): Size of the convolutional kernel. Defaults to 3.
+                stride (int, optional): Stride of the convolution. Defaults to 1.
+            """
+
+            def __init__(
+                self,
+                input_channel: int,
+                output_channel: int,
+                num_qubits: int,
+                num_weight: int,
+                kernel_size: int = 3,
+                stride: int = 1,
+            ):
+
+                super().__init__()
+                self.kernel_size = kernel_size
+                self.stride = stride
+                self.input_channel = input_channel
+                self.output_channel = output_channel
+                self.num_weight = num_weight
+                self.num_input = kernel_size * kernel_size * input_channel
+                self.num_qubits = num_qubits
+                self.qnn = TorchConnector(self.sampler())
+                if 2**num_qubits < output_channel:
+                    raise ValueError(
+                        (
+                            f"The output channel must be >= 2**num_qubits. "
+                            f"Got output_channel {output_channel:d} < 2 ** {num_qubits:d}"
+                        )
+                    )
+
+            @staticmethod
+            def build_circuit(
+                num_weights: int, num_input: int, num_qubits: int = 3
+            ) -> Tuple[QuantumCircuit, List[Parameter], List[Parameter]]:
+                """
+                Build the quantum circuit for the convolutional layer.
+
+                Args:
+                    num_weights (int): Number of weight parameters.
+                    num_input (int): Number of input parameters.
+                    num_qubits (int, optional): Number of qubits for representing parameters.
+                        Defaults to 3.
+
+                Returns:
+                    Tuple[QuantumCircuit, List[Parameter], List[Parameter]]: Quantum circuit,
+                        list of weight parameters, list of input parameters.
+                """
+                qc = QuantumCircuit(num_qubits)
+                weight_params = [Parameter(f"w{i}") for i in range(num_weights)]
+                input_params = [Parameter(f"x{i}") for i in range(num_input)]
+
+                # Construct the quantum circuit with the parameters
+                for i in range(num_qubits):
+                    qc.h(i)
+                for i in range(num_input):
+                    qc.ry(input_params[i] * 2 * torch.pi, i % num_qubits)
+                for i in range(num_qubits - 1):
+                    qc.cx(i, i + 1)
+                for i in range(num_weights):
+                    qc.rx(weight_params[i] * 2 * torch.pi, i % num_qubits)
+                for i in range(num_qubits - 1):
+                    qc.cx(i, i + 1)
+
+                return qc, weight_params, input_params
+
+            def sampler(self) -> SamplerQNN:
+                """
+                Creates a SamplerQNN object representing the quantum neural network.
+
+                Returns:
+                    SamplerQNN: Quantum neural network.
+                """
+                qc, weight_params, input_params = self.build_circuit(
+                    self.num_weight, self.num_input, 3
+                )
+
+                # Use SamplerQNN to convert the quantum circuit to a PyTorch module
+                return SamplerQNN(
+                    circuit=qc,
+                    weight_params=weight_params,
+                    interpret=self.interpret,  # type: ignore
+                    input_params=input_params,
+                    output_shape=self.output_channel,
+                )
+
+            def interpret(self, output: Union[float, int]) -> Any:
+                """
+                Interprets the output from the quantum circuit.
+
+                Args:
+                    output (Union[float, int]): Output from the quantum circuit.
+
+                Returns:
+                    Any: Remainder of the output divided by the
+                    number of output channels.
+                """
+                return output % self.output_channel
+
+            def forward(self, input_tensor: torch.Tensor) -> torch.Tensor:
+                """
+                Perform forward pass through the quantum convolutional layer.
+
+                Args:
+                    input_tensor (torch.Tensor): Input tensor.
+
+                Returns:
+                    torch.Tensor: Output tensor after convolution.
+                """
+                height = len(range(0, input_tensor.shape[-2] - self.kernel_size + 1, self.stride))
+                width = len(range(0, input_tensor.shape[-1] - self.kernel_size + 1, self.stride))
+                output = torch.zeros((input_tensor.shape[0], self.output_channel, height, width))
+                input_tensor = torch.nn.functional.unfold(
+                    input_tensor[...], kernel_size=self.kernel_size, stride=self.stride
+                )
+                qnn_output = self.qnn(input_tensor.permute(2, 0, 1)).permute(1, 2, 0)
+                qnn_output = torch.reshape(
+                    qnn_output, shape=(input_tensor.shape[0], self.output_channel, height, width)
+                )
+                output += qnn_output
+                return output
+
+        return ConvolutionalLayer(input_channel, output_channel, num_qubits, num_weight, stride=1)
+
+    def test_get_einsum_signature(self):
+        """
+        Tests the functionality of `_get_einsum_signature` function.
+
+        This function is tested with valid inputs (e.g., `n_dimensions` = 3) and expected outputs.
+        It also covers error handling scenarios for invalid input arguments.
+
+        Error Handling Tests:
+        - Raises a RuntimeError when the character limit for signature generation is exceeded.
+
+        Additional Test Scenario:
+        - Tests `_get_einsum_signature` in the context of a convolutional layer based on Sampler QNN,
+          using a 4-D input tensor (batch, channel, height, width) to ensure compatibility.
+          This test mirrors Issue https://github.com/qiskit-community/qiskit-machine-learning/issues/716
+
+        Note: Backward test is run implicitly within the context of convolutional layer execution.
+
+        pylint disable=SPELLING
+        """
+        # Test raises for exceeding character limit
+        with self.assertRaises(RuntimeError):
+            _get_einsum_signature(30)
+
+        # Test with a convolutional layer based on Sampler QNN and 4-D input
+        # Refer to issue https://github.com/qiskit-community/qiskit-machine-learning/issues/716
+        import torch
+
+        model = self._create_convolutional_layer(3, 1, 3, 3)
+        input_tensor = torch.rand((2, 3, 6, 6))
+        input_tensor.requires_grad = True
+        output_tensor = model.forward(input_tensor)
+        output_tensor = torch.sum(output_tensor)
+
+        # Run `backward`, which calls `_get_einsum_signature`
+        output_tensor.backward()