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/.github/workflows/main.yml b/.github/workflows/main.yml
index 49c3a6b0f..828f48b76 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -229,36 +229,36 @@ jobs:
         with:
           name: tutorials${{ matrix.python-version }}
           path: docs/_build/html/artifacts/tutorials.tar.gz
-      - name: Run stable tutorials
-        env:
-          QISKIT_PARALLEL: False
-          QISKIT_DOCS_BUILD_TUTORIALS: 'always'
-        run: |
-          # clean last sphinx output
-          make clean_sphinx
-          # get current version
-          version=$(pip show qiskit-machine-learning | awk -F. '/^Version:/ { print substr($1,10), $2-1 }' OFS=.)
-          # download stable version
-          wget https://codeload.github.com/qiskit-community/qiskit-machine-learning/zip/stable/$version -O /tmp/repo.zip
-          unzip /tmp/repo.zip -d /tmp/
-          # copy stable tutorials to main tutorials
-          cp -R /tmp/qiskit-machine-learning-stable-$version/docs/tutorials/* docs/tutorials
-          # run tutorials and zip results
-          echo "earliest_version: 0.1.0" >> releasenotes/config.yaml
-          # ignore unreleased/untagged notes
-          tools/ignore_untagged_notes.sh
-          make html
-          cd docs/_build/html
-          mkdir artifacts
-          tar -zcvf artifacts/tutorials.tar.gz --exclude=./artifacts .
-        if: ${{ matrix.python-version == 3.8 && !startsWith(github.ref, 'refs/heads/stable') && !startsWith(github.base_ref, 'stable/') }}
-        shell: bash
-      - name: Run upload stable tutorials
-        uses: actions/upload-artifact@v4
-        with:
-          name: tutorials-stable${{ matrix.python-version }}
-          path: docs/_build/html/artifacts/tutorials.tar.gz
-        if: ${{ matrix.python-version == 3.8 && !startsWith(github.ref, 'refs/heads/stable') && !startsWith(github.base_ref, 'stable/') }}
+#      - name: Run stable tutorials
+#        env:
+#          QISKIT_PARALLEL: False
+#          QISKIT_DOCS_BUILD_TUTORIALS: 'always'
+#        run: |
+#          # clean last sphinx output
+#          make clean_sphinx
+#          # get current version
+#          version=$(pip show qiskit-machine-learning | awk -F. '/^Version:/ { print substr($1,10), $2-1 }' OFS=.)
+#          # download stable version
+#          wget https://codeload.github.com/qiskit-community/qiskit-machine-learning/zip/stable/$version -O /tmp/repo.zip
+#          unzip /tmp/repo.zip -d /tmp/
+#          # copy stable tutorials to main tutorials
+#          cp -R /tmp/qiskit-machine-learning-stable-$version/docs/tutorials/* docs/tutorials
+#          # run tutorials and zip results
+#          echo "earliest_version: 0.1.0" >> releasenotes/config.yaml
+#          # ignore unreleased/untagged notes
+#          tools/ignore_untagged_notes.sh
+#          make html
+#          cd docs/_build/html
+#          mkdir artifacts
+#          tar -zcvf artifacts/tutorials.tar.gz --exclude=./artifacts .
+#        if: ${{ matrix.python-version == 3.8 && !startsWith(github.ref, 'refs/heads/stable') && !startsWith(github.base_ref, 'stable/') }}
+#        shell: bash
+#      - name: Run upload stable tutorials
+#        uses: actions/upload-artifact@v4
+#        with:
+#          name: tutorials-stable${{ matrix.python-version }}
+#          path: docs/_build/html/artifacts/tutorials.tar.gz
+#        if: ${{ matrix.python-version == 3.8 && !startsWith(github.ref, 'refs/heads/stable') && !startsWith(github.base_ref, 'stable/') }}
   Deprecation_Messages_and_Coverage:
     needs: [Checks, MachineLearning, Tutorials]
     runs-on: ubuntu-latest
diff --git a/.pylintdict b/.pylintdict
index 51c6a9431..375963119 100644
--- a/.pylintdict
+++ b/.pylintdict
@@ -1,14 +1,26 @@
 acyclic
 adam
 adjoint
+ae
 aer
 al
 alan
+algo
+algorithmerror
+allclose
+amsgrad
 ancilla
 ancillas
 ansatz
+ansatz's
 ansatzes
+ap
+apl
+arg
+argmax
 args
+armijo
+arxiv
 asmatrix
 aspuru
 async
@@ -16,148 +28,313 @@ autoencoder
 autoencoders
 autograd
 autosummary
+ba
 backend
 backends
 backpropagation
+barison
+barkoutsos
+batchsize
 bayes
 bayesian
 benchmarking
 bergholm
+bfgs
+bielza
 bitstring
 bitstrings
 bivariate
 bloch
+boltzmann
 bool
 boolean
 borujeni
+boyer
+brassard
+broyden
+callables
+cambridge
+cancelled
 cargs
+carleo
 carlo
 cbit
 centroid
+chernoff
 choi
 chuang
 clbit
 clbits
+clopper
+cobyla
+codebase
 codec
+coeffs
 colin
+combinatorial
+concha
 config
+configs
+confint
 cong
 contravariance
 convolutional
 cpu
 creg
 crossentropyloss
+crs
 csr
 ctrl
 ctx
 currentmodule
+customizable
+cvar
 cvs
 cx
 córcoles
 data's
+dataclass
+dataframe
 dataloader
 datapoints
 dataset
 datasets
 deepcopy
 denoising
+deriv
 deterministically
 diag
 dicts
+diederik
 dimensionality
 dir
 discretization
 discretize
 discretized
 discriminative
+disp
 distro
+dobsicek
 docstring
+doi
 dok
+dp
 dt
+dω
+eda
+egger
+eigen
+eigenphase
+eigenproblem
+eigensolver
+eigensolvers
 eigenstate
 eigenstates
-einsum
 einstein
+einsum
+elif
 endian
 entangler
+enum
+eps
 estimatorqnn
 et
+euler
 eval
+evals
+evolutions
+evolutionsynthesis
+evolver
+evolvers
+excitations
+exponentials
+exponentiated
+exponentiating
 expressibility
+f'spsa
+fae
+failsafe
+farhi
 farrokh
+fi
 fidelities
 fidelityquantumkernel
+filippo
+fletcher
+fm
+fmin
 formatter
+fourier
 frac
+frobenius
 frontend
+ftol
+fujii
 func
+functools
+gacon
 gambetta
 gaussian
 gellmann
+generalised
 getter
+getters
+gilliam
+giuseppe
+globals
+gogolin
+goldfarb
+goldstone
+gonciulea
 gpu
+gqt
+grinko
 grover
+gsls
+gtol
 guang
+gutmann
 guzik
+hadamard
+hadamards
+hadfield
 hamiltonian
+hamiltonians
 hao
 hashable
+hatano
 havlíček
+heidelberg
+hessians
 hilbert
 hoc
+hopkins
+hoyer
 html
+https
 hubbard
 hyperparameter
 hyperparameters
 hyperplanes
+idx
+im
+imag
 init
+initializer
 inlier
 inplace
 instantiation
 instantiations
+interatomic
 interdependencies
+ints
+iprint
+iqft
 isaac
+ising
 isometry
 iten
 iterable
 iteratively
+iz
+izaac
+izz
+jac
 jacobian
 jm
 jonathan
+jones
+july
 jupyter
 kandala
 kernelized
+killoran
+kingma
+kitagawa
+kraft
+kth
+kumar
 kwarg
 kwargs
 labelled
 lagrange
 langle
+larrañaga
+lcu
+len
+leq
+lin
+linalg
+loglik
+loglikelihood
+lov
+lr
+lsb
+lse
 lukin
 macos
+magnus
 makefile
+marecek
 mary
+masuo
+mathcal
+mathrm
 matmul
 matplotlib
+maxcut
+maxfail
+maxfev
+maxfun
 maxiter
+maxiters
+maxmp
 mcrx
 mcry
 mcrz
+mcz
+michael
+minimised
 minimizer
+minwidth
 misclassified
+mitarai
 mixin
+mle
+modelspace
 monte
+mosca
 mpl
+mprev
 multiclass
 multinomial
 multioutput
 mxd
 mypy
+nabla
+nakaji
+nakanishi
+nan
+nannicini
+naomichi
 nat
+nathan
 nbsphinx
+nd
 ndarray
+negoro
+nelder
+nevals
+newtons's
+nfev
+nfevs
+nft
 nielsen
+njev
+nlopt
 nn
 noancilla
+nones
 nonlocal
 nosignatures
 np
@@ -166,9 +343,13 @@ num
 numpy
 nxd
 nxm
+o'brien
+objval
 observables
+oct
 olson
 onboarding
+onodera
 opflow
 optim
 optimizer's
@@ -180,27 +361,46 @@ overfitted
 overfitting
 ovo
 ovr
+parallelization
+parallelized
 param
 parameterization
+parameterizations
+parametrization
+parametrizations
 parametrize
 parametrized
 params
 pauli
+paulis
+pearson
+pedro
 pegasos
+peruzzo
 pixelated
 platt
+polyfit
+postprocess
+powell
 pre
 precompute
 precomputed
 precomputes
 precomputing
+preconditioner
+prepend
 preprint
+preprocess
+preprocesses
 priori
+proj
 ps
+pvqd
 pxd
 py
 pytorch
 qae
+qaoa
 qarg
 qargs
 qasm
@@ -208,68 +408,131 @@ qb
 qbayesian
 qbi
 qc
+qdrift
+qfi
+qfi's
+qfis
 qgan
 qgans
+qgt
+qgt's
+qgts
 qiskit
 qiskit's
+qn
 qnn
+qnspsa
+qpe
 qsvc
 qsvr
+quadratically
+quant
+quantile
 quantumcircuit
 qubit
 qubits
 rangle
+raymond
 rbf
 readme
 recalibration
+reddi
 regressor
 regressors
 regs
+representable
 reproducibility
+resamplings
 rescale
+rescaled
+rescaling
+retval
+rhobeg
 rhs
+rightarrow
+robert
 romero
+rosen
+runarsson
 runtime
 runtimes
 rx
 ry
 rz
 samplerqnn
+sanjiv
+sashank
+satisfiability
+satyen
 scalability
+schroediger
+schroedinger
+schrödinger
+schuld
 scikit
 scipy
+sdg
+seealso
 semidefinite
+serializable
 shalev
+shanno
 shende
 shwartz
 sigmoid
 sima
 sklearn
+skquant
+sle
+slsqp
 softmax
+soloviev
+spall
 sparsearray
+spedalieri
+spsa
+sqrt
+statefn
 statevector
 statevectors
-stdlib
+stddev
 stdlib
 stdout
+stefano
+steppable
+stepsize
 str
-subclasses
 subcircuits
+subclassed
+subclasses
+subcomponents
 submodules
 subobjects
 subseteq
+subspaces
+suzuki
 svc
 svm
 svr
+swappable
 sx
 sy
+tanaka
+tapp
+tarasinski
+tavernelli
 temme
 tensored
+terhal
 terra
 th
 theodore
+timestep
+timesteps
+tnc
 toctree
 todo
+tol
 traceback
 trainability
 trainablefidelityquantumkernel
@@ -279,28 +542,59 @@ transpiled
 transpiler
 transpiles
 transpiling
+trotterization
+trotterized
 tunable
 uncompiled
 uncompress
 uncompute
 unitaries
 univariate
+uno
+unscaled
 unsymmetric
 utf
 utils
+varadarajan
 variational
 vatan
 vec
+vectorized
+veeravalli
+vicentini
+vigo
+ville
+voilà
 vqc
+vqd
 vqe
 vqr
 vx
 vy
 vz
+wavefunction
 wikipedia
+wilhelm
 williams
+woerner
 wrt
+xatol
+xopt
+xtol
+yamamoto
+yao
 yoder
+york
+yy
+yz
+zi
 zoufal
 zsh
+zz
 θ
+ψ
+ω
+assertRaises
+RuntimeError
+Rosenbrock
+fidelities
diff --git a/README.md b/README.md
index e8d773477..3973737ad 100644
--- a/README.md
+++ b/README.md
@@ -112,8 +112,8 @@ be classified.
 
 ```python
 from qiskit.circuit.library import TwoLocal, ZZFeatureMap
-from qiskit_algorithms.optimizers import COBYLA
-from qiskit_algorithms.utils import algorithm_globals
+from qiskit_machine_learning.optimizers import COBYLA
+from qiskit_machine_learning.utils import algorithm_globals
 
 from qiskit_machine_learning.algorithms import VQC
 from qiskit_machine_learning.datasets import ad_hoc_data
diff --git a/docs/conf.py b/docs/conf.py
index 4c239b619..9eb0e42d7 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -177,7 +177,6 @@
     "scipy": ("https://docs.scipy.org/doc/scipy", None),
     "sklearn": ("https://scikit-learn.org/stable", None),
     "qiskit": ("https://docs.quantum.ibm.com/api/qiskit", None),
-    "qiskit-algorithms": ("https://qiskit-community.github.io/qiskit-algorithms", None),
 }
 
 html_context = {"analytics_enabled": True}
diff --git a/docs/tutorials/01_neural_networks.ipynb b/docs/tutorials/01_neural_networks.ipynb
index 30bdfde5b..255aa8c3c 100644
--- a/docs/tutorials/01_neural_networks.ipynb
+++ b/docs/tutorials/01_neural_networks.ipynb
@@ -92,7 +92,7 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "from qiskit_algorithms.utils import algorithm_globals\n",
+    "from qiskit_machine_learning.utils import algorithm_globals\n",
     "\n",
     "algorithm_globals.random_seed = 42"
    ]
diff --git a/docs/tutorials/02_neural_network_classifier_and_regressor.ipynb b/docs/tutorials/02_neural_network_classifier_and_regressor.ipynb
index 63e41d8e1..c15a6b5c6 100644
--- a/docs/tutorials/02_neural_network_classifier_and_regressor.ipynb
+++ b/docs/tutorials/02_neural_network_classifier_and_regressor.ipynb
@@ -36,8 +36,8 @@
     "from qiskit import QuantumCircuit\n",
     "from qiskit.circuit import Parameter\n",
     "from qiskit.circuit.library import RealAmplitudes, ZZFeatureMap\n",
-    "from qiskit_algorithms.optimizers import COBYLA, L_BFGS_B\n",
-    "from qiskit_algorithms.utils import algorithm_globals\n",
+    "from qiskit_machine_learning.optimizers import COBYLA, L_BFGS_B\n",
+    "from qiskit_machine_learning.utils import algorithm_globals\n",
     "\n",
     "from qiskit_machine_learning.algorithms.classifiers import NeuralNetworkClassifier, VQC\n",
     "from qiskit_machine_learning.algorithms.regressors import NeuralNetworkRegressor, VQR\n",
diff --git a/docs/tutorials/02a_training_a_quantum_model_on_a_real_dataset.ipynb b/docs/tutorials/02a_training_a_quantum_model_on_a_real_dataset.ipynb
index 53c6ee7ef..63bf39dbb 100644
--- a/docs/tutorials/02a_training_a_quantum_model_on_a_real_dataset.ipynb
+++ b/docs/tutorials/02a_training_a_quantum_model_on_a_real_dataset.ipynb
@@ -117,8 +117,7 @@
       "  conceptual clustering system finds 3 classes in the data.\n",
       "- Many, many more ...\n",
       "\n",
-      "|details-end|\n",
-      "\n"
+      "|details-end|\n"
      ]
     }
    ],
@@ -249,7 +248,7 @@
    "outputs": [],
    "source": [
     "from sklearn.model_selection import train_test_split\n",
-    "from qiskit_algorithms.utils import algorithm_globals\n",
+    "from qiskit_machine_learning.utils import algorithm_globals\n",
     "\n",
     "algorithm_globals.random_seed = 123\n",
     "train_features, test_features, train_labels, test_labels = train_test_split(\n",
@@ -412,7 +411,7 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "from qiskit_algorithms.optimizers import COBYLA\n",
+    "from qiskit_machine_learning.optimizers import COBYLA\n",
     "\n",
     "optimizer = COBYLA(maxiter=100)"
    ]
diff --git a/docs/tutorials/03_quantum_kernel.ipynb b/docs/tutorials/03_quantum_kernel.ipynb
index 7c314fa68..1f5629ce4 100644
--- a/docs/tutorials/03_quantum_kernel.ipynb
+++ b/docs/tutorials/03_quantum_kernel.ipynb
@@ -78,7 +78,7 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "from qiskit_algorithms.utils import algorithm_globals\n",
+    "from qiskit_machine_learning.utils import algorithm_globals\n",
     "\n",
     "algorithm_globals.random_seed = 12345"
    ]
@@ -246,7 +246,7 @@
    "source": [
     "from qiskit.circuit.library import ZZFeatureMap\n",
     "from qiskit.primitives import Sampler\n",
-    "from qiskit_algorithms.state_fidelities import ComputeUncompute\n",
+    "from qiskit_machine_learning.state_fidelities import ComputeUncompute\n",
     "from qiskit_machine_learning.kernels import FidelityQuantumKernel\n",
     "\n",
     "adhoc_feature_map = ZZFeatureMap(feature_dimension=adhoc_dimension, reps=2, entanglement=\"linear\")\n",
diff --git a/docs/tutorials/04_torch_qgan.ipynb b/docs/tutorials/04_torch_qgan.ipynb
index fbf8949f1..eb93decb9 100644
--- a/docs/tutorials/04_torch_qgan.ipynb
+++ b/docs/tutorials/04_torch_qgan.ipynb
@@ -85,7 +85,7 @@
    "outputs": [],
    "source": [
     "import torch\n",
-    "from qiskit_algorithms.utils import algorithm_globals\n",
+    "from qiskit_machine_learning.utils import algorithm_globals\n",
     "\n",
     "algorithm_globals.random_seed = 123456\n",
     "_ = torch.manual_seed(123456)  # suppress output"
diff --git a/docs/tutorials/05_torch_connector.ipynb b/docs/tutorials/05_torch_connector.ipynb
index 5f9efb2dd..ff419093a 100644
--- a/docs/tutorials/05_torch_connector.ipynb
+++ b/docs/tutorials/05_torch_connector.ipynb
@@ -47,7 +47,7 @@
     "from qiskit import QuantumCircuit\n",
     "from qiskit.circuit import Parameter\n",
     "from qiskit.circuit.library import RealAmplitudes, ZZFeatureMap\n",
-    "from qiskit_algorithms.utils import algorithm_globals\n",
+    "from qiskit_machine_learning.utils import algorithm_globals\n",
     "from qiskit_machine_learning.neural_networks import SamplerQNN, EstimatorQNN\n",
     "from qiskit_machine_learning.connectors import TorchConnector\n",
     "\n",
@@ -836,8 +836,7 @@
       "\n",
       "Downloading http://yann.lecun.com/exdb/mnist/t10k-labels-idx1-ubyte.gz\n",
       "Downloading http://yann.lecun.com/exdb/mnist/t10k-labels-idx1-ubyte.gz to ./data/MNIST/raw/t10k-labels-idx1-ubyte.gz\n",
-      "Extracting ./data/MNIST/raw/t10k-labels-idx1-ubyte.gz to ./data/MNIST/raw\n",
-      "\n"
+      "Extracting ./data/MNIST/raw/t10k-labels-idx1-ubyte.gz to ./data/MNIST/raw\n"
      ]
     }
    ],
diff --git a/docs/tutorials/07_pegasos_qsvc.ipynb b/docs/tutorials/07_pegasos_qsvc.ipynb
index da91dd383..27d91665b 100644
--- a/docs/tutorials/07_pegasos_qsvc.ipynb
+++ b/docs/tutorials/07_pegasos_qsvc.ipynb
@@ -110,7 +110,7 @@
    "outputs": [],
    "source": [
     "from qiskit.circuit.library import ZFeatureMap\n",
-    "from qiskit_algorithms.utils import algorithm_globals\n",
+    "from qiskit_machine_learning.utils import algorithm_globals\n",
     "\n",
     "from qiskit_machine_learning.kernels import FidelityQuantumKernel\n",
     "\n",
diff --git a/docs/tutorials/08_quantum_kernel_trainer.ipynb b/docs/tutorials/08_quantum_kernel_trainer.ipynb
index 7f69c3f17..2a7a01435 100644
--- a/docs/tutorials/08_quantum_kernel_trainer.ipynb
+++ b/docs/tutorials/08_quantum_kernel_trainer.ipynb
@@ -46,7 +46,7 @@
     "from qiskit.circuit import ParameterVector\n",
     "from qiskit.visualization import circuit_drawer\n",
     "from qiskit.circuit.library import ZZFeatureMap\n",
-    "from qiskit_algorithms.optimizers import SPSA\n",
+    "from qiskit_machine_learning.optimizers import SPSA\n",
     "from qiskit_machine_learning.kernels import TrainableFidelityQuantumKernel\n",
     "from qiskit_machine_learning.kernels.algorithms import QuantumKernelTrainer\n",
     "from qiskit_machine_learning.algorithms import QSVC\n",
diff --git a/docs/tutorials/09_saving_and_loading_models.ipynb b/docs/tutorials/09_saving_and_loading_models.ipynb
index 08a45f8ed..0386d6c13 100644
--- a/docs/tutorials/09_saving_and_loading_models.ipynb
+++ b/docs/tutorials/09_saving_and_loading_models.ipynb
@@ -37,8 +37,8 @@
     "import numpy as np\n",
     "from qiskit.circuit.library import RealAmplitudes\n",
     "from qiskit.primitives import Sampler\n",
-    "from qiskit_algorithms.optimizers import COBYLA\n",
-    "from qiskit_algorithms.utils import algorithm_globals\n",
+    "from qiskit_machine_learning.optimizers import COBYLA\n",
+    "from qiskit_machine_learning.utils import algorithm_globals\n",
     "from sklearn.model_selection import train_test_split\n",
     "from sklearn.preprocessing import OneHotEncoder, MinMaxScaler\n",
     "\n",
diff --git a/docs/tutorials/10_effective_dimension.ipynb b/docs/tutorials/10_effective_dimension.ipynb
index 21e656f67..f3357aa12 100644
--- a/docs/tutorials/10_effective_dimension.ipynb
+++ b/docs/tutorials/10_effective_dimension.ipynb
@@ -73,8 +73,8 @@
     "from IPython.display import clear_output\n",
     "from qiskit import QuantumCircuit\n",
     "from qiskit.circuit.library import ZFeatureMap, RealAmplitudes\n",
-    "from qiskit_algorithms.optimizers import COBYLA\n",
-    "from qiskit_algorithms.utils import algorithm_globals\n",
+    "from qiskit_machine_learning.optimizers import COBYLA\n",
+    "from qiskit_machine_learning.utils import algorithm_globals\n",
     "from sklearn.datasets import make_classification\n",
     "from sklearn.preprocessing import MinMaxScaler\n",
     "\n",
diff --git a/docs/tutorials/11_quantum_convolutional_neural_networks.ipynb b/docs/tutorials/11_quantum_convolutional_neural_networks.ipynb
index b9d297273..c57cf3b9f 100644
--- a/docs/tutorials/11_quantum_convolutional_neural_networks.ipynb
+++ b/docs/tutorials/11_quantum_convolutional_neural_networks.ipynb
@@ -51,8 +51,8 @@
     "from qiskit.circuit import ParameterVector\n",
     "from qiskit.circuit.library import ZFeatureMap\n",
     "from qiskit.quantum_info import SparsePauliOp\n",
-    "from qiskit_algorithms.optimizers import COBYLA\n",
-    "from qiskit_algorithms.utils import algorithm_globals\n",
+    "from qiskit_machine_learning.optimizers import COBYLA\n",
+    "from qiskit_machine_learning.utils import algorithm_globals\n",
     "from qiskit_machine_learning.algorithms.classifiers import NeuralNetworkClassifier\n",
     "from qiskit_machine_learning.neural_networks import EstimatorQNN\n",
     "from sklearn.model_selection import train_test_split\n",
diff --git a/docs/tutorials/12_quantum_autoencoder.ipynb b/docs/tutorials/12_quantum_autoencoder.ipynb
index 139f1c14e..e13425c36 100644
--- a/docs/tutorials/12_quantum_autoencoder.ipynb
+++ b/docs/tutorials/12_quantum_autoencoder.ipynb
@@ -270,8 +270,8 @@
     "from qiskit import QuantumCircuit\n",
     "from qiskit.circuit.library import RealAmplitudes\n",
     "from qiskit.quantum_info import Statevector\n",
-    "from qiskit_algorithms.optimizers import COBYLA\n",
-    "from qiskit_algorithms.utils import algorithm_globals\n",
+    "from qiskit_machine_learning.optimizers import COBYLA\n",
+    "from qiskit_machine_learning.utils import algorithm_globals\n",
     "\n",
     "from qiskit_machine_learning.circuit.library import RawFeatureVector\n",
     "from qiskit_machine_learning.neural_networks import SamplerQNN\n",
diff --git a/qiskit_machine_learning/__init__.py b/qiskit_machine_learning/__init__.py
index ad23f2a8b..3d9dc498a 100644
--- a/qiskit_machine_learning/__init__.py
+++ b/qiskit_machine_learning/__init__.py
@@ -1,6 +1,6 @@
 # This code is part of a Qiskit project.
 #
-# (C) Copyright IBM 2019, 2023.
+# (C) Copyright IBM 2019, 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
@@ -44,6 +44,6 @@
 """
 
 from .version import __version__
-from .exceptions import QiskitMachineLearningError
+from .exceptions import QiskitMachineLearningError, AlgorithmError
 
-__all__ = ["__version__", "QiskitMachineLearningError"]
+__all__ = ["__version__", "QiskitMachineLearningError", "AlgorithmError"]
diff --git a/qiskit_machine_learning/algorithm_job.py b/qiskit_machine_learning/algorithm_job.py
new file mode 100644
index 000000000..abd6def46
--- /dev/null
+++ b/qiskit_machine_learning/algorithm_job.py
@@ -0,0 +1,45 @@
+# This code is part of a Qiskit project.
+#
+# (C) Copyright IBM 2022, 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
+# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
+#
+# Any modifications or derivative works of this code must retain this
+# copyright notice, and modified files need to carry a notice indicating
+# that they have been altered from the originals.
+
+"""
+AlgorithmJob class
+"""
+from qiskit.primitives.primitive_job import PrimitiveJob
+
+
+class AlgorithmJob(PrimitiveJob):
+    """
+    This class is introduced for typing purposes and provides no
+    additional function beyond that inherited from its parents.
+
+    Update: :meth:`AlgorithmJob.submit()` method added. See its
+    documentation for more info.
+    """
+
+    def submit(self) -> None:
+        """
+        Submit the job for execution.
+
+        For V1 primitives, Qiskit ``PrimitiveJob`` subclassed JobV1 and defined ``submit()``.
+        ``PrimitiveJob`` was updated for V2 primitives, no longer subclasses ``JobV1``, and
+        now has a private ``_submit()`` method, with ``submit()`` being deprecated as of
+        Qiskit version 0.46. This maintains the ``submit()`` for ``AlgorithmJob`` here as
+        it's called in many places for such a job. An alternative could be to make
+        0.46 the required minimum version and alter all algorithm's call sites to use
+        ``_submit()`` and make this an empty class again as it once was. For now this
+        way maintains compatibility with the current min version of 0.44.
+        """
+        # TODO: Considering changing this in the future - see above docstring.
+        try:
+            super()._submit()
+        except AttributeError:
+            super().submit()  # pylint: disable=no-member
diff --git a/qiskit_machine_learning/algorithm_result.py b/qiskit_machine_learning/algorithm_result.py
new file mode 100644
index 000000000..0d1956f01
--- /dev/null
+++ b/qiskit_machine_learning/algorithm_result.py
@@ -0,0 +1,64 @@
+# This code is part of a Qiskit project.
+#
+# (C) Copyright IBM 2020, 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
+# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
+#
+# Any modifications or derivative works of this code must retain this
+# copyright notice, and modified files need to carry a notice indicating
+# that they have been altered from the originals.
+
+"""
+This module implements the abstract base class for algorithm results.
+"""
+
+from abc import ABC
+import inspect
+import pprint
+
+
+class AlgorithmResult(ABC):
+    """Abstract Base Class for algorithm results."""
+
+    def __str__(self) -> str:
+        result = {}
+        for name, value in inspect.getmembers(self):
+            if (
+                not name.startswith("_")
+                and not inspect.ismethod(value)
+                and not inspect.isfunction(value)
+                and hasattr(self, name)
+            ):
+                result[name] = value
+
+        return pprint.pformat(result, indent=4)
+
+    def combine(self, result: "AlgorithmResult") -> None:
+        """
+        Any property from the argument that exists in the receiver is
+        updated.
+        Args:
+            result: Argument result with properties to be set.
+        Raises:
+            TypeError: Argument is None
+        """
+        if result is None:
+            raise TypeError("Argument result expected.")
+        if result == self:
+            return
+
+        # find any result public property that exists in the receiver
+        for name, value in inspect.getmembers(result):
+            if (
+                not name.startswith("_")
+                and not inspect.ismethod(value)
+                and not inspect.isfunction(value)
+                and hasattr(self, name)
+            ):
+                try:
+                    setattr(self, name, value)
+                except AttributeError:
+                    # some attributes may be read only
+                    pass
diff --git a/qiskit_machine_learning/algorithms/classifiers/neural_network_classifier.py b/qiskit_machine_learning/algorithms/classifiers/neural_network_classifier.py
index 096b64301..37595caa1 100644
--- a/qiskit_machine_learning/algorithms/classifiers/neural_network_classifier.py
+++ b/qiskit_machine_learning/algorithms/classifiers/neural_network_classifier.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
@@ -17,7 +17,6 @@
 
 import numpy as np
 import scipy.sparse
-from qiskit_algorithms.optimizers import Optimizer, OptimizerResult, Minimizer
 from scipy.sparse import spmatrix
 from sklearn.base import ClassifierMixin
 from sklearn.exceptions import NotFittedError
@@ -31,6 +30,7 @@
     ObjectiveFunction,
 )
 from ..trainable_model import TrainableModel
+from ...optimizers import Optimizer, OptimizerResult, Minimizer
 from ...exceptions import QiskitMachineLearningError
 from ...neural_networks import NeuralNetwork
 from ...utils.loss_functions import Loss
@@ -76,9 +76,9 @@ def __init__(
                 loss). In case of a one-dimensional categorical output, this option determines how
                 to encode the target data (i.e. one-hot or integer encoding).
             optimizer: An instance of an optimizer or a callable to be used in training.
-                Refer to  :class:`~qiskit_algorithms.optimizers.Minimizer` for more information on
+                Refer to  :class:`~qiskit_machine_learning.optimizers.Minimizer` for more information on
                 the callable protocol. When `None` defaults to
-                :class:`~qiskit_algorithms.optimizers.SLSQP`.
+                :class:`~qiskit_machine_learning.optimizers.SLSQP`.
             warm_start: Use weights from previous fit to start next fit.
             initial_point: Initial point for the optimizer to start from.
             callback: a reference to a user's callback function that has two parameters and
diff --git a/qiskit_machine_learning/algorithms/classifiers/pegasos_qsvc.py b/qiskit_machine_learning/algorithms/classifiers/pegasos_qsvc.py
index 1b496d6f5..cae4c0e74 100644
--- a/qiskit_machine_learning/algorithms/classifiers/pegasos_qsvc.py
+++ b/qiskit_machine_learning/algorithms/classifiers/pegasos_qsvc.py
@@ -1,6 +1,6 @@
 # This code is part of a Qiskit project.
 #
-# (C) Copyright IBM 2022, 2023.
+# (C) Copyright IBM 2022, 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
@@ -18,12 +18,12 @@
 from typing import Dict
 
 import numpy as np
-from qiskit_algorithms.utils import algorithm_globals
 from sklearn.base import ClassifierMixin
 
 from ...algorithms.serializable_model import SerializableModelMixin
 from ...exceptions import QiskitMachineLearningError
 from ...kernels import BaseKernel, FidelityQuantumKernel
+from ...utils import algorithm_globals
 
 
 logger = logging.getLogger(__name__)
diff --git a/qiskit_machine_learning/algorithms/classifiers/qsvc.py b/qiskit_machine_learning/algorithms/classifiers/qsvc.py
index 30cf30d29..a67cfafd0 100644
--- a/qiskit_machine_learning/algorithms/classifiers/qsvc.py
+++ b/qiskit_machine_learning/algorithms/classifiers/qsvc.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,13 +15,14 @@
 import warnings
 from typing import Optional
 
-from qiskit_algorithms.utils import algorithm_globals
 from sklearn.svm import SVC
 
 from qiskit_machine_learning.algorithms.serializable_model import SerializableModelMixin
 from qiskit_machine_learning.exceptions import QiskitMachineLearningWarning
 from qiskit_machine_learning.kernels import BaseKernel, FidelityQuantumKernel
 
+from ...utils import algorithm_globals
+
 
 class QSVC(SVC, SerializableModelMixin):
     r"""Quantum Support Vector Classifier that extends the scikit-learn
diff --git a/qiskit_machine_learning/algorithms/classifiers/vqc.py b/qiskit_machine_learning/algorithms/classifiers/vqc.py
index d0ef96341..1f3c4238b 100644
--- a/qiskit_machine_learning/algorithms/classifiers/vqc.py
+++ b/qiskit_machine_learning/algorithms/classifiers/vqc.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
@@ -18,9 +18,9 @@
 
 from qiskit import QuantumCircuit
 from qiskit.primitives import BaseSampler
-from qiskit_algorithms.optimizers import Optimizer, OptimizerResult, Minimizer
 
 from ...neural_networks import SamplerQNN
+from ...optimizers import Optimizer, OptimizerResult, Minimizer
 from ...utils import derive_num_qubits_feature_map_ansatz
 from ...utils.loss_functions import Loss
 
@@ -74,9 +74,9 @@ def __init__(
                 circuit is used.
             loss: A target loss function to be used in training. Default value is ``cross_entropy``.
             optimizer: An instance of an optimizer or a callable to be used in training.
-                Refer to :class:`~qiskit_algorithms.optimizers.Minimizer` for more information on
+                Refer to :class:`~qiskit_machine_learning.optimizers.Minimizer` for more information on
                 the callable protocol. When `None` defaults to
-                :class:`~qiskit_algorithms.optimizers.SLSQP`.
+                :class:`~qiskit_machine_learning.optimizers.SLSQP`.
             warm_start: Use weights from previous fit to start next fit.
             initial_point: Initial point for the optimizer to start from.
             callback: a reference to a user's callback function that has two parameters and
diff --git a/qiskit_machine_learning/algorithms/objective_functions.py b/qiskit_machine_learning/algorithms/objective_functions.py
index 252b53314..86d1bfc50 100644
--- a/qiskit_machine_learning/algorithms/objective_functions.py
+++ b/qiskit_machine_learning/algorithms/objective_functions.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
@@ -17,9 +17,9 @@
 
 import numpy as np
 
-import qiskit_machine_learning.optionals as _optionals
-from qiskit_machine_learning.neural_networks import NeuralNetwork
-from qiskit_machine_learning.utils.loss_functions import Loss
+from .. import optionals as _optionals
+from ..neural_networks import NeuralNetwork
+from ..utils.loss_functions import Loss
 
 if _optionals.HAS_SPARSE:
     # pylint: disable=import-error
diff --git a/qiskit_machine_learning/algorithms/regressors/neural_network_regressor.py b/qiskit_machine_learning/algorithms/regressors/neural_network_regressor.py
index 428c0ed5f..94a77f620 100644
--- a/qiskit_machine_learning/algorithms/regressors/neural_network_regressor.py
+++ b/qiskit_machine_learning/algorithms/regressors/neural_network_regressor.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
@@ -14,7 +14,6 @@
 from typing import Optional
 
 import numpy as np
-from qiskit_algorithms.optimizers import OptimizerResult
 from sklearn.base import RegressorMixin
 
 from ..objective_functions import (
@@ -23,6 +22,7 @@
     ObjectiveFunction,
 )
 from ..trainable_model import TrainableModel
+from ...optimizers import OptimizerResult
 
 
 class NeuralNetworkRegressor(TrainableModel, RegressorMixin):
diff --git a/qiskit_machine_learning/algorithms/regressors/qsvr.py b/qiskit_machine_learning/algorithms/regressors/qsvr.py
index ebb24b832..eb059a149 100644
--- a/qiskit_machine_learning/algorithms/regressors/qsvr.py
+++ b/qiskit_machine_learning/algorithms/regressors/qsvr.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
@@ -17,9 +17,9 @@
 
 from sklearn.svm import SVR
 
-from qiskit_machine_learning.algorithms.serializable_model import SerializableModelMixin
-from qiskit_machine_learning.exceptions import QiskitMachineLearningWarning
-from qiskit_machine_learning.kernels import BaseKernel, FidelityQuantumKernel
+from ...algorithms.serializable_model import SerializableModelMixin
+from ...exceptions import QiskitMachineLearningWarning
+from ...kernels import BaseKernel, FidelityQuantumKernel
 
 
 class QSVR(SVR, SerializableModelMixin):
diff --git a/qiskit_machine_learning/algorithms/regressors/vqr.py b/qiskit_machine_learning/algorithms/regressors/vqr.py
index b33d2930d..a26499c87 100644
--- a/qiskit_machine_learning/algorithms/regressors/vqr.py
+++ b/qiskit_machine_learning/algorithms/regressors/vqr.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
@@ -18,10 +18,10 @@
 from qiskit import QuantumCircuit
 from qiskit.primitives import BaseEstimator
 from qiskit.quantum_info.operators.base_operator import BaseOperator
-from qiskit_algorithms.optimizers import Optimizer, Minimizer
 
 from .neural_network_regressor import NeuralNetworkRegressor
 from ...neural_networks import EstimatorQNN
+from ...optimizers import Optimizer, Minimizer
 from ...utils import derive_num_qubits_feature_map_ansatz
 from ...utils.loss_functions import Loss
 
@@ -61,9 +61,9 @@ def __init__(
                 use the default :math:`Z^{\otimes num\_qubits}` observable.
             loss: A target loss function to be used in training. Default is squared error.
             optimizer: An instance of an optimizer or a callable to be used in training.
-                Refer to :class:`~qiskit_algorithms.optimizers.Minimizer` for more information on
+                Refer to :class:`~qiskit_machine_learning.optimizers.Minimizer` for more information on
                 the callable protocol. When `None` defaults to
-                :class:`~qiskit_algorithms.optimizers.SLSQP`.
+                :class:`~qiskit_machine_learning.optimizers.SLSQP`.
             warm_start: Use weights from previous fit to start next fit.
             initial_point: Initial point for the optimizer to start from.
             callback: A reference to a user's callback function that has two parameters and
diff --git a/qiskit_machine_learning/algorithms/trainable_model.py b/qiskit_machine_learning/algorithms/trainable_model.py
index d74eea8d9..efd7b1796 100644
--- a/qiskit_machine_learning/algorithms/trainable_model.py
+++ b/qiskit_machine_learning/algorithms/trainable_model.py
@@ -14,23 +14,22 @@
 
 from abc import abstractmethod
 from typing import Callable
-
 import numpy as np
-from qiskit_algorithms.optimizers import Optimizer, SLSQP, OptimizerResult, Minimizer
-from qiskit_algorithms.utils import algorithm_globals
 
 from qiskit_machine_learning import QiskitMachineLearningError
-from qiskit_machine_learning.neural_networks import NeuralNetwork
-from qiskit_machine_learning.utils.loss_functions import (
+
+from .objective_functions import ObjectiveFunction
+from .serializable_model import SerializableModelMixin
+from ..optimizers import Optimizer, SLSQP, OptimizerResult, Minimizer
+from ..utils import algorithm_globals
+from ..neural_networks import NeuralNetwork
+from ..utils.loss_functions import (
     Loss,
     L1Loss,
     L2Loss,
     CrossEntropyLoss,
 )
 
-from .objective_functions import ObjectiveFunction
-from .serializable_model import SerializableModelMixin
-
 
 class TrainableModel(SerializableModelMixin):
     """Base class for ML model that defines a scikit-learn like interface for Estimators."""
@@ -61,9 +60,9 @@ def __init__(
                 'squared_error', 'cross_entropy', or as a loss function
                 implementing the Loss interface.
             optimizer: An instance of an optimizer or a callable to be used in training.
-                Refer to :class:`~qiskit_algorithms.optimizers.Minimizer` for more information on
+                Refer to :class:`~qiskit_machine_learning.optimizers.Minimizer` for more information on
                 the callable protocol. When `None` defaults to
-                :class:`~qiskit_algorithms.optimizers.SLSQP`.
+                :class:`~qiskit_machine_learning.optimizers.SLSQP`.
             warm_start: Use weights from previous fit to start next fit.
             initial_point: Initial point for the optimizer to start from.
             callback: A reference to a user's callback function that has two parameters and
@@ -155,7 +154,7 @@ def weights(self) -> np.ndarray:
     def fit_result(self) -> OptimizerResult:
         """Returns a resulting object from the optimization procedure. Please refer to the
         documentation of the `OptimizerResult
-        <https://qiskit-community.github.io/qiskit-algorithms/stubs/qiskit_algorithms.optimizers.OptimizerResult.html>`_
+        <https://qiskit-community.github.io/qiskit-machine-learning/stubs/qiskit_machine_learning.optimizers.OptimizerResult.html>`_
         class for more details.
 
         Raises:
@@ -245,7 +244,7 @@ def _choose_initial_point(self) -> np.ndarray:
             An array as an initial point
         """
         if self._warm_start and self._fit_result is not None:
-            self._initial_point = self._fit_result.x
+            self._initial_point = self._fit_result.x  # type: ignore[assignment]
         elif self._initial_point is None:
             self._initial_point = algorithm_globals.random.random(self._neural_network.num_weights)
         return self._initial_point
@@ -288,13 +287,13 @@ def _minimize(self, function: ObjectiveFunction) -> OptimizerResult:
 
         initial_point = self._choose_initial_point()
         if callable(self._optimizer):
-            optimizer_result = self._optimizer(
+            optimizer_result = self._optimizer(  # type: ignore[call-arg]
                 fun=objective, x0=initial_point, jac=function.gradient
             )
         else:
             optimizer_result = self._optimizer.minimize(
                 fun=objective,
                 x0=initial_point,
-                jac=function.gradient,
+                jac=function.gradient,  # type: ignore[arg-type]
             )
         return optimizer_result
diff --git a/qiskit_machine_learning/circuit/library/qnn_circuit.py b/qiskit_machine_learning/circuit/library/qnn_circuit.py
index d3a99c2fa..7385a8f46 100644
--- a/qiskit_machine_learning/circuit/library/qnn_circuit.py
+++ b/qiskit_machine_learning/circuit/library/qnn_circuit.py
@@ -1,6 +1,6 @@
 # This code is part of a Qiskit project.
 #
-# (C) Copyright IBM 2023.
+# (C) Copyright IBM 2023, 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
@@ -13,12 +13,15 @@
 """The QNN circuit."""
 from __future__ import annotations
 from typing import List
+
 from qiskit.circuit import QuantumRegister, QuantumCircuit
 from qiskit.circuit.parametertable import ParameterView
 from qiskit.circuit.library import BlueprintCircuit
-from qiskit_machine_learning.utils import derive_num_qubits_feature_map_ansatz
+
 from qiskit_machine_learning import QiskitMachineLearningError
 
+from ...utils import derive_num_qubits_feature_map_ansatz
+
 
 class QNNCircuit(BlueprintCircuit):
     """
diff --git a/qiskit_machine_learning/datasets/ad_hoc.py b/qiskit_machine_learning/datasets/ad_hoc.py
index a5a39accc..f553f74f4 100644
--- a/qiskit_machine_learning/datasets/ad_hoc.py
+++ b/qiskit_machine_learning/datasets/ad_hoc.py
@@ -15,15 +15,16 @@
 """
 from __future__ import annotations
 
-import itertools as it
 from functools import reduce
+import itertools as it
 from typing import Tuple, Dict, List
-
 import numpy as np
-from qiskit.utils import optionals
-from qiskit_algorithms.utils import algorithm_globals
 from sklearn import preprocessing
 
+from qiskit.utils import optionals
+
+from ..utils import algorithm_globals
+
 
 def ad_hoc_data(
     training_size: int,
diff --git a/qiskit_machine_learning/exceptions.py b/qiskit_machine_learning/exceptions.py
index 65f687f15..ac2befee7 100644
--- a/qiskit_machine_learning/exceptions.py
+++ b/qiskit_machine_learning/exceptions.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
@@ -32,3 +32,9 @@ def __init__(self, *message):
     def __str__(self):
         """Return the message."""
         return repr(self.message)
+
+
+class AlgorithmError(QiskitError):
+    """For Algorithm specific errors."""
+
+    pass
diff --git a/qiskit_machine_learning/gradients/__init__.py b/qiskit_machine_learning/gradients/__init__.py
new file mode 100644
index 000000000..9f24daa53
--- /dev/null
+++ b/qiskit_machine_learning/gradients/__init__.py
@@ -0,0 +1,109 @@
+# This code is part of a Qiskit project.
+#
+# (C) Copyright IBM 2022, 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
+# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
+#
+# Any modifications or derivative works of this code must retain this
+# copyright notice, and modified files need to carry a notice indicating
+# that they have been altered from the originals.
+
+"""
+Gradients (:mod:`qiskit_machine_learning.gradients`)
+==============================================
+Algorithms to calculate the gradient of a quantum circuit.
+
+.. currentmodule:: qiskit_machine_learning.gradients
+
+Base Classes
+------------
+
+.. autosummary::
+   :toctree: ../stubs/
+   :nosignatures:
+
+   BaseEstimatorGradient
+   BaseQGT
+   BaseSamplerGradient
+   EstimatorGradientResult
+   SamplerGradientResult
+   QGTResult
+
+Linear Combination of Unitaries
+-------------------------------
+
+.. autosummary::
+   :toctree: ../stubs/
+   :nosignatures:
+
+   LinCombEstimatorGradient
+   LinCombSamplerGradient
+   LinCombQGT
+
+Parameter Shift Rules
+---------------------
+
+.. autosummary::
+   :toctree: ../stubs/
+   :nosignatures:
+
+   ParamShiftEstimatorGradient
+   ParamShiftSamplerGradient
+
+Quantum Fisher Information
+--------------------------
+
+.. autosummary::
+   :toctree: ../stubs/
+   :nosignatures:
+
+   QFIResult
+   QFI
+
+Simultaneous Perturbation Stochastic Approximation
+--------------------------------------------------
+
+.. autosummary::
+   :toctree: ../stubs/
+   :nosignatures:
+
+   SPSAEstimatorGradient
+   SPSASamplerGradient
+"""
+
+from .base.base_estimator_gradient import BaseEstimatorGradient
+from .base.base_qgt import BaseQGT
+from .base.base_sampler_gradient import BaseSamplerGradient
+from .base.estimator_gradient_result import EstimatorGradientResult
+from .lin_comb.lin_comb_estimator_gradient import DerivativeType, LinCombEstimatorGradient
+from .lin_comb.lin_comb_qgt import LinCombQGT
+from .lin_comb.lin_comb_sampler_gradient import LinCombSamplerGradient
+from .param_shift.param_shift_estimator_gradient import ParamShiftEstimatorGradient
+from .param_shift.param_shift_sampler_gradient import ParamShiftSamplerGradient
+from .qfi import QFI
+from .qfi_result import QFIResult
+from .base.qgt_result import QGTResult
+from .base.sampler_gradient_result import SamplerGradientResult
+from .spsa.spsa_estimator_gradient import SPSAEstimatorGradient
+from .spsa.spsa_sampler_gradient import SPSASamplerGradient
+
+__all__ = [
+    "BaseEstimatorGradient",
+    "BaseQGT",
+    "BaseSamplerGradient",
+    "DerivativeType",
+    "EstimatorGradientResult",
+    "LinCombEstimatorGradient",
+    "LinCombQGT",
+    "LinCombSamplerGradient",
+    "ParamShiftEstimatorGradient",
+    "ParamShiftSamplerGradient",
+    "QFI",
+    "QFIResult",
+    "QGTResult",
+    "SamplerGradientResult",
+    "SPSAEstimatorGradient",
+    "SPSASamplerGradient",
+]
diff --git a/qiskit_machine_learning/gradients/base/__init__.py b/qiskit_machine_learning/gradients/base/__init__.py
new file mode 100644
index 000000000..c870af8ba
--- /dev/null
+++ b/qiskit_machine_learning/gradients/base/__init__.py
@@ -0,0 +1,11 @@
+# This code is part of a Qiskit project.
+#
+# (C) Copyright IBM 2022, 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
+# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
+#
+# Any modifications or derivative works of this code must retain this
+# copyright notice, and modified files need to carry a notice indicating
+# that they have been altered from the originals.
diff --git a/qiskit_machine_learning/gradients/base/base_estimator_gradient.py b/qiskit_machine_learning/gradients/base/base_estimator_gradient.py
new file mode 100644
index 000000000..edfe80fd0
--- /dev/null
+++ b/qiskit_machine_learning/gradients/base/base_estimator_gradient.py
@@ -0,0 +1,363 @@
+# This code is part of a Qiskit project.
+#
+# (C) Copyright IBM 2022, 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
+# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
+#
+# Any modifications or derivative works of this code must retain this
+# copyright notice, and modified files need to carry a notice indicating
+# that they have been altered from the originals.
+
+"""
+Abstract base class of gradient for ``Estimator``.
+"""
+
+from __future__ import annotations
+
+from abc import ABC, abstractmethod
+from collections.abc import Sequence
+from copy import copy
+
+import numpy as np
+
+from qiskit.circuit import Parameter, ParameterExpression, QuantumCircuit
+from qiskit.primitives import BaseEstimator
+from qiskit.primitives.utils import _circuit_key
+from qiskit.providers import Options
+from qiskit.quantum_info.operators.base_operator import BaseOperator
+from qiskit.transpiler.passes import TranslateParameterizedGates
+
+from .estimator_gradient_result import EstimatorGradientResult
+from ..utils import (
+    DerivativeType,
+    GradientCircuit,
+    _assign_unique_parameters,
+    _make_gradient_parameters,
+    _make_gradient_parameter_values,
+)
+
+from ...algorithm_job import AlgorithmJob
+
+
+class BaseEstimatorGradient(ABC):
+    """Base class for an ``EstimatorGradient`` to compute the gradients of the expectation value."""
+
+    def __init__(
+        self,
+        estimator: BaseEstimator,
+        options: Options | None = None,
+        derivative_type: DerivativeType = DerivativeType.REAL,
+    ):
+        r"""
+        Args:
+            estimator: The estimator used to compute the gradients.
+            options: Primitive backend runtime options used for circuit execution.
+                The order of priority is: options in ``run`` method > gradient's
+                default options > primitive's default setting.
+                Higher priority setting overrides lower priority setting
+            derivative_type: The type of derivative. Can be either ``DerivativeType.REAL``
+                ``DerivativeType.IMAG``, or ``DerivativeType.COMPLEX``.
+
+                    - ``DerivativeType.REAL`` computes :math:`2 \mathrm{Re}[⟨ψ(ω)|O(θ)|dω ψ(ω)〉]`.
+                    - ``DerivativeType.IMAG`` computes :math:`2 \mathrm{Im}[⟨ψ(ω)|O(θ)|dω ψ(ω)〉]`.
+                    - ``DerivativeType.COMPLEX`` computes :math:`2 ⟨ψ(ω)|O(θ)|dω ψ(ω)〉`.
+
+                Defaults to ``DerivativeType.REAL``, as this yields e.g. the commonly-used energy
+                gradient and this type is the only supported type for function-level schemes like
+                finite difference.
+        """
+        self._estimator: BaseEstimator = estimator
+        self._default_options = Options()
+        if options is not None:
+            self._default_options.update_options(**options)
+        self._derivative_type = derivative_type
+
+        self._gradient_circuit_cache: dict[
+            tuple,
+            GradientCircuit,
+        ] = {}
+
+    @property
+    def derivative_type(self) -> DerivativeType:
+        """Return the derivative type (real, imaginary or complex).
+
+        Returns:
+            The derivative type.
+        """
+        return self._derivative_type
+
+    def run(
+        self,
+        circuits: Sequence[QuantumCircuit],
+        observables: Sequence[BaseOperator],
+        parameter_values: Sequence[Sequence[float]],
+        parameters: Sequence[Sequence[Parameter] | None] | None = None,
+        **options,
+    ) -> AlgorithmJob:
+        """Run the job of the estimator gradient on the given circuits.
+
+        Args:
+            circuits: The list of quantum circuits to compute the gradients.
+            observables: The list of observables.
+            parameter_values: The list of parameter values to be bound to the circuit.
+            parameters: The sequence of parameters to calculate only the gradients of
+                the specified parameters. Each sequence of parameters corresponds to a circuit in
+                ``circuits``. Defaults to None, which means that the gradients of all parameters in
+                each circuit are calculated. None in the sequence means that the gradients of all
+                parameters in the corresponding circuit are calculated.
+            options: Primitive backend runtime options used for circuit execution.
+                The order of priority is: options in ``run`` method > gradient's
+                default options > primitive's default setting.
+                Higher priority setting overrides lower priority setting
+
+        Returns:
+            The job object of the gradients of the expectation values. The i-th result corresponds to
+            ``circuits[i]`` evaluated with parameters bound as ``parameter_values[i]``. The j-th
+            element of the i-th result corresponds to the gradient of the i-th circuit with respect
+            to the j-th parameter.
+
+        Raises:
+            ValueError: Invalid arguments are given.
+        """
+        if isinstance(circuits, QuantumCircuit):
+            # Allow a single circuit to be passed in.
+            circuits = (circuits,)
+        if isinstance(observables, (BaseOperator)):
+            # Allow a single observable to be passed in.
+            observables = (observables,)
+
+        if parameters is None:
+            # If parameters is None, we calculate the gradients of all parameters in each circuit.
+            parameters = [circuit.parameters for circuit in circuits]
+        else:
+            # If parameters is not None, we calculate the gradients of the specified parameters.
+            # None in parameters means that the gradients of all parameters in the corresponding
+            # circuit are calculated.
+            parameters = [
+                params if params is not None else circuits[i].parameters
+                for i, params in enumerate(parameters)
+            ]
+        # Validate the arguments.
+        self._validate_arguments(circuits, observables, parameter_values, parameters)
+        # The priority of run option is as follows:
+        # options in ``run`` method > gradient's default options > primitive's default setting.
+        opts = copy(self._default_options)
+        opts.update_options(**options)
+        # Run the job.
+        job = AlgorithmJob(
+            self._run, circuits, observables, parameter_values, parameters, **opts.__dict__
+        )
+        job.submit()
+        return job
+
+    @abstractmethod
+    def _run(
+        self,
+        circuits: Sequence[QuantumCircuit],
+        observables: Sequence[BaseOperator],
+        parameter_values: Sequence[Sequence[float]],
+        parameters: Sequence[Sequence[Parameter]],
+        **options,
+    ) -> EstimatorGradientResult:
+        """Compute the estimator gradients on the given circuits."""
+        raise NotImplementedError()
+
+    def _preprocess(
+        self,
+        circuits: Sequence[QuantumCircuit],
+        parameter_values: Sequence[Sequence[float]],
+        parameters: Sequence[Sequence[Parameter]],
+        supported_gates: Sequence[str],
+    ) -> tuple[Sequence[QuantumCircuit], Sequence[Sequence[float]], Sequence[Sequence[Parameter]]]:
+        """Preprocess the gradient. This makes a gradient circuit for each circuit. The gradient
+        circuit is a transpiled circuit by using the supported gates, and has unique parameters.
+        ``parameter_values`` and ``parameters`` are also updated to match the gradient circuit.
+
+        Args:
+            circuits: The list of quantum circuits to compute the gradients.
+            parameter_values: The list of parameter values to be bound to the circuit.
+            parameters: The sequence of parameters to calculate only the gradients of the specified
+                parameters.
+            supported_gates: The supported gates used to transpile the circuit.
+
+        Returns:
+            The list of gradient circuits, the list of parameter values, and the list of parameters.
+            parameter_values and parameters are updated to match the gradient circuit.
+        """
+        translator = TranslateParameterizedGates(supported_gates)
+        g_circuits: list[QuantumCircuit] = []
+        g_parameter_values: list[Sequence[float]] = []
+        g_parameters: list[Sequence[Parameter]] = []
+        for circuit, parameter_value_, parameters_ in zip(circuits, parameter_values, parameters):
+            circuit_key = _circuit_key(circuit)
+            if circuit_key not in self._gradient_circuit_cache:
+                unrolled = translator(circuit)
+                self._gradient_circuit_cache[circuit_key] = _assign_unique_parameters(unrolled)
+            gradient_circuit = self._gradient_circuit_cache[circuit_key]
+            g_circuits.append(gradient_circuit.gradient_circuit)
+            g_parameter_values.append(
+                _make_gradient_parameter_values(  # type: ignore[arg-type]
+                    circuit, gradient_circuit, parameter_value_
+                )
+            )
+            g_parameters.append(_make_gradient_parameters(gradient_circuit, parameters_))
+        return g_circuits, g_parameter_values, g_parameters
+
+    def _postprocess(
+        self,
+        results: EstimatorGradientResult,
+        circuits: Sequence[QuantumCircuit],
+        parameter_values: Sequence[Sequence[float]],
+        parameters: Sequence[Sequence[Parameter]],
+    ) -> EstimatorGradientResult:
+        """Postprocess the gradients. This method computes the gradient of the original circuits
+        by applying the chain rule to the gradient of the circuits with unique parameters.
+
+        Args:
+            results: The computed gradients for the circuits with unique parameters.
+            circuits: The list of original circuits submitted for gradient computation.
+            parameter_values: The list of parameter values to be bound to the circuits.
+            parameters: The sequence of parameters to calculate only the gradients of the specified
+                parameters.
+
+        Returns:
+            The gradients of the original circuits.
+        """
+        gradients, metadata = [], []
+        for idx, (circuit, parameter_values_, parameters_) in enumerate(
+            zip(circuits, parameter_values, parameters)
+        ):
+            gradient = np.zeros(len(parameters_))
+            if (
+                "derivative_type" in results.metadata[idx]
+                and results.metadata[idx]["derivative_type"] == DerivativeType.COMPLEX
+            ):
+                # If the derivative type is complex, cast the gradient to complex.
+                gradient = gradient.astype("complex")
+            gradient_circuit = self._gradient_circuit_cache[_circuit_key(circuit)]
+            g_parameters = _make_gradient_parameters(gradient_circuit, parameters_)
+            # Make a map from the gradient parameter to the respective index in the gradient.
+            g_parameter_indices = {param: i for i, param in enumerate(g_parameters)}
+            # Compute the original gradient from the gradient of the gradient circuit
+            # by using the chain rule.
+            for i, parameter in enumerate(parameters_):
+                for g_parameter, coeff in gradient_circuit.parameter_map[parameter]:
+                    # Compute the coefficient
+                    if isinstance(coeff, ParameterExpression):
+                        local_map = {
+                            p: parameter_values_[circuit.parameters.data.index(p)]
+                            for p in coeff.parameters
+                        }
+                        bound_coeff = coeff.bind(local_map)
+                    else:
+                        bound_coeff = coeff
+                    # The original gradient is a sum of the gradients of the parameters in the
+                    # gradient circuit multiplied by the coefficients.
+                    gradient[i] += (
+                        float(bound_coeff)
+                        * results.gradients[idx][g_parameter_indices[g_parameter]]
+                    )
+            gradients.append(gradient)
+            metadata.append({"parameters": parameters_})
+        return EstimatorGradientResult(
+            gradients=gradients, metadata=metadata, options=results.options
+        )
+
+    @staticmethod
+    def _validate_arguments(
+        circuits: Sequence[QuantumCircuit],
+        observables: Sequence[BaseOperator],
+        parameter_values: Sequence[Sequence[float]],
+        parameters: Sequence[Sequence[Parameter]],
+    ) -> None:
+        """Validate the arguments of the ``run`` method.
+
+        Args:
+            circuits: The list of quantum circuits to compute the gradients.
+            observables: The list of observables.
+            parameter_values: The list of parameter values to be bound to the circuit.
+            parameters: The sequence of parameters to calculate only the gradients of the specified
+                parameters.
+
+        Raises:
+            ValueError: Invalid arguments are given.
+        """
+        if len(circuits) != len(parameter_values):
+            raise ValueError(
+                f"The number of circuits ({len(circuits)}) does not match "
+                f"the number of parameter value sets ({len(parameter_values)})."
+            )
+
+        for i, (circuit, parameter_value) in enumerate(zip(circuits, parameter_values)):
+            if not circuit.num_parameters:
+                raise ValueError(f"The {i}-th circuit is not parameterised.")
+            if len(parameter_value) != circuit.num_parameters:
+                raise ValueError(
+                    f"The number of values ({len(parameter_value)}) does not match "
+                    f"the number of parameters ({circuit.num_parameters}) for the {i}-th circuit."
+                )
+
+        if len(circuits) != len(observables):
+            raise ValueError(
+                f"The number of circuits ({len(circuits)}) does not match "
+                f"the number of observables ({len(observables)})."
+            )
+
+        for i, (circuit, observable) in enumerate(zip(circuits, observables)):
+            if circuit.num_qubits != observable.num_qubits:
+                raise ValueError(
+                    f"The number of qubits of the {i}-th circuit ({circuit.num_qubits}) does "
+                    f"not match the number of qubits of the {i}-th observable "
+                    f"({observable.num_qubits})."
+                )
+
+        if len(circuits) != len(parameters):
+            raise ValueError(
+                f"The number of circuits ({len(circuits)}) does not match "
+                f"the number of the list of specified parameters ({len(parameters)})."
+            )
+
+        for i, (circuit, parameters_) in enumerate(zip(circuits, parameters)):
+            if not set(parameters_).issubset(circuit.parameters):
+                raise ValueError(
+                    f"The {i}-th parameters contains parameters not present in the "
+                    f"{i}-th circuit."
+                )
+
+    @property
+    def options(self) -> Options:
+        """Return the union of estimator options setting and gradient default options,
+        where, if the same field is set in both, the gradient's default options override
+        the primitive's default setting.
+
+        Returns:
+            The gradient default + estimator options.
+        """
+        return self._get_local_options(self._default_options.__dict__)
+
+    def update_default_options(self, **options):
+        """Update the gradient's default options setting.
+
+        Args:
+            **options: The fields to update the default options.
+        """
+
+        self._default_options.update_options(**options)
+
+    def _get_local_options(self, options: Options) -> Options:
+        """Return the union of the primitive's default setting,
+        the gradient default options, and the options in the ``run`` method.
+        The order of priority is: options in ``run`` method > gradient's
+                default options > primitive's default setting.
+
+        Args:
+            options: The fields to update the options
+
+        Returns:
+            The gradient default + estimator + run options.
+        """
+        opts = copy(self._estimator.options)
+        opts.update_options(**options)
+        return opts
diff --git a/qiskit_machine_learning/gradients/base/base_qgt.py b/qiskit_machine_learning/gradients/base/base_qgt.py
new file mode 100644
index 000000000..9094a26a5
--- /dev/null
+++ b/qiskit_machine_learning/gradients/base/base_qgt.py
@@ -0,0 +1,388 @@
+# This code is part of a Qiskit project.
+#
+# (C) Copyright IBM 2022, 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
+# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
+#
+# Any modifications or derivative works of this code must retain this
+# copyright notice, and modified files need to carry a notice indicating
+# that they have been altered from the originals.
+
+"""
+Abstract base class of the Quantum Geometric Tensor (QGT).
+"""
+
+from __future__ import annotations
+
+from abc import ABC, abstractmethod
+from collections.abc import Sequence
+from copy import copy
+
+import numpy as np
+
+from qiskit.circuit import Parameter, ParameterExpression, QuantumCircuit
+from qiskit.primitives import BaseEstimator
+from qiskit.primitives.utils import _circuit_key
+from qiskit.providers import Options
+from qiskit.transpiler.passes import TranslateParameterizedGates
+
+from .qgt_result import QGTResult
+from ..utils import (
+    DerivativeType,
+    GradientCircuit,
+    _assign_unique_parameters,
+    _make_gradient_parameters,
+    _make_gradient_parameter_values,
+)
+
+from ...algorithm_job import AlgorithmJob
+
+
+class BaseQGT(ABC):
+    r"""Base class to computes the Quantum Geometric Tensor (QGT) given a pure,
+    parameterized quantum state. QGT is defined as:
+
+    .. math::
+
+        \mathrm{QGT}_{ij}= \langle \partial_i \psi | \partial_j \psi \rangle
+            - \langle\partial_i \psi | \psi \rangle \langle\psi | \partial_j \psi \rangle.
+    """
+
+    def __init__(
+        self,
+        estimator: BaseEstimator,
+        phase_fix: bool = True,
+        derivative_type: DerivativeType = DerivativeType.COMPLEX,
+        options: Options | None = None,
+    ):
+        r"""
+        Args:
+            estimator: The estimator used to compute the QGT.
+            phase_fix: Whether to calculate the second term (phase fix) of the QGT, which is
+                :math:`\langle\partial_i \psi | \psi \rangle \langle\psi | \partial_j \psi \rangle`.
+                Defaults to ``True``.
+            derivative_type: The type of derivative. Can be either ``DerivativeType.REAL``
+                ``DerivativeType.IMAG``, or ``DerivativeType.COMPLEX``. Defaults to
+                ``DerivativeType.REAL``.
+
+                - ``DerivativeType.REAL`` computes
+
+                .. math::
+
+                    \mathrm{Re(QGT)}_{ij}= \mathrm{Re}[\langle \partial_i \psi | \partial_j \psi \rangle
+                        - \langle\partial_i \psi | \psi \rangle \langle\psi | \partial_j \psi \rangle].
+
+                - ``DerivativeType.IMAG`` computes
+
+                .. math::
+
+                    \mathrm{Im(QGT)}_{ij}= \mathrm{Im}[\langle \partial_i \psi | \partial_j \psi \rangle
+                        - \langle\partial_i \psi | \psi \rangle \langle\psi | \partial_j \psi \rangle].
+
+                - ``DerivativeType.COMPLEX`` computes
+
+                .. math::
+
+                    \mathrm{QGT}_{ij}= [\langle \partial_i \psi | \partial_j \psi \rangle
+                        - \langle\partial_i \psi | \psi \rangle \langle\psi | \partial_j \psi \rangle].
+
+            options: Backend runtime options used for circuit execution. The order of priority is:
+                options in ``run`` method > QGT's default options > primitive's default
+                setting. Higher priority setting overrides lower priority setting.
+        """
+        self._estimator: BaseEstimator = estimator
+        self._phase_fix: bool = phase_fix
+        self._derivative_type: DerivativeType = derivative_type
+        self._default_options = Options()
+        if options is not None:
+            self._default_options.update_options(**options)
+        self._qgt_circuit_cache: dict[tuple, GradientCircuit] = {}
+        self._gradient_circuit_cache: dict[tuple, GradientCircuit] = {}
+
+    @property
+    def derivative_type(self) -> DerivativeType:
+        """The derivative type."""
+        return self._derivative_type
+
+    @derivative_type.setter
+    def derivative_type(self, derivative_type: DerivativeType) -> None:
+        """Set the derivative type."""
+        self._derivative_type = derivative_type
+
+    def run(
+        self,
+        circuits: Sequence[QuantumCircuit],
+        parameter_values: Sequence[Sequence[float]],
+        parameters: Sequence[Sequence[Parameter] | None] | None = None,
+        **options,
+    ) -> AlgorithmJob:
+        """Run the job of the QGTs on the given circuits.
+
+        Args:
+            circuits: The list of quantum circuits to compute the QGTs.
+            parameter_values: The list of parameter values to be bound to the circuit.
+            parameters: The sequence of parameters to calculate only the QGTs of
+                the specified parameters. Each sequence of parameters corresponds to a circuit in
+                ``circuits``. Defaults to None, which means that the QGTs of all parameters in
+                each circuit are calculated.
+            options: Primitive backend runtime options used for circuit execution.
+                The order of priority is: options in ``run`` method > QGT's
+                default options > primitive's default setting.
+                Higher priority setting overrides lower priority setting.
+
+        Returns:
+            The job object of the QGTs of the expectation values. The i-th result corresponds to
+            ``circuits[i]`` evaluated with parameters bound as ``parameter_values[i]``.
+
+        Raises:
+            ValueError: Invalid arguments are given.
+        """
+        if isinstance(circuits, QuantumCircuit):
+            # Allow a single circuit to be passed in.
+            circuits = (circuits,)
+
+        if parameters is None:
+            # If parameters is None, we calculate the gradients of all parameters in each circuit.
+            parameters = [circuit.parameters for circuit in circuits]
+        else:
+            # If parameters is not None, we calculate the gradients of the specified parameters.
+            # None in parameters means that the gradients of all parameters in the corresponding
+            # circuit are calculated.
+            parameters = [
+                params if params is not None else circuits[i].parameters
+                for i, params in enumerate(parameters)
+            ]
+        # Validate the arguments.
+        self._validate_arguments(circuits, parameter_values, parameters)
+        # The priority of run option is as follows:
+        # options in ``run`` method > QGT's default options > primitive's default setting.
+        opts = copy(self._default_options)
+        opts.update_options(**options)
+        job = AlgorithmJob(self._run, circuits, parameter_values, parameters, **opts.__dict__)
+        job.submit()
+        return job
+
+    @abstractmethod
+    def _run(
+        self,
+        circuits: Sequence[QuantumCircuit],
+        parameter_values: Sequence[Sequence[float]],
+        parameters: Sequence[Sequence[Parameter]],
+        **options,
+    ) -> QGTResult:
+        """Compute the QGTs on the given circuits."""
+        raise NotImplementedError()
+
+    def _preprocess(
+        self,
+        circuits: Sequence[QuantumCircuit],
+        parameter_values: Sequence[Sequence[float]],
+        parameters: Sequence[Sequence[Parameter]],
+        supported_gates: Sequence[str],
+    ) -> tuple[Sequence[QuantumCircuit], Sequence[Sequence[float]], Sequence[Sequence[Parameter]]]:
+        """Preprocess the gradient. This makes a gradient circuit for each circuit. The gradient
+        circuit is a transpiled circuit by using the supported gates, and has unique parameters.
+        ``parameter_values`` and ``parameters`` are also updated to match the gradient circuit.
+
+        Args:
+            circuits: The list of quantum circuits to compute the gradients.
+            parameter_values: The list of parameter values to be bound to the circuit.
+            parameters: The sequence of parameters to calculate only the gradients of the specified
+                parameters.
+            supported_gates: The supported gates used to transpile the circuit.
+
+        Returns:
+            The list of gradient circuits, the list of parameter values, and the list of parameters.
+            parameter_values and parameters are updated to match the gradient circuit.
+        """
+        translator = TranslateParameterizedGates(supported_gates)
+        g_circuits: list[QuantumCircuit] = []
+        g_parameter_values: list[Sequence[float]] = []
+        g_parameters: list[Sequence[Parameter]] = []
+        for circuit, parameter_value_, parameters_ in zip(circuits, parameter_values, parameters):
+            circuit_key = _circuit_key(circuit)
+            if circuit_key not in self._gradient_circuit_cache:
+                unrolled = translator(circuit)
+                self._gradient_circuit_cache[circuit_key] = _assign_unique_parameters(unrolled)
+            gradient_circuit = self._gradient_circuit_cache[circuit_key]
+            g_circuits.append(gradient_circuit.gradient_circuit)
+            g_parameter_values.append(
+                _make_gradient_parameter_values(  # type: ignore[arg-type]
+                    circuit, gradient_circuit, parameter_value_
+                )
+            )
+            g_parameters_ = [
+                g_param
+                for g_param in gradient_circuit.gradient_circuit.parameters
+                if g_param in _make_gradient_parameters(gradient_circuit, parameters_)
+            ]
+            g_parameters.append(g_parameters_)
+        return g_circuits, g_parameter_values, g_parameters
+
+    def _postprocess(
+        self,
+        results: QGTResult,
+        circuits: Sequence[QuantumCircuit],
+        parameter_values: Sequence[Sequence[float]],
+        parameters: Sequence[Sequence[Parameter]],
+    ) -> QGTResult:
+        """Postprocess the QGTs. This method computes the QGTs of the original circuits
+        by applying the chain rule to the QGTs of the circuits with unique parameters.
+
+        Args:
+            results: The computed QGT for the circuits with unique parameters.
+            circuits: The list of original circuits submitted for gradient computation.
+            parameter_values: The list of parameter values to be bound to the circuits.
+            parameters: The sequence of parameters to calculate only the gradients of the specified
+                parameters.
+
+        Returns:
+            The QGTs of the original circuits.
+        """
+        qgts, metadata = [], []
+        for idx, (circuit, parameter_values_, parameters_) in enumerate(
+            zip(circuits, parameter_values, parameters)
+        ):
+            dtype = complex if self.derivative_type == DerivativeType.COMPLEX else float
+            qgt: np.ndarray = np.zeros((len(parameters_), len(parameters_)), dtype=dtype)
+
+            gradient_circuit = self._gradient_circuit_cache[_circuit_key(circuit)]
+            g_parameters = _make_gradient_parameters(gradient_circuit, parameters_)
+            # Make a map from the gradient parameter to the respective index in the gradient.
+            # parameters_ = [param for param in circuit.parameters if param in parameters_]
+            g_parameter_indices = [
+                param
+                for param in gradient_circuit.gradient_circuit.parameters
+                if param in g_parameters
+            ]
+            g_parameter_indices_d = {param: i for i, param in enumerate(g_parameter_indices)}
+            rows, cols = np.triu_indices(len(parameters_))
+            for row, col in zip(rows, cols):
+                for g_parameter1, coeff1 in gradient_circuit.parameter_map[parameters_[row]]:
+                    for g_parameter2, coeff2 in gradient_circuit.parameter_map[parameters_[col]]:
+                        if isinstance(coeff1, ParameterExpression):
+                            local_map = {
+                                p: parameter_values_[circuit.parameters.data.index(p)]
+                                for p in coeff1.parameters
+                            }
+                            bound_coeff1 = coeff1.bind(local_map)
+                        else:
+                            bound_coeff1 = coeff1
+                        if isinstance(coeff2, ParameterExpression):
+                            local_map = {
+                                p: parameter_values_[circuit.parameters.data.index(p)]
+                                for p in coeff2.parameters
+                            }
+                            bound_coeff2 = coeff2.bind(local_map)
+                        else:
+                            bound_coeff2 = coeff2
+                        qgt[row, col] += (
+                            float(bound_coeff1)
+                            * float(bound_coeff2)
+                            * results.qgts[idx][
+                                g_parameter_indices_d[g_parameter1],
+                                g_parameter_indices_d[g_parameter2],
+                            ]
+                        )
+
+            if self.derivative_type == DerivativeType.IMAG:
+                qgt += -1 * np.triu(qgt, k=1).T
+            else:
+                qgt += np.triu(qgt, k=1).conjugate().T
+            qgts.append(qgt)
+            metadata.append([{"parameters": parameters_}])
+        return QGTResult(
+            qgts=qgts,
+            derivative_type=self.derivative_type,
+            metadata=metadata,
+            options=results.options,
+        )
+
+    @staticmethod
+    def _validate_arguments(
+        circuits: Sequence[QuantumCircuit],
+        parameter_values: Sequence[Sequence[float]],
+        parameters: Sequence[Sequence[Parameter]],
+    ) -> None:
+        """Validate the arguments of the ``run`` method.
+
+        Args:
+            circuits: The list of quantum circuits to compute the QGTs.
+            parameter_values: The list of parameter values to be bound to the circuits.
+            parameters: The sequence of parameters with respect to which the QGTs should be
+                computed.
+
+        Raises:
+            ValueError: Invalid arguments are given.
+        """
+        if len(circuits) != len(parameter_values):
+            raise ValueError(
+                f"The number of circuits ({len(circuits)}) does not match "
+                f"the number of parameter values ({len(parameter_values)})."
+            )
+
+        if len(circuits) != len(parameters):
+            raise ValueError(
+                f"The number of circuits ({len(circuits)}) does not match "
+                f"the number of the specified parameter sets ({len(parameters)})."
+            )
+
+        for i, (circuit, parameter_value) in enumerate(zip(circuits, parameter_values)):
+            if not circuit.num_parameters:
+                raise ValueError(f"The {i}-th circuit is not parameterised.")
+            if len(parameter_value) != circuit.num_parameters:
+                raise ValueError(
+                    f"The number of values ({len(parameter_value)}) does not match "
+                    f"the number of parameters ({circuit.num_parameters}) for the {i}-th circuit."
+                )
+
+        if len(circuits) != len(parameters):
+            raise ValueError(
+                f"The number of circuits ({len(circuits)}) does not match "
+                f"the number of the list of specified parameters ({len(parameters)})."
+            )
+
+        for i, (circuit, parameters_) in enumerate(zip(circuits, parameters)):
+            if not set(parameters_).issubset(circuit.parameters):
+                raise ValueError(
+                    f"The {i}-th parameters contains parameters not present in the "
+                    f"{i}-th circuit."
+                )
+
+    @property
+    def options(self) -> Options:
+        """Return the union of estimator options setting and QGT default options,
+        where, if the same field is set in both, the QGT's default options override
+        the primitive's default setting.
+
+        Returns:
+            The QGT default + estimator options.
+        """
+        return self._get_local_options(self._default_options.__dict__)
+
+    def update_default_options(self, **options):
+        """Update the gradient's default options setting.
+
+        Args:
+            **options: The fields to update the default options.
+        """
+
+        self._default_options.update_options(**options)
+
+    def _get_local_options(self, options: Options) -> Options:
+        """Return the union of the primitive's default setting,
+        the QGT default options, and the options in the ``run`` method.
+        The order of priority is: options in ``run`` method > QGT's default options > primitive's
+        default setting.
+
+        Args:
+            options: The fields to update the options
+
+        Returns:
+            The QGT default + estimator + run options.
+        """
+        opts = copy(self._estimator.options)
+        opts.update_options(**options)
+        return opts
diff --git a/qiskit_machine_learning/gradients/base/base_sampler_gradient.py b/qiskit_machine_learning/gradients/base/base_sampler_gradient.py
new file mode 100644
index 000000000..9e29b47ab
--- /dev/null
+++ b/qiskit_machine_learning/gradients/base/base_sampler_gradient.py
@@ -0,0 +1,300 @@
+# This code is part of a Qiskit project.
+#
+# (C) Copyright IBM 2022, 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
+# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
+#
+# Any modifications or derivative works of this code must retain this
+# copyright notice, and modified files need to carry a notice indicating
+# that they have been altered from the originals.
+
+"""
+Abstract base class of gradient for ``Sampler``.
+"""
+
+from __future__ import annotations
+
+from abc import ABC, abstractmethod
+from collections import defaultdict
+from collections.abc import Sequence
+from copy import copy
+
+from qiskit.circuit import Parameter, ParameterExpression, QuantumCircuit
+from qiskit.primitives import BaseSampler
+from qiskit.primitives.utils import _circuit_key
+from qiskit.providers import Options
+from qiskit.transpiler.passes import TranslateParameterizedGates
+
+from .sampler_gradient_result import SamplerGradientResult
+from ..utils import (
+    GradientCircuit,
+    _assign_unique_parameters,
+    _make_gradient_parameters,
+    _make_gradient_parameter_values,
+)
+
+from ...algorithm_job import AlgorithmJob
+
+
+class BaseSamplerGradient(ABC):
+    """Base class for a ``SamplerGradient`` to compute the gradients of the sampling probability."""
+
+    def __init__(self, sampler: BaseSampler, options: Options | None = None):
+        """
+        Args:
+            sampler: The sampler used to compute the gradients.
+            options: Primitive backend runtime options used for circuit execution.
+                The order of priority is: options in ``run`` method > gradient's
+                default options > primitive's default setting.
+                Higher priority setting overrides lower priority setting
+        """
+        self._sampler: BaseSampler = sampler
+        self._default_options = Options()
+        if options is not None:
+            self._default_options.update_options(**options)
+        self._gradient_circuit_cache: dict[tuple, GradientCircuit] = {}
+
+    def run(
+        self,
+        circuits: Sequence[QuantumCircuit],
+        parameter_values: Sequence[Sequence[float]],
+        parameters: Sequence[Sequence[Parameter] | None] | None = None,
+        **options,
+    ) -> AlgorithmJob:
+        """Run the job of the sampler gradient on the given circuits.
+
+        Args:
+            circuits: The list of quantum circuits to compute the gradients.
+            parameter_values: The list of parameter values to be bound to the circuit.
+            parameters: The sequence of parameters to calculate only the gradients of
+                the specified parameters. Each sequence of parameters corresponds to a circuit in
+                ``circuits``. Defaults to None, which means that the gradients of all parameters in
+                each circuit are calculated. None in the sequence means that the gradients of all
+                parameters in the corresponding circuit are calculated.
+            options: Primitive backend runtime options used for circuit execution.
+                The order of priority is: options in ``run`` method > gradient's
+                default options > primitive's default setting.
+                Higher priority setting overrides lower priority setting
+        Returns:
+            The job object of the gradients of the sampling probability. The i-th result
+            corresponds to ``circuits[i]`` evaluated with parameters bound as ``parameter_values[i]``.
+            The j-th quasi-probability distribution in the i-th result corresponds to the gradients of
+            the sampling probability for the j-th parameter in ``circuits[i]``.
+
+        Raises:
+            ValueError: Invalid arguments are given.
+        """
+        if isinstance(circuits, QuantumCircuit):
+            # Allow a single circuit to be passed in.
+            circuits = (circuits,)
+        if parameters is None:
+            # If parameters is None, we calculate the gradients of all parameters in each circuit.
+            parameters = [circuit.parameters for circuit in circuits]
+        else:
+            # If parameters is not None, we calculate the gradients of the specified parameters.
+            # None in parameters means that the gradients of all parameters in the corresponding
+            # circuit are calculated.
+            parameters = [
+                params if params is not None else circuits[i].parameters
+                for i, params in enumerate(parameters)
+            ]
+        # Validate the arguments.
+        self._validate_arguments(circuits, parameter_values, parameters)
+        # The priority of run option is as follows:
+        # options in `run` method > gradient's default options > primitive's default options.
+        opts = copy(self._default_options)
+        opts.update_options(**options)
+        job = AlgorithmJob(self._run, circuits, parameter_values, parameters, **opts.__dict__)
+        job.submit()
+        return job
+
+    @abstractmethod
+    def _run(
+        self,
+        circuits: Sequence[QuantumCircuit],
+        parameter_values: Sequence[Sequence[float]],
+        parameters: Sequence[Sequence[Parameter]],
+        **options,
+    ) -> SamplerGradientResult:
+        """Compute the sampler gradients on the given circuits."""
+        raise NotImplementedError()
+
+    def _preprocess(
+        self,
+        circuits: Sequence[QuantumCircuit],
+        parameter_values: Sequence[Sequence[float]],
+        parameters: Sequence[Sequence[Parameter]],
+        supported_gates: Sequence[str],
+    ) -> tuple[Sequence[QuantumCircuit], Sequence[Sequence[float]], Sequence[Sequence[Parameter]]]:
+        """Preprocess the gradient. This makes a gradient circuit for each circuit. The gradient
+        circuit is a transpiled circuit by using the supported gates, and has unique parameters.
+        ``parameter_values`` and ``parameters`` are also updated to match the gradient circuit.
+
+        Args:
+            circuits: The list of quantum circuits to compute the gradients.
+            parameter_values: The list of parameter values to be bound to the circuit.
+            parameters: The sequence of parameters to calculate only the gradients of the specified
+                parameters.
+            supported_gates: The supported gates used to transpile the circuit.
+
+        Returns:
+            The list of gradient circuits, the list of parameter values, and the list of parameters.
+            parameter_values and parameters are updated to match the gradient circuit.
+        """
+        translator = TranslateParameterizedGates(supported_gates)
+        g_circuits: list[QuantumCircuit] = []
+        g_parameter_values: list[Sequence[float]] = []
+        g_parameters: list[Sequence[Parameter]] = []
+        for circuit, parameter_value_, parameters_ in zip(circuits, parameter_values, parameters):
+            circuit_key = _circuit_key(circuit)
+            if circuit_key not in self._gradient_circuit_cache:
+                unrolled = translator(circuit)
+                self._gradient_circuit_cache[circuit_key] = _assign_unique_parameters(unrolled)
+            gradient_circuit = self._gradient_circuit_cache[circuit_key]
+            g_circuits.append(gradient_circuit.gradient_circuit)
+            g_parameter_values.append(
+                _make_gradient_parameter_values(  # type: ignore[arg-type]
+                    circuit, gradient_circuit, parameter_value_
+                )
+            )
+            g_parameters.append(_make_gradient_parameters(gradient_circuit, parameters_))
+        return g_circuits, g_parameter_values, g_parameters
+
+    def _postprocess(
+        self,
+        results: SamplerGradientResult,
+        circuits: Sequence[QuantumCircuit],
+        parameter_values: Sequence[Sequence[float]],
+        parameters: Sequence[Sequence[Parameter] | None],
+    ) -> SamplerGradientResult:
+        """Postprocess the gradient. This computes the gradient of the original circuit from the
+        gradient of the gradient circuit by using the chain rule.
+
+        Args:
+            results: The results of the gradient of the gradient circuits.
+            circuits: The list of quantum circuits to compute the gradients.
+            parameter_values: The list of parameter values to be bound to the circuit.
+            parameters: The sequence of parameters to calculate only the gradients of the specified
+                parameters.
+
+        Returns:
+            The results of the gradient of the original circuits.
+        """
+        gradients, metadata = [], []
+        for idx, (circuit, parameter_values_, parameters_) in enumerate(
+            zip(circuits, parameter_values, parameters)
+        ):
+            gradient_circuit = self._gradient_circuit_cache[_circuit_key(circuit)]
+            g_parameters = _make_gradient_parameters(gradient_circuit, parameters_)
+            # Make a map from the gradient parameter to the respective index in the gradient.
+            g_parameter_indices = {param: i for i, param in enumerate(g_parameters)}
+            # Compute the original gradient from the gradient of the gradient circuit
+            # by using the chain rule.
+            gradient = []
+            for parameter in parameters_:
+                grad_dist: dict[int, float] = defaultdict(float)
+                for g_parameter, coeff in gradient_circuit.parameter_map[parameter]:
+                    # Compute the coefficient
+                    if isinstance(coeff, ParameterExpression):
+                        local_map = {
+                            p: parameter_values_[circuit.parameters.data.index(p)]
+                            for p in coeff.parameters
+                        }
+                        bound_coeff = coeff.bind(local_map)
+                    else:
+                        bound_coeff = coeff
+                    # The original gradient is a sum of the gradients of the parameters in the
+                    # gradient circuit multiplied by the coefficients.
+                    unique_gradient = results.gradients[idx][g_parameter_indices[g_parameter]]
+                    for key, value in unique_gradient.items():
+                        grad_dist[key] += float(bound_coeff) * value
+                gradient.append(dict(grad_dist))
+            gradients.append(gradient)
+            metadata.append([{"parameters": parameters_}])
+        return SamplerGradientResult(
+            gradients=gradients, metadata=metadata, options=results.options
+        )
+
+    @staticmethod
+    def _validate_arguments(
+        circuits: Sequence[QuantumCircuit],
+        parameter_values: Sequence[Sequence[float]],
+        parameters: Sequence[Sequence[Parameter]],
+    ) -> None:
+        """Validate the arguments of the ``run`` method.
+
+        Args:
+            circuits: The list of quantum circuits to compute the gradients.
+            parameter_values: The list of parameter values to be bound to the circuit.
+            parameters: The sequence of parameters to calculate only the gradients of the specified
+                parameters.
+
+        Raises:
+            ValueError: Invalid arguments are given.
+        """
+        if len(circuits) != len(parameter_values):
+            raise ValueError(
+                f"The number of circuits ({len(circuits)}) does not match "
+                f"the number of parameter value sets ({len(parameter_values)})."
+            )
+
+        for i, (circuit, parameter_value) in enumerate(zip(circuits, parameter_values)):
+            if not circuit.num_parameters:
+                raise ValueError(f"The {i}-th circuit is not parameterised.")
+
+            if len(parameter_value) != circuit.num_parameters:
+                raise ValueError(
+                    f"The number of values ({len(parameter_value)}) does not match "
+                    f"the number of parameters ({circuit.num_parameters}) for the {i}-th circuit."
+                )
+
+        if len(circuits) != len(parameters):
+            raise ValueError(
+                f"The number of circuits ({len(circuits)}) does not match "
+                f"the number of the specified parameter sets ({len(parameters)})."
+            )
+
+        for i, (circuit, parameters_) in enumerate(zip(circuits, parameters)):
+            if not set(parameters_).issubset(circuit.parameters):
+                raise ValueError(
+                    f"The {i}-th parameter set contains parameters not present in the "
+                    f"{i}-th circuit."
+                )
+
+    @property
+    def options(self) -> Options:
+        """Return the union of sampler options setting and gradient default options,
+        where, if the same field is set in both, the gradient's default options override
+        the primitive's default setting.
+
+        Returns:
+            The gradient default + sampler options.
+        """
+        return self._get_local_options(self._default_options.__dict__)
+
+    def update_default_options(self, **options):
+        """Update the gradient's default options setting.
+
+        Args:
+            **options: The fields to update the default options.
+        """
+
+        self._default_options.update_options(**options)
+
+    def _get_local_options(self, options: Options) -> Options:
+        """Return the union of the primitive's default setting,
+        the gradient default options, and the options in the ``run`` method.
+        The order of priority is: options in ``run`` method > gradient's
+                default options > primitive's default setting.
+
+        Args:
+            options: The fields to update the options
+
+        Returns:
+            The gradient default + sampler + run options.
+        """
+        opts = copy(self._sampler.options)
+        opts.update_options(**options)
+        return opts
diff --git a/qiskit_machine_learning/gradients/base/estimator_gradient_result.py b/qiskit_machine_learning/gradients/base/estimator_gradient_result.py
new file mode 100644
index 000000000..3c4ecabf6
--- /dev/null
+++ b/qiskit_machine_learning/gradients/base/estimator_gradient_result.py
@@ -0,0 +1,35 @@
+# This code is part of a Qiskit project.
+#
+# (C) Copyright IBM 2022, 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
+# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
+#
+# Any modifications or derivative works of this code must retain this
+# copyright notice, and modified files need to carry a notice indicating
+# that they have been altered from the originals.
+"""
+Estimator result class
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Any
+
+import numpy as np
+
+from qiskit.providers import Options
+
+
+@dataclass(frozen=True)
+class EstimatorGradientResult:
+    """Result of EstimatorGradient."""
+
+    gradients: list[np.ndarray]
+    """The gradients of the expectation values."""
+    metadata: list[dict[str, Any]]
+    """Additional information about the job."""
+    options: Options
+    """Primitive runtime options for the execution of the job."""
diff --git a/qiskit_machine_learning/gradients/base/qgt_result.py b/qiskit_machine_learning/gradients/base/qgt_result.py
new file mode 100644
index 000000000..acdb6710e
--- /dev/null
+++ b/qiskit_machine_learning/gradients/base/qgt_result.py
@@ -0,0 +1,39 @@
+# This code is part of a Qiskit project.
+#
+# (C) Copyright IBM 2022, 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
+# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
+#
+# Any modifications or derivative works of this code must retain this
+# copyright notice, and modified files need to carry a notice indicating
+# that they have been altered from the originals.
+"""
+QGT result class
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Any
+
+import numpy as np
+
+from qiskit.providers import Options
+
+from ..utils import DerivativeType
+
+
+@dataclass(frozen=True)
+class QGTResult:
+    """Result of QGT."""
+
+    qgts: list[np.ndarray]
+    """The QGT."""
+    derivative_type: DerivativeType
+    """The type of derivative."""
+    metadata: list[dict[str, Any]] | list[list[dict[str, Any]]]
+    """Additional information about the job."""
+    options: Options
+    """Primitive runtime options for the execution of the job."""
diff --git a/qiskit_machine_learning/gradients/base/sampler_gradient_result.py b/qiskit_machine_learning/gradients/base/sampler_gradient_result.py
new file mode 100644
index 000000000..2c27fddc3
--- /dev/null
+++ b/qiskit_machine_learning/gradients/base/sampler_gradient_result.py
@@ -0,0 +1,33 @@
+# This code is part of a Qiskit project.
+#
+# (C) Copyright IBM 2022, 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
+# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
+#
+# Any modifications or derivative works of this code must retain this
+# copyright notice, and modified files need to carry a notice indicating
+# that they have been altered from the originals.
+"""
+Sampler result class
+"""
+
+from __future__ import annotations
+
+from typing import Any
+from dataclasses import dataclass
+
+from qiskit.providers import Options
+
+
+@dataclass(frozen=True)
+class SamplerGradientResult:
+    """Result of SamplerGradient."""
+
+    gradients: list[list[dict[int, float]]]
+    """The gradients of the sample probabilities."""
+    metadata: list[dict[str, Any]] | list[list[dict[str, Any]]]
+    """Additional information about the job."""
+    options: Options
+    """Primitive runtime options for the execution of the job."""
diff --git a/qiskit_machine_learning/gradients/lin_comb/__init__.py b/qiskit_machine_learning/gradients/lin_comb/__init__.py
new file mode 100644
index 000000000..c870af8ba
--- /dev/null
+++ b/qiskit_machine_learning/gradients/lin_comb/__init__.py
@@ -0,0 +1,11 @@
+# This code is part of a Qiskit project.
+#
+# (C) Copyright IBM 2022, 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
+# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
+#
+# Any modifications or derivative works of this code must retain this
+# copyright notice, and modified files need to carry a notice indicating
+# that they have been altered from the originals.
diff --git a/qiskit_machine_learning/gradients/lin_comb/lin_comb_estimator_gradient.py b/qiskit_machine_learning/gradients/lin_comb/lin_comb_estimator_gradient.py
new file mode 100644
index 000000000..f7787f7e3
--- /dev/null
+++ b/qiskit_machine_learning/gradients/lin_comb/lin_comb_estimator_gradient.py
@@ -0,0 +1,194 @@
+# This code is part of a Qiskit project.
+#
+# (C) Copyright IBM 2022, 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
+# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
+#
+# Any modifications or derivative works of this code must retain this
+# copyright notice, and modified files need to carry a notice indicating
+# that they have been altered from the originals.
+"""
+Gradient of probabilities with linear combination of unitaries (LCU)
+"""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+
+import numpy as np
+
+from qiskit.circuit import Parameter, QuantumCircuit
+from qiskit.primitives import BaseEstimator
+from qiskit.primitives.utils import init_observable, _circuit_key
+from qiskit.providers import Options
+from qiskit.quantum_info.operators.base_operator import BaseOperator
+
+from ..base.base_estimator_gradient import BaseEstimatorGradient
+from ..base.estimator_gradient_result import EstimatorGradientResult
+from ..utils import DerivativeType, _make_lin_comb_gradient_circuit, _make_lin_comb_observables
+
+from ...exceptions import AlgorithmError
+
+
+class LinCombEstimatorGradient(BaseEstimatorGradient):
+    """Compute the gradients of the expectation values.
+    This method employs a linear combination of unitaries [1].
+
+    **Reference:**
+    [1] Schuld et al., Evaluating analytic gradients on quantum hardware, 2018
+    `arXiv:1811.11184 <https://arxiv.org/pdf/1811.11184.pdf>`_
+    """
+
+    SUPPORTED_GATES = [
+        "rx",
+        "ry",
+        "rz",
+        "rzx",
+        "rzz",
+        "ryy",
+        "rxx",
+        "cx",
+        "cy",
+        "cz",
+        "ccx",
+        "swap",
+        "iswap",
+        "h",
+        "t",
+        "s",
+        "sdg",
+        "x",
+        "y",
+        "z",
+    ]
+
+    def __init__(
+        self,
+        estimator: BaseEstimator,
+        derivative_type: DerivativeType = DerivativeType.REAL,
+        options: Options | None = None,
+    ):
+        r"""
+        Args:
+            estimator: The estimator used to compute the gradients.
+            derivative_type: The type of derivative. Can be either ``DerivativeType.REAL``
+                ``DerivativeType.IMAG``, or ``DerivativeType.COMPLEX``. Defaults to
+                ``DerivativeType.REAL``.
+
+                    - ``DerivativeType.REAL`` computes :math:`2 \mathrm{Re}[⟨ψ(ω)|O(θ)|dω ψ(ω)〉]`.
+                    - ``DerivativeType.IMAG`` computes :math:`2 \mathrm{Im}[⟨ψ(ω)|O(θ)|dω ψ(ω)〉]`.
+                    - ``DerivativeType.COMPLEX`` computes :math:`2 ⟨ψ(ω)|O(θ)|dω ψ(ω)〉`.
+
+            options: Primitive backend runtime options used for circuit execution.
+                The order of priority is: options in ``run`` method > gradient's
+                default options > primitive's default setting.
+                Higher priority setting overrides lower priority setting.
+        """
+        self._lin_comb_cache: dict[tuple, dict[Parameter, QuantumCircuit]] = {}
+        super().__init__(estimator, options, derivative_type=derivative_type)
+
+    @BaseEstimatorGradient.derivative_type.setter  # type: ignore[attr-defined]
+    def derivative_type(self, derivative_type: DerivativeType) -> None:
+        """Set the derivative type."""
+        self._derivative_type = derivative_type
+
+    def _run(
+        self,
+        circuits: Sequence[QuantumCircuit],
+        observables: Sequence[BaseOperator],
+        parameter_values: Sequence[Sequence[float]],
+        parameters: Sequence[Sequence[Parameter]],
+        **options,
+    ) -> EstimatorGradientResult:
+        """Compute the estimator gradients on the given circuits."""
+        g_circuits, g_parameter_values, g_parameters = self._preprocess(
+            circuits, parameter_values, parameters, self.SUPPORTED_GATES
+        )
+        results = self._run_unique(
+            g_circuits, observables, g_parameter_values, g_parameters, **options
+        )
+        return self._postprocess(results, circuits, parameter_values, parameters)
+
+    def _run_unique(
+        self,
+        circuits: Sequence[QuantumCircuit],
+        observables: Sequence[BaseOperator],
+        parameter_values: Sequence[Sequence[float]],
+        parameters: Sequence[Sequence[Parameter]],
+        **options,
+    ) -> EstimatorGradientResult:
+        """Compute the estimator gradients on the given circuits."""
+        job_circuits, job_observables, job_param_values, metadata = [], [], [], []
+        all_n = []
+        for circuit, observable, parameter_values_, parameters_ in zip(
+            circuits, observables, parameter_values, parameters
+        ):
+            # Prepare circuits for the gradient of the specified parameters.
+            meta = {"parameters": parameters_}
+            circuit_key = _circuit_key(circuit)
+            if circuit_key not in self._lin_comb_cache:
+                # Cache the circuits for the linear combination of unitaries.
+                # We only cache the circuits for the specified parameters in the future.
+                self._lin_comb_cache[circuit_key] = _make_lin_comb_gradient_circuit(
+                    circuit, add_measurement=False
+                )
+            lin_comb_circuits = self._lin_comb_cache[circuit_key]
+            gradient_circuits = []
+            for param in parameters_:
+                gradient_circuits.append(lin_comb_circuits[param])
+            n = len(gradient_circuits)
+            # Make the observable as :class:`~qiskit.quantum_info.SparsePauliOp` and
+            # add an ancillary operator to compute the gradient.
+            observable = init_observable(observable)
+            observable_1, observable_2 = _make_lin_comb_observables(
+                observable, self._derivative_type
+            )
+            # If its derivative type is `DerivativeType.COMPLEX`, calculate the gradient
+            # of the real and imaginary parts separately.
+            meta["derivative_type"] = self.derivative_type
+            metadata.append(meta)
+            # Combine inputs into a single job to reduce overhead.
+            if self._derivative_type == DerivativeType.COMPLEX:
+                job_circuits.extend(gradient_circuits * 2)
+                job_observables.extend([observable_1] * n + [observable_2] * n)
+                job_param_values.extend([parameter_values_] * 2 * n)
+                all_n.append(2 * n)
+            else:
+                job_circuits.extend(gradient_circuits)
+                job_observables.extend([observable_1] * n)
+                job_param_values.extend([parameter_values_] * n)
+                all_n.append(n)
+
+        # Run the single job with all circuits.
+        job = self._estimator.run(
+            job_circuits,
+            job_observables,
+            job_param_values,
+            **options,
+        )
+        try:
+            results = job.result()
+        except AlgorithmError as exc:
+            raise AlgorithmError("Estimator job failed.") from exc
+
+        # Compute the gradients.
+        gradients = []
+        partial_sum_n = 0
+        for n in all_n:
+            # this disable is needed as Pylint does not understand derivative_type is a property if
+            # it is only defined in the base class and the getter is in the child
+            # pylint: disable=comparison-with-callable
+            if self.derivative_type == DerivativeType.COMPLEX:
+                gradient = np.zeros(n // 2, dtype="complex")
+                gradient.real = results.values[partial_sum_n : partial_sum_n + n // 2]
+                gradient.imag = results.values[partial_sum_n + n // 2 : partial_sum_n + n]
+
+            else:
+                gradient = np.real(results.values[partial_sum_n : partial_sum_n + n])
+            partial_sum_n += n
+            gradients.append(gradient)
+
+        opt = self._get_local_options(options)
+        return EstimatorGradientResult(gradients=gradients, metadata=metadata, options=opt)
diff --git a/qiskit_machine_learning/gradients/lin_comb/lin_comb_qgt.py b/qiskit_machine_learning/gradients/lin_comb/lin_comb_qgt.py
new file mode 100644
index 000000000..afa452ae5
--- /dev/null
+++ b/qiskit_machine_learning/gradients/lin_comb/lin_comb_qgt.py
@@ -0,0 +1,258 @@
+# This code is part of a Qiskit project.
+#
+# (C) Copyright IBM 2022, 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
+# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
+#
+# Any modifications or derivative works of this code must retain this
+# copyright notice, and modified files need to carry a notice indicating
+# that they have been altered from the originals.
+"""
+A class for the Linear Combination Quantum Gradient Tensor.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+
+import numpy as np
+
+from qiskit.circuit import Parameter, QuantumCircuit
+from qiskit.primitives import BaseEstimator
+from qiskit.primitives.utils import _circuit_key
+from qiskit.providers import Options
+from qiskit.quantum_info import SparsePauliOp
+
+from ..base.base_qgt import BaseQGT
+from .lin_comb_estimator_gradient import LinCombEstimatorGradient
+from ..base.qgt_result import QGTResult
+from ..utils import DerivativeType, _make_lin_comb_qgt_circuit, _make_lin_comb_observables
+
+from ...exceptions import AlgorithmError
+
+
+class LinCombQGT(BaseQGT):
+    """Computes the Quantum Geometric Tensor (QGT) given a pure, parameterized quantum state.
+
+    This method employs a linear combination of unitaries [1].
+
+    **Reference:**
+
+        [1]: Schuld et al., "Evaluating analytic gradients on quantum hardware" (2018).
+             `arXiv:1811.11184 <https://arxiv.org/pdf/1811.11184.pdf>`_
+    """
+
+    SUPPORTED_GATES = [
+        "rx",
+        "ry",
+        "rz",
+        "rzx",
+        "rzz",
+        "ryy",
+        "rxx",
+        "cx",
+        "cy",
+        "cz",
+        "ccx",
+        "swap",
+        "iswap",
+        "h",
+        "t",
+        "s",
+        "sdg",
+        "x",
+        "y",
+        "z",
+    ]
+
+    def __init__(
+        self,
+        estimator: BaseEstimator,
+        phase_fix: bool = True,
+        derivative_type: DerivativeType = DerivativeType.COMPLEX,
+        options: Options | None = None,
+    ):
+        r"""
+        Args:
+            estimator: The estimator used to compute the QGT.
+            phase_fix: Whether to calculate the second term (phase fix) of the QGT, which is
+                :math:`\langle\partial_i \psi | \psi \rangle \langle\psi | \partial_j \psi \rangle`.
+                Default to ``True``.
+            derivative_type: The type of derivative. Can be either ``DerivativeType.REAL``
+                ``DerivativeType.IMAG``, or ``DerivativeType.COMPLEX``. Defaults to
+                ``DerivativeType.REAL``.
+
+                - ``DerivativeType.REAL`` computes
+
+                .. math::
+
+                    \mathrm{Re(QGT)}_{ij}= \mathrm{Re}[\langle \partial_i \psi | \partial_j \psi \rangle
+                        - \langle\partial_i \psi | \psi \rangle \langle\psi | \partial_j \psi \rangle].
+
+                - ``DerivativeType.IMAG`` computes
+
+                .. math::
+
+                    \mathrm{Re(QGT)}_{ij}= \mathrm{Im}[\langle \partial_i \psi | \partial_j \psi \rangle
+                        - \langle\partial_i \psi | \psi \rangle \langle\psi | \partial_j \psi \rangle].
+
+                - ``DerivativeType.COMPLEX`` computes
+
+                .. math::
+
+                    \mathrm{QGT}_{ij}= [\langle \partial_i \psi | \partial_j \psi \rangle
+                        - \langle\partial_i \psi | \psi \rangle \langle\psi | \partial_j \psi \rangle].
+
+            options: Backend runtime options used for circuit execution. The order of priority is:
+                options in ``run`` method > QGT's default options > primitive's default
+                setting. Higher priority setting overrides lower priority setting.
+        """
+        super().__init__(estimator, phase_fix, derivative_type, options=options)
+        self._gradient = LinCombEstimatorGradient(
+            estimator, derivative_type=DerivativeType.COMPLEX, options=options
+        )
+        self._lin_comb_qgt_circuit_cache: dict[
+            tuple, dict[tuple[Parameter, Parameter], QuantumCircuit]
+        ] = {}
+
+    def _run(
+        self,
+        circuits: Sequence[QuantumCircuit],
+        parameter_values: Sequence[Sequence[float]],
+        parameters: Sequence[Sequence[Parameter]],
+        **options,
+    ) -> QGTResult:
+        """Compute the QGT on the given circuits."""
+        g_circuits, g_parameter_values, g_parameters = self._preprocess(
+            circuits, parameter_values, parameters, self.SUPPORTED_GATES
+        )
+        results = self._run_unique(g_circuits, g_parameter_values, g_parameters, **options)
+        return self._postprocess(results, circuits, parameter_values, parameters)
+
+    def _run_unique(
+        self,
+        circuits: Sequence[QuantumCircuit],
+        parameter_values: Sequence[Sequence[float]],
+        parameters: Sequence[Sequence[Parameter]],
+        **options,
+    ) -> QGTResult:
+        """Compute the QGTs on the given circuits."""
+        job_circuits, job_observables, job_param_values, metadata = [], [], [], []
+        all_n, all_m = [], []
+        phase_fixes: list[int | np.ndarray] = []
+
+        for circuit, parameter_values_, parameters_ in zip(circuits, parameter_values, parameters):
+            # Prepare circuits for the gradient of the specified parameters.
+            parameters_ = [p for p in circuit.parameters if p in parameters_]
+            meta = {"parameters": parameters_}
+            metadata.append(meta)
+
+            # Compute the first term in the QGT
+            circuit_key = _circuit_key(circuit)
+            if circuit_key not in self._lin_comb_qgt_circuit_cache:
+                # generate the all of the circuits for the first term in the QGT and cache them.
+                # Only the circuit related to specified parameters will be executed.
+                # In the future, we can generate the specified circuits on demand.
+                self._lin_comb_qgt_circuit_cache[circuit_key] = _make_lin_comb_qgt_circuit(circuit)
+            lin_comb_qgt_circuits = self._lin_comb_qgt_circuit_cache[circuit_key]
+
+            qgt_circuits = []
+            rows, cols = np.triu_indices(len(parameters_))
+            for row, col in zip(rows, cols):
+                param_i = parameters_[row]
+                param_j = parameters_[col]
+                qgt_circuits.append(lin_comb_qgt_circuits[(param_i, param_j)])
+
+            observable = SparsePauliOp.from_list([("I" * circuit.num_qubits, 1)])
+            observable_1, observable_2 = _make_lin_comb_observables(
+                observable, self._derivative_type
+            )
+
+            n = len(qgt_circuits)
+            if self._derivative_type == DerivativeType.COMPLEX:
+                job_circuits.extend(qgt_circuits * 2)
+                job_observables.extend([observable_1] * n + [observable_2] * n)
+                job_param_values.extend([parameter_values_] * 2 * n)
+                all_m.append(len(parameters_))
+                all_n.append(2 * n)
+            else:
+                job_circuits.extend(qgt_circuits)
+                job_observables.extend([observable_1] * n)
+                job_param_values.extend([parameter_values_] * n)
+                all_m.append(len(parameters_))
+                all_n.append(n)
+
+        # Run the single job with all circuits.
+        job = self._estimator.run(
+            job_circuits,
+            job_observables,
+            job_param_values,
+            **options,
+        )
+
+        if self._phase_fix:
+            # Compute the second term in the QGT if phase fix is enabled.
+            phase_fix_obs = [
+                SparsePauliOp.from_list([("I" * circuit.num_qubits, 1)]) for circuit in circuits
+            ]
+            phase_fix_job = self._gradient.run(
+                circuits=circuits,
+                observables=phase_fix_obs,
+                parameter_values=parameter_values,
+                parameters=parameters,
+                **options,
+            )
+
+        try:
+            results = job.result()
+            if self._phase_fix:
+                gradient_results = phase_fix_job.result()
+        except AlgorithmError as exc:
+            raise AlgorithmError("Estimator job or gradient job failed.") from exc
+
+        # Compute the phase fix
+        if self._phase_fix:
+            for gradient in gradient_results.gradients:
+                phase_fix = np.outer(np.conjugate(gradient), gradient)
+                # Select the real or imaginary part of the phase fix if needed
+                if self.derivative_type == DerivativeType.REAL:
+                    phase_fix = np.real(phase_fix)
+                elif self.derivative_type == DerivativeType.IMAG:
+                    phase_fix = np.imag(phase_fix)
+                phase_fixes.append(phase_fix)
+        else:
+            phase_fixes = [0 for i in range(len(circuits))]
+        # Compute the QGT
+        qgts = []
+        partial_sum_n = 0
+        for i, (n, m) in enumerate(zip(all_n, all_m)):
+            qgt = np.zeros((m, m), dtype="complex")
+            # Compute the first term in the QGT
+            if self.derivative_type == DerivativeType.COMPLEX:
+                qgt[np.triu_indices(m)] = results.values[partial_sum_n : partial_sum_n + n // 2]
+                qgt[np.triu_indices(m)] += (
+                    1j * results.values[partial_sum_n + n // 2 : partial_sum_n + n]
+                )
+            elif self.derivative_type == DerivativeType.REAL:
+                qgt[np.triu_indices(m)] = results.values[partial_sum_n : partial_sum_n + n]
+            elif self.derivative_type == DerivativeType.IMAG:
+                qgt[np.triu_indices(m)] = 1j * results.values[partial_sum_n : partial_sum_n + n]
+
+            # Add the conjugate of the upper triangle to the lower triangle
+            qgt += np.triu(qgt, k=1).conjugate().T
+            if self.derivative_type == DerivativeType.REAL:
+                qgt = np.real(qgt)
+            elif self.derivative_type == DerivativeType.IMAG:
+                qgt = np.imag(qgt)
+
+            # Subtract the phase fix from the QGT
+            qgt = qgt - phase_fixes[i]
+            partial_sum_n += n
+            qgts.append(qgt / 4)
+
+        opt = self._get_local_options(options)
+        return QGTResult(
+            qgts=qgts, derivative_type=self.derivative_type, metadata=metadata, options=opt
+        )
diff --git a/qiskit_machine_learning/gradients/lin_comb/lin_comb_sampler_gradient.py b/qiskit_machine_learning/gradients/lin_comb/lin_comb_sampler_gradient.py
new file mode 100644
index 000000000..cbbe8bf45
--- /dev/null
+++ b/qiskit_machine_learning/gradients/lin_comb/lin_comb_sampler_gradient.py
@@ -0,0 +1,148 @@
+# This code is part of a Qiskit project.
+#
+# (C) Copyright IBM 2022, 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
+# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
+#
+# Any modifications or derivative works of this code must retain this
+# copyright notice, and modified files need to carry a notice indicating
+# that they have been altered from the originals.
+"""
+Gradient of probabilities with linear combination of unitaries (LCU)
+"""
+
+from __future__ import annotations
+
+from collections import defaultdict
+from collections.abc import Sequence
+
+from qiskit.circuit import Parameter, QuantumCircuit
+from qiskit.primitives import BaseSampler
+from qiskit.primitives.utils import _circuit_key
+from qiskit.providers import Options
+
+from ..base.base_sampler_gradient import BaseSamplerGradient
+from ..base.sampler_gradient_result import SamplerGradientResult
+from ..utils import _make_lin_comb_gradient_circuit
+
+from ...exceptions import AlgorithmError
+
+
+class LinCombSamplerGradient(BaseSamplerGradient):
+    """Compute the gradients of the sampling probability.
+    This method employs a linear combination of unitaries [1].
+
+    **Reference:**
+    [1] Schuld et al., Evaluating analytic gradients on quantum hardware, 2018
+    `arXiv:1811.11184 <https://arxiv.org/pdf/1811.11184.pdf>`_
+    """
+
+    SUPPORTED_GATES = [
+        "rx",
+        "ry",
+        "rz",
+        "rzx",
+        "rzz",
+        "ryy",
+        "rxx",
+        "cx",
+        "cy",
+        "cz",
+        "ccx",
+        "swap",
+        "iswap",
+        "h",
+        "t",
+        "s",
+        "sdg",
+        "x",
+        "y",
+        "z",
+    ]
+
+    def __init__(self, sampler: BaseSampler, options: Options | None = None):
+        """
+        Args:
+            sampler: The sampler used to compute the gradients.
+            options: Primitive backend runtime options used for circuit execution.
+                The order of priority is: options in ``run`` method > gradient's
+                default options > primitive's default setting.
+                Higher priority setting overrides lower priority setting
+        """
+        self._lin_comb_cache: dict[tuple, dict[Parameter, QuantumCircuit]] = {}
+        super().__init__(sampler, options)
+
+    def _run(
+        self,
+        circuits: Sequence[QuantumCircuit],
+        parameter_values: Sequence[Sequence[float]],
+        parameters: Sequence[Sequence[Parameter]],
+        **options,
+    ) -> SamplerGradientResult:
+        """Compute the estimator gradients on the given circuits."""
+        g_circuits, g_parameter_values, g_parameters = self._preprocess(
+            circuits, parameter_values, parameters, self.SUPPORTED_GATES
+        )
+        results = self._run_unique(g_circuits, g_parameter_values, g_parameters, **options)
+        return self._postprocess(results, circuits, parameter_values, parameters)
+
+    def _run_unique(
+        self,
+        circuits: Sequence[QuantumCircuit],
+        parameter_values: Sequence[Sequence[float]],
+        parameters: Sequence[Sequence[Parameter]],
+        **options,
+    ) -> SamplerGradientResult:
+        """Compute the sampler gradients on the given circuits."""
+        job_circuits, job_param_values, metadata = [], [], []
+        all_n = []
+        for circuit, parameter_values_, parameters_ in zip(circuits, parameter_values, parameters):
+            # Prepare circuits for the gradient of the specified parameters.
+            # TODO: why is this not wrapped into another list level like it is done elsewhere?
+            metadata.append({"parameters": parameters_})
+            circuit_key = _circuit_key(circuit)
+            if circuit_key not in self._lin_comb_cache:
+                # Cache the circuits for the linear combination of unitaries.
+                # We only cache the circuits for the specified parameters in the future.
+                self._lin_comb_cache[circuit_key] = _make_lin_comb_gradient_circuit(
+                    circuit, add_measurement=True
+                )
+            lin_comb_circuits = self._lin_comb_cache[circuit_key]
+            gradient_circuits = []
+            for param in parameters_:
+                gradient_circuits.append(lin_comb_circuits[param])
+            # Combine inputs into a single job to reduce overhead.
+            n = len(gradient_circuits)
+            job_circuits.extend(gradient_circuits)
+            job_param_values.extend([parameter_values_] * n)
+            all_n.append(n)
+
+        # Run the single job with all circuits.
+        job = self._sampler.run(job_circuits, job_param_values, **options)
+        try:
+            results = job.result()
+        except Exception as exc:
+            raise AlgorithmError("Sampler job failed.") from exc
+
+        # Compute the gradients.
+        gradients = []
+        partial_sum_n = 0
+        for i, n in enumerate(all_n):
+            gradient = []
+            result = results.quasi_dists[partial_sum_n : partial_sum_n + n]
+            m = 2 ** circuits[i].num_qubits
+            for dist in result:
+                grad_dist: dict[int, float] = defaultdict(float)
+                for key, value in dist.items():
+                    if key < m:
+                        grad_dist[key] += value
+                    else:
+                        grad_dist[key - m] -= value
+                gradient.append(dict(grad_dist))
+            gradients.append(gradient)
+            partial_sum_n += n
+
+        opt = self._get_local_options(options)
+        return SamplerGradientResult(gradients=gradients, metadata=metadata, options=opt)
diff --git a/qiskit_machine_learning/gradients/param_shift/__init__.py b/qiskit_machine_learning/gradients/param_shift/__init__.py
new file mode 100644
index 000000000..c870af8ba
--- /dev/null
+++ b/qiskit_machine_learning/gradients/param_shift/__init__.py
@@ -0,0 +1,11 @@
+# This code is part of a Qiskit project.
+#
+# (C) Copyright IBM 2022, 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
+# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
+#
+# Any modifications or derivative works of this code must retain this
+# copyright notice, and modified files need to carry a notice indicating
+# that they have been altered from the originals.
diff --git a/qiskit_machine_learning/gradients/param_shift/param_shift_estimator_gradient.py b/qiskit_machine_learning/gradients/param_shift/param_shift_estimator_gradient.py
new file mode 100644
index 000000000..cde25a0fd
--- /dev/null
+++ b/qiskit_machine_learning/gradients/param_shift/param_shift_estimator_gradient.py
@@ -0,0 +1,122 @@
+# This code is part of a Qiskit project.
+#
+# (C) Copyright IBM 2022, 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
+# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
+#
+# Any modifications or derivative works of this code must retain this
+# copyright notice, and modified files need to carry a notice indicating
+# that they have been altered from the originals.
+"""
+Gradient of probabilities with parameter shift
+"""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+
+from qiskit.circuit import Parameter, QuantumCircuit
+from qiskit.quantum_info.operators.base_operator import BaseOperator
+
+from ..base.base_estimator_gradient import BaseEstimatorGradient
+from ..base.estimator_gradient_result import EstimatorGradientResult
+from ..utils import _make_param_shift_parameter_values
+
+from ...exceptions import AlgorithmError
+
+
+class ParamShiftEstimatorGradient(BaseEstimatorGradient):
+    """
+    Compute the gradients of the expectation values by the parameter shift rule [1].
+
+    **Reference:**
+    [1] Schuld, M., Bergholm, V., Gogolin, C., Izaac, J., and Killoran, N. Evaluating analytic
+    gradients on quantum hardware, `DOI <https://doi.org/10.1103/PhysRevA.99.032331>`_
+    """
+
+    SUPPORTED_GATES = [
+        "x",
+        "y",
+        "z",
+        "h",
+        "rx",
+        "ry",
+        "rz",
+        "p",
+        "cx",
+        "cy",
+        "cz",
+        "ryy",
+        "rxx",
+        "rzz",
+        "rzx",
+    ]
+
+    def _run(
+        self,
+        circuits: Sequence[QuantumCircuit],
+        observables: Sequence[BaseOperator],
+        parameter_values: Sequence[Sequence[float]],
+        parameters: Sequence[Sequence[Parameter]],
+        **options,
+    ) -> EstimatorGradientResult:
+        """Compute the gradients of the expectation values by the parameter shift rule."""
+        g_circuits, g_parameter_values, g_parameters = self._preprocess(
+            circuits, parameter_values, parameters, self.SUPPORTED_GATES
+        )
+        results = self._run_unique(
+            g_circuits, observables, g_parameter_values, g_parameters, **options
+        )
+        return self._postprocess(results, circuits, parameter_values, parameters)
+
+    def _run_unique(
+        self,
+        circuits: Sequence[QuantumCircuit],
+        observables: Sequence[BaseOperator],
+        parameter_values: Sequence[Sequence[float]],
+        parameters: Sequence[Sequence[Parameter]],
+        **options,
+    ) -> EstimatorGradientResult:
+        """Compute the estimator gradients on the given circuits."""
+        job_circuits, job_observables, job_param_values, metadata = [], [], [], []
+        all_n = []
+        for circuit, observable, parameter_values_, parameters_ in zip(
+            circuits, observables, parameter_values, parameters
+        ):
+            metadata.append({"parameters": parameters_})
+            # Make parameter values for the parameter shift rule.
+            param_shift_parameter_values = _make_param_shift_parameter_values(
+                circuit, parameter_values_, parameters_
+            )
+            # Combine inputs into a single job to reduce overhead.
+            n = len(param_shift_parameter_values)
+            job_circuits.extend([circuit] * n)
+            job_observables.extend([observable] * n)
+            job_param_values.extend(param_shift_parameter_values)
+            all_n.append(n)
+
+        # Run the single job with all circuits.
+        job = self._estimator.run(
+            job_circuits,
+            job_observables,
+            job_param_values,
+            **options,
+        )
+        try:
+            results = job.result()
+        except Exception as exc:
+            raise AlgorithmError("Estimator job failed.") from exc
+
+        # Compute the gradients.
+        gradients = []
+        partial_sum_n = 0
+        for n in all_n:
+            result = results.values[partial_sum_n : partial_sum_n + n]
+            gradient_ = (result[: n // 2] - result[n // 2 :]) / 2
+            gradients.append(gradient_)
+            partial_sum_n += n
+
+        opt = self._get_local_options(options)
+        return EstimatorGradientResult(gradients=gradients, metadata=metadata, options=opt)
diff --git a/qiskit_machine_learning/gradients/param_shift/param_shift_sampler_gradient.py b/qiskit_machine_learning/gradients/param_shift/param_shift_sampler_gradient.py
new file mode 100644
index 000000000..0d7f384a8
--- /dev/null
+++ b/qiskit_machine_learning/gradients/param_shift/param_shift_sampler_gradient.py
@@ -0,0 +1,117 @@
+# This code is part of a Qiskit project.
+#
+# (C) Copyright IBM 2022, 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
+# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
+#
+# Any modifications or derivative works of this code must retain this
+# copyright notice, and modified files need to carry a notice indicating
+# that they have been altered from the originals.
+"""
+Gradient of probabilities with parameter shift
+"""
+
+from __future__ import annotations
+
+from collections import defaultdict
+from collections.abc import Sequence
+
+from qiskit.circuit import Parameter, QuantumCircuit
+
+from ..base.base_sampler_gradient import BaseSamplerGradient
+from ..base.sampler_gradient_result import SamplerGradientResult
+from ..utils import _make_param_shift_parameter_values
+
+from ...exceptions import AlgorithmError
+
+
+class ParamShiftSamplerGradient(BaseSamplerGradient):
+    """
+    Compute the gradients of the sampling probability by the parameter shift rule [1].
+
+    **Reference:**
+    [1] Schuld, M., Bergholm, V., Gogolin, C., Izaac, J., and Killoran, N. Evaluating analytic
+    gradients on quantum hardware, `DOI <https://doi.org/10.1103/PhysRevA.99.032331>`_
+    """
+
+    SUPPORTED_GATES = [
+        "x",
+        "y",
+        "z",
+        "h",
+        "rx",
+        "ry",
+        "rz",
+        "p",
+        "cx",
+        "cy",
+        "cz",
+        "ryy",
+        "rxx",
+        "rzz",
+        "rzx",
+    ]
+
+    def _run(
+        self,
+        circuits: Sequence[QuantumCircuit],
+        parameter_values: Sequence[Sequence[float]],
+        parameters: Sequence[Sequence[Parameter]],
+        **options,
+    ) -> SamplerGradientResult:
+        """Compute the estimator gradients on the given circuits."""
+        g_circuits, g_parameter_values, g_parameters = self._preprocess(
+            circuits, parameter_values, parameters, self.SUPPORTED_GATES
+        )
+        results = self._run_unique(g_circuits, g_parameter_values, g_parameters, **options)
+        return self._postprocess(results, circuits, parameter_values, parameters)
+
+    def _run_unique(
+        self,
+        circuits: Sequence[QuantumCircuit],
+        parameter_values: Sequence[Sequence[float]],
+        parameters: Sequence[Sequence[Parameter]],
+        **options,
+    ) -> SamplerGradientResult:
+        """Compute the sampler gradients on the given circuits."""
+        job_circuits, job_param_values, metadata = [], [], []
+        all_n = []
+        for circuit, parameter_values_, parameters_ in zip(circuits, parameter_values, parameters):
+            metadata.append({"parameters": parameters_})
+            # Make parameter values for the parameter shift rule.
+            param_shift_parameter_values = _make_param_shift_parameter_values(
+                circuit, parameter_values_, parameters_
+            )
+            # Combine inputs into a single job to reduce overhead.
+            n = len(param_shift_parameter_values)
+            job_circuits.extend([circuit] * n)
+            job_param_values.extend(param_shift_parameter_values)
+            all_n.append(n)
+
+        # Run the single job with all circuits.
+        job = self._sampler.run(job_circuits, job_param_values, **options)
+        try:
+            results = job.result()
+        except Exception as exc:
+            raise AlgorithmError("Estimator job failed.") from exc
+
+        # Compute the gradients.
+        gradients = []
+        partial_sum_n = 0
+        for n in all_n:
+            gradient = []
+            result = results.quasi_dists[partial_sum_n : partial_sum_n + n]
+            for dist_plus, dist_minus in zip(result[: n // 2], result[n // 2 :]):
+                grad_dist: dict[int, float] = defaultdict(float)
+                for key, val in dist_plus.items():
+                    grad_dist[key] += val / 2
+                for key, val in dist_minus.items():
+                    grad_dist[key] -= val / 2
+                gradient.append(dict(grad_dist))
+            gradients.append(gradient)
+            partial_sum_n += n
+
+        opt = self._get_local_options(options)
+        return SamplerGradientResult(gradients=gradients, metadata=metadata, options=opt)
diff --git a/qiskit_machine_learning/gradients/qfi.py b/qiskit_machine_learning/gradients/qfi.py
new file mode 100644
index 000000000..ad0c83e85
--- /dev/null
+++ b/qiskit_machine_learning/gradients/qfi.py
@@ -0,0 +1,171 @@
+# This code is part of a Qiskit project.
+#
+# (C) Copyright IBM 2022, 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
+# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
+#
+# Any modifications or derivative works of this code must retain this
+# copyright notice, and modified files need to carry a notice indicating
+# that they have been altered from the originals.
+"""
+A class for the Quantum Fisher Information.
+"""
+
+from __future__ import annotations
+
+from abc import ABC
+from collections.abc import Sequence
+from copy import copy
+
+from qiskit.circuit import Parameter, QuantumCircuit
+from qiskit.providers import Options
+
+from .base.base_qgt import BaseQGT
+from .lin_comb.lin_comb_estimator_gradient import DerivativeType
+from .qfi_result import QFIResult
+
+from ..algorithm_job import AlgorithmJob
+from ..exceptions import AlgorithmError
+
+
+class QFI(ABC):
+    r"""Computes the Quantum Fisher Information (QFI) given a pure,
+    parameterized quantum state. QFI is defined as:
+
+    .. math::
+
+        \mathrm{QFI}_{ij}= 4 \mathrm{Re}[\langle \partial_i \psi | \partial_j \psi \rangle
+        - \langle\partial_i \psi | \psi \rangle \langle\psi | \partial_j \psi \rangle].
+    """
+
+    def __init__(
+        self,
+        qgt: BaseQGT,
+        options: Options | None = None,
+    ):
+        r"""
+        Args:
+            qgt: The quantum geometric tensor used to compute the QFI.
+            options: Backend runtime options used for circuit execution. The order of priority is:
+                options in ``run`` method > QFI's default options > primitive's default
+                setting. Higher priority setting overrides lower priority setting.
+        """
+        self._qgt: BaseQGT = qgt
+        self._default_options = Options()
+        if options is not None:
+            self._default_options.update_options(**options)
+
+    def run(
+        self,
+        circuits: Sequence[QuantumCircuit],
+        parameter_values: Sequence[Sequence[float]],
+        parameters: Sequence[Sequence[Parameter] | None] | None = None,
+        **options,
+    ) -> AlgorithmJob:
+        """Run the job of the QFIs on the given circuits.
+
+        Args:
+            circuits: The list of quantum circuits to compute the QFIs.
+            parameter_values: The list of parameter values to be bound to the circuit.
+            parameters: The sequence of parameters to calculate only the QFIs of
+                the specified parameters. Each sequence of parameters corresponds to a circuit in
+                ``circuits``. Defaults to None, which means that the QFIs of all parameters in
+                each circuit are calculated.
+            options: Primitive backend runtime options used for circuit execution.
+                The order of priority is: options in ``run`` method > QFI's
+                default options > QGT's default setting.
+                Higher priority setting overrides lower priority setting.
+
+        Returns:
+            The job object of the QFIs of the expectation values. The i-th result corresponds to
+            ``circuits[i]`` evaluated with parameters bound as ``parameter_values[i]``.
+        """
+
+        if isinstance(circuits, QuantumCircuit):
+            # Allow a single circuit to be passed in.
+            circuits = (circuits,)
+
+        if parameters is None:
+            # If parameters is None, we calculate the gradients of all parameters in each circuit.
+            parameters = [circuit.parameters for circuit in circuits]
+        else:
+            # If parameters is not None, we calculate the gradients of the specified parameters.
+            # None in parameters means that the gradients of all parameters in the corresponding
+            # circuit are calculated.
+            parameters = [
+                params if params is not None else circuits[i].parameters
+                for i, params in enumerate(parameters)
+            ]
+        # The priority of run option is as follows:
+        # options in ``run`` method > QFI's default options > QGT's default setting.
+        opts = copy(self._default_options)
+        opts.update_options(**options)
+        job = AlgorithmJob(self._run, circuits, parameter_values, parameters, **opts.__dict__)
+        job.submit()
+        return job
+
+    def _run(
+        self,
+        circuits: Sequence[QuantumCircuit],
+        parameter_values: Sequence[Sequence[float]],
+        parameters: Sequence[Sequence[Parameter]],
+        **options,
+    ) -> QFIResult:
+        """Compute the QFI on the given circuits."""
+        # Set the derivative type to real
+        temp_derivative_type, self._qgt.derivative_type = (
+            self._qgt.derivative_type,
+            DerivativeType.REAL,
+        )
+        job = self._qgt.run(circuits, parameter_values, parameters, **options)
+
+        try:
+            result = job.result()
+        except AlgorithmError as exc:
+            raise AlgorithmError("Estimator job or gradient job failed.") from exc
+
+        self._qgt.derivative_type = temp_derivative_type
+
+        return QFIResult(
+            qfis=[4 * qgt.real for qgt in result.qgts],
+            metadata=result.metadata,
+            options=result.options,
+        )
+
+    @property
+    def options(self) -> Options:
+        """Return the union of QGT's options setting and QFI's default options,
+        where, if the same field is set in both, the QFI's default options override
+        the QGT's default setting.
+
+        Returns:
+            The QFI default + QGT options.
+        """
+        return self._get_local_options(self._default_options.__dict__)
+
+    def update_default_options(self, **options):
+        """Update the gradient's default options setting.
+
+        Args:
+            **options: The fields to update the default options.
+        """
+
+        self._default_options.update_options(**options)
+
+    def _get_local_options(self, options: Options) -> Options:
+        """Return the union of the QFI default setting,
+        the QGT default options, and the options in the ``run`` method.
+        The order of priority is: options in ``run`` method > QFI's default options > QGT's
+        default setting.
+
+        Args:
+            options: The fields to update the options
+
+        Returns:
+            The QFI default + QGT default + run options.
+        """
+        opts = copy(self._qgt.options)
+        opts.update_options(**options)
+        return opts
diff --git a/qiskit_machine_learning/gradients/qfi_result.py b/qiskit_machine_learning/gradients/qfi_result.py
new file mode 100644
index 000000000..57aeeb932
--- /dev/null
+++ b/qiskit_machine_learning/gradients/qfi_result.py
@@ -0,0 +1,35 @@
+# This code is part of a Qiskit project.
+#
+# (C) Copyright IBM 2022, 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
+# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
+#
+# Any modifications or derivative works of this code must retain this
+# copyright notice, and modified files need to carry a notice indicating
+# that they have been altered from the originals.
+"""
+QFI result class
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Any
+
+import numpy as np
+
+from qiskit.providers import Options
+
+
+@dataclass(frozen=True)
+class QFIResult:
+    """Result of QFI."""
+
+    qfis: list[np.ndarray]
+    """The QFI."""
+    metadata: list[dict[str, Any]]
+    """Additional information about the job."""
+    options: Options
+    """Primitive runtime options for the execution of the job."""
diff --git a/qiskit_machine_learning/gradients/spsa/__init__.py b/qiskit_machine_learning/gradients/spsa/__init__.py
new file mode 100644
index 000000000..c870af8ba
--- /dev/null
+++ b/qiskit_machine_learning/gradients/spsa/__init__.py
@@ -0,0 +1,11 @@
+# This code is part of a Qiskit project.
+#
+# (C) Copyright IBM 2022, 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
+# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
+#
+# Any modifications or derivative works of this code must retain this
+# copyright notice, and modified files need to carry a notice indicating
+# that they have been altered from the originals.
diff --git a/qiskit_machine_learning/gradients/spsa/spsa_estimator_gradient.py b/qiskit_machine_learning/gradients/spsa/spsa_estimator_gradient.py
new file mode 100644
index 000000000..1f9bfa0b2
--- /dev/null
+++ b/qiskit_machine_learning/gradients/spsa/spsa_estimator_gradient.py
@@ -0,0 +1,134 @@
+# This code is part of a Qiskit project.
+#
+# (C) Copyright IBM 2022, 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
+# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
+#
+# Any modifications or derivative works of this code must retain this
+# copyright notice, and modified files need to carry a notice indicating
+# that they have been altered from the originals.
+
+"""Gradient of Sampler with Finite difference method."""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+
+import numpy as np
+
+from qiskit.circuit import Parameter, QuantumCircuit
+from qiskit.primitives import BaseEstimator
+from qiskit.providers import Options
+from qiskit.quantum_info.operators.base_operator import BaseOperator
+
+from ..base.base_estimator_gradient import BaseEstimatorGradient
+from ..base.estimator_gradient_result import EstimatorGradientResult
+
+from ...exceptions import AlgorithmError
+
+
+class SPSAEstimatorGradient(BaseEstimatorGradient):
+    """
+    Compute the gradients of the expectation value by the Simultaneous Perturbation Stochastic
+    Approximation (SPSA) [1].
+
+    **Reference:**
+    [1] J. C. Spall, Adaptive stochastic approximation by the simultaneous perturbation method in
+    IEEE Transactions on Automatic Control, vol. 45, no. 10, pp. 1839-1853, Oct 2020,
+    `doi: 10.1109/TAC.2000.880982 <https://ieeexplore.ieee.org/document/880982>`_
+    """
+
+    def __init__(
+        self,
+        estimator: BaseEstimator,
+        epsilon: float,
+        batch_size: int = 1,
+        seed: int | None = None,
+        options: Options | None = None,
+    ):
+        """
+        Args:
+            estimator: The estimator used to compute the gradients.
+            epsilon: The offset size for the SPSA gradients.
+            batch_size: The number of gradients to average.
+            seed: The seed for a random perturbation vector.
+            options: Primitive backend runtime options used for circuit execution.
+                The order of priority is: options in ``run`` method > gradient's
+                default options > primitive's default setting.
+                Higher priority setting overrides lower priority setting
+
+        Raises:
+            ValueError: If ``epsilon`` is not positive.
+        """
+        if epsilon <= 0:
+            raise ValueError(f"epsilon ({epsilon}) should be positive.")
+        self._epsilon = epsilon
+        self._batch_size = batch_size
+        self._seed = np.random.default_rng(seed)
+
+        super().__init__(estimator, options)
+
+    def _run(
+        self,
+        circuits: Sequence[QuantumCircuit],
+        observables: Sequence[BaseOperator],
+        parameter_values: Sequence[Sequence[float]],
+        parameters: Sequence[Sequence[Parameter]],
+        **options,
+    ) -> EstimatorGradientResult:
+        """Compute the estimator gradients on the given circuits."""
+        job_circuits, job_observables, job_param_values, metadata, offsets = [], [], [], [], []
+        all_n = []
+        for circuit, observable, parameter_values_, parameters_ in zip(
+            circuits, observables, parameter_values, parameters
+        ):
+            # Indices of parameters to be differentiated.
+            indices = [circuit.parameters.data.index(p) for p in parameters_]
+            metadata.append({"parameters": parameters_})
+            # Make random perturbation vectors.
+            offset = [
+                (-1) ** (self._seed.integers(0, 2, len(circuit.parameters)))
+                for _ in range(self._batch_size)
+            ]
+            plus = [parameter_values_ + self._epsilon * offset_ for offset_ in offset]
+            minus = [parameter_values_ - self._epsilon * offset_ for offset_ in offset]
+            offsets.append(offset)
+
+            # Combine inputs into a single job to reduce overhead.
+            job_circuits.extend([circuit] * 2 * self._batch_size)
+            job_observables.extend([observable] * 2 * self._batch_size)
+            job_param_values.extend(plus + minus)
+            all_n.append(2 * self._batch_size)
+
+        # Run the single job with all circuits.
+        job = self._estimator.run(
+            job_circuits,
+            job_observables,
+            job_param_values,
+            **options,
+        )
+        try:
+            results = job.result()
+        except Exception as exc:
+            raise AlgorithmError("Estimator job failed.") from exc
+
+        # Compute the gradients.
+        gradients = []
+        partial_sum_n = 0
+        for i, n in enumerate(all_n):
+            result = results.values[partial_sum_n : partial_sum_n + n]
+            partial_sum_n += n
+            n = len(result) // 2
+            diffs = (result[:n] - result[n:]) / (2 * self._epsilon)
+            # Calculate the gradient for each batch. Note that (``diff`` / ``offset``) is the gradient
+            # since ``offset`` is a perturbation vector of 1s and -1s.
+            batch_gradients = np.array([diff / offset for diff, offset in zip(diffs, offsets[i])])
+            # Take the average of the batch gradients.
+            gradient = np.mean(batch_gradients, axis=0)
+            indices = [circuits[i].parameters.data.index(p) for p in metadata[i]["parameters"]]
+            gradients.append(gradient[indices])
+
+        opt = self._get_local_options(options)
+        return EstimatorGradientResult(gradients=gradients, metadata=metadata, options=opt)
diff --git a/qiskit_machine_learning/gradients/spsa/spsa_sampler_gradient.py b/qiskit_machine_learning/gradients/spsa/spsa_sampler_gradient.py
new file mode 100644
index 000000000..c3de7c4da
--- /dev/null
+++ b/qiskit_machine_learning/gradients/spsa/spsa_sampler_gradient.py
@@ -0,0 +1,136 @@
+# This code is part of a Qiskit project.
+#
+# (C) Copyright IBM 2022, 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
+# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
+#
+# Any modifications or derivative works of this code must retain this
+# copyright notice, and modified files need to carry a notice indicating
+# that they have been altered from the originals.
+
+"""Gradient of Sampler with Finite difference method."""
+
+from __future__ import annotations
+
+from collections import defaultdict
+from collections.abc import Sequence
+
+import numpy as np
+
+from qiskit.circuit import Parameter, QuantumCircuit
+from qiskit.primitives import BaseSampler
+from qiskit.providers import Options
+
+from ..base.base_sampler_gradient import BaseSamplerGradient
+from ..base.sampler_gradient_result import SamplerGradientResult
+
+from ...exceptions import AlgorithmError
+
+
+class SPSASamplerGradient(BaseSamplerGradient):
+    """
+    Compute the gradients of the sampling probability by the Simultaneous Perturbation Stochastic
+    Approximation (SPSA) [1].
+
+    **Reference:**
+    [1] J. C. Spall, Adaptive stochastic approximation by the simultaneous perturbation method in
+    IEEE Transactions on Automatic Control, vol. 45, no. 10, pp. 1839-1853, Oct 2020,
+    `doi: 10.1109/TAC.2000.880982 <https://ieeexplore.ieee.org/document/880982>`_.
+    """
+
+    def __init__(
+        self,
+        sampler: BaseSampler,
+        epsilon: float,
+        batch_size: int = 1,
+        seed: int | None = None,
+        options: Options | None = None,
+    ):
+        """
+        Args:
+            sampler: The sampler used to compute the gradients.
+            epsilon: The offset size for the SPSA gradients.
+            batch_size: number of gradients to average.
+            seed: The seed for a random perturbation vector.
+            options: Primitive backend runtime options used for circuit execution.
+                The order of priority is: options in ``run`` method > gradient's
+                default options > primitive's default setting.
+                Higher priority setting overrides lower priority setting
+
+        Raises:
+            ValueError: If ``epsilon`` is not positive.
+        """
+        if epsilon <= 0:
+            raise ValueError(f"epsilon ({epsilon}) should be positive.")
+        self._batch_size = batch_size
+        self._epsilon = epsilon
+        self._seed = np.random.default_rng(seed)
+
+        super().__init__(sampler, options)
+
+    def _run(
+        self,
+        circuits: Sequence[QuantumCircuit],
+        parameter_values: Sequence[Sequence[float]],
+        parameters: Sequence[Sequence[Parameter]],
+        **options,
+    ) -> SamplerGradientResult:
+        """Compute the sampler gradients on the given circuits."""
+        job_circuits, job_param_values, metadata, offsets = [], [], [], []
+        all_n = []
+        for circuit, parameter_values_, parameters_ in zip(circuits, parameter_values, parameters):
+            # Indices of parameters to be differentiated.
+            indices = [circuit.parameters.data.index(p) for p in parameters_]
+            metadata.append({"parameters": parameters_})
+            offset = np.array(
+                [
+                    (-1) ** (self._seed.integers(0, 2, len(circuit.parameters)))
+                    for _ in range(self._batch_size)
+                ]
+            )
+            plus = [parameter_values_ + self._epsilon * offset_ for offset_ in offset]
+            minus = [parameter_values_ - self._epsilon * offset_ for offset_ in offset]
+            offsets.append(offset)
+
+            # Combine inputs into a single job to reduce overhead.
+            n = 2 * self._batch_size
+            job_circuits.extend([circuit] * n)
+            job_param_values.extend(plus + minus)
+            all_n.append(n)
+
+        # Run the single job with all circuits.
+        job = self._sampler.run(job_circuits, job_param_values, **options)
+        try:
+            results = job.result()
+        except Exception as exc:
+            raise AlgorithmError("Sampler job failed.") from exc
+
+        # Compute the gradients.
+        gradients = []
+        partial_sum_n = 0
+        for i, n in enumerate(all_n):
+            dist_diffs = {}
+            result = results.quasi_dists[partial_sum_n : partial_sum_n + n]
+            for j, (dist_plus, dist_minus) in enumerate(zip(result[: n // 2], result[n // 2 :])):
+                dist_diff: dict[int, float] = defaultdict(float)
+                for key, value in dist_plus.items():
+                    dist_diff[key] += value / (2 * self._epsilon)
+                for key, value in dist_minus.items():
+                    dist_diff[key] -= value / (2 * self._epsilon)
+                dist_diffs[j] = dist_diff
+            gradient = []
+            indices = [circuits[i].parameters.data.index(p) for p in metadata[i]["parameters"]]
+            for j in indices:
+                gradient_j: dict[int, float] = defaultdict(float)
+                for k in range(self._batch_size):
+                    for key, value in dist_diffs[k].items():
+                        gradient_j[key] += value * offsets[i][k][j]
+                gradient_j = {key: value / self._batch_size for key, value in gradient_j.items()}
+                gradient.append(gradient_j)
+            gradients.append(gradient)
+            partial_sum_n += n
+
+        opt = self._get_local_options(options)
+        return SamplerGradientResult(gradients=gradients, metadata=metadata, options=opt)
diff --git a/qiskit_machine_learning/gradients/utils.py b/qiskit_machine_learning/gradients/utils.py
new file mode 100644
index 000000000..53ef7fcc2
--- /dev/null
+++ b/qiskit_machine_learning/gradients/utils.py
@@ -0,0 +1,375 @@
+# This code is part of a Qiskit project.
+#
+# (C) Copyright IBM 2022, 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
+# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
+#
+# Any modifications or derivative works of this code must retain this
+# copyright notice, and modified files need to carry a notice indicating
+# that they have been altered from the originals.
+
+"""
+Utility functions for gradients
+"""
+
+from __future__ import annotations
+
+from collections import defaultdict
+from collections.abc import Sequence
+from dataclasses import dataclass
+from enum import Enum
+
+import numpy as np
+
+from qiskit.circuit import (
+    ClassicalRegister,
+    Gate,
+    Instruction,
+    Parameter,
+    ParameterExpression,
+    QuantumCircuit,
+    QuantumRegister,
+)
+from qiskit.circuit.library.standard_gates import (
+    CXGate,
+    CYGate,
+    CZGate,
+    RXGate,
+    RXXGate,
+    RYGate,
+    RYYGate,
+    RZGate,
+    RZXGate,
+    RZZGate,
+    XGate,
+)
+from qiskit.quantum_info import SparsePauliOp
+
+
+################################################################################
+## Gradient circuits and Enum
+################################################################################
+class DerivativeType(Enum):
+    """Types of derivative."""
+
+    REAL = "real"
+    IMAG = "imag"
+    COMPLEX = "complex"
+
+
+@dataclass
+class GradientCircuit:
+    """Gradient circuit with unique parameters and mapping information."""
+
+    gradient_circuit: QuantumCircuit
+    """An internal quantum circuit with unique parameters used to calculate the gradient"""
+    parameter_map: dict[Parameter, list[tuple[Parameter, float | ParameterExpression]]]
+    """A dictionary maps the parameters of ``circuit`` to the parameters of ``gradient_circuit`` with
+    coefficients"""
+    gradient_parameter_map: dict[Parameter, ParameterExpression]
+    """A dictionary maps the parameters of ``gradient_circuit`` to the parameter expressions of
+    ``circuit``"""
+
+
+@dataclass
+class LinearCombGradientCircuit:
+    """Gradient circuit for the linear combination of unitaries method."""
+
+    gradient_circuit: QuantumCircuit
+    """A gradient circuit  for the linear combination of unitaries method."""
+    coeff: float | ParameterExpression
+    """A coefficient corresponds to the gradient circuit."""
+
+
+################################################################################
+## Parameter shift gradient
+################################################################################
+def _make_param_shift_parameter_values(  # pylint: disable=invalid-name
+    circuit: QuantumCircuit,
+    parameter_values: np.ndarray | Sequence[float],
+    parameters: Sequence[Parameter],
+) -> list[np.ndarray]:
+    """Returns a list of parameter values with offsets for parameter shift rule.
+
+    Args:
+        circuit: The original quantum circuit
+        parameter_values: parameter values to be added to the base parameter values.
+        parameters: The parameters to be shifted.
+
+    Returns:
+        A list of parameter values with offsets for parameter shift rule.
+    """
+    indices = [circuit.parameters.data.index(p) for p in parameters]
+    offset = np.identity(circuit.num_parameters)[indices, :]
+    plus_offsets = parameter_values + offset * np.pi / 2
+    minus_offsets = parameter_values - offset * np.pi / 2
+    return plus_offsets.tolist() + minus_offsets.tolist()
+
+
+################################################################################
+## Linear combination gradient and Linear combination QGT
+################################################################################
+def _make_lin_comb_gradient_circuit(
+    circuit: QuantumCircuit, add_measurement: bool = False
+) -> dict[Parameter, QuantumCircuit]:
+    """Makes a circuit that computes the linear combination of the gradient circuits."""
+    circuit_temp = circuit.copy()
+    qr_aux = QuantumRegister(1, "qr_aux")
+    cr_aux = ClassicalRegister(1, "cr_aux")
+    circuit_temp.add_register(qr_aux)
+    circuit_temp.add_register(cr_aux)
+    circuit_temp.h(qr_aux)
+    circuit_temp.data.insert(0, circuit_temp.data.pop())
+    circuit_temp.sdg(qr_aux)
+    circuit_temp.data.insert(1, circuit_temp.data.pop())
+
+    lin_comb_circuits = {}
+    for i, instruction in enumerate(circuit_temp.data):
+        if instruction.operation.is_parameterized():
+            for p in instruction.operation.params[0].parameters:  # pylint: disable=invalid-name
+                gate = _gate_gradient(instruction.operation)
+                lin_comb_circuit = circuit_temp.copy()
+                # insert `gate` to i-th position
+                lin_comb_circuit.append(gate, [qr_aux[0]] + list(instruction.qubits), [])
+                lin_comb_circuit.data.insert(i, lin_comb_circuit.data.pop())
+                lin_comb_circuit.h(qr_aux)
+                if add_measurement:
+                    lin_comb_circuit.measure(qr_aux, cr_aux)
+                lin_comb_circuits[p] = lin_comb_circuit
+
+    return lin_comb_circuits
+
+
+def _gate_gradient(gate: Gate) -> Instruction:
+    """Returns the derivative of the gate"""
+    # pylint: disable=too-many-return-statements
+    if isinstance(gate, RXGate):
+        return CXGate()
+    if isinstance(gate, RYGate):
+        return CYGate()
+    if isinstance(gate, RZGate):
+        return CZGate()
+    if isinstance(gate, RXXGate):
+        cxx_circ = QuantumCircuit(3)
+        cxx_circ.cx(0, 1)
+        cxx_circ.cx(0, 2)
+        cxx = cxx_circ.to_instruction()
+        return cxx
+    if isinstance(gate, RYYGate):
+        cyy_circ = QuantumCircuit(3)
+        cyy_circ.cy(0, 1)
+        cyy_circ.cy(0, 2)
+        cyy = cyy_circ.to_instruction()
+        return cyy
+    if isinstance(gate, RZZGate):
+        czz_circ = QuantumCircuit(3)
+        czz_circ.cz(0, 1)
+        czz_circ.cz(0, 2)
+        czz = czz_circ.to_instruction()
+        return czz
+    if isinstance(gate, RZXGate):
+        czx_circ = QuantumCircuit(3)
+        czx_circ.cx(0, 2)
+        czx_circ.cz(0, 1)
+        czx = czx_circ.to_instruction()
+        return czx
+    raise TypeError(f"Unrecognized parameterized gate, {gate}")
+
+
+def _make_lin_comb_qgt_circuit(
+    circuit: QuantumCircuit, add_measurement: bool = False
+) -> dict[tuple[Parameter, Parameter], QuantumCircuit]:
+    """Makes a circuit that computes the linear combination of the QGT circuits."""
+    circuit_temp = circuit.copy()
+    qr_aux = QuantumRegister(1, "aux")
+    circuit_temp.add_register(qr_aux)
+    if add_measurement:
+        cr_aux = ClassicalRegister(1, "aux")
+        circuit_temp.add_bits(cr_aux)
+    circuit_temp.h(qr_aux)
+    circuit_temp.data.insert(0, circuit_temp.data.pop())
+
+    lin_comb_qgt_circuits = {}
+    for i, instruction_i in enumerate(circuit_temp.data):
+        if not instruction_i.operation.is_parameterized():
+            continue
+        for j, instruction_j in enumerate(circuit_temp.data):
+            if not instruction_j.operation.is_parameterized():
+                continue
+            # Calculate the QGT of the i-th gate with respect to the j-th gate.
+            param_i = instruction_i.operation.params[0]
+            param_j = instruction_j.operation.params[0]
+
+            for p_i in param_i.parameters:
+                for p_j in param_j.parameters:
+                    if circuit_temp.parameters.data.index(p_i) > circuit_temp.parameters.data.index(
+                        p_j
+                    ):
+                        continue
+                    gate_i = _gate_gradient(instruction_i.operation)
+                    gate_j = _gate_gradient(instruction_j.operation)
+                    lin_comb_qgt_circuit = circuit_temp.copy()
+                    if i < j:
+                        # insert gate_j to j-th position
+                        lin_comb_qgt_circuit.append(
+                            gate_j, [qr_aux[0]] + list(instruction_j.qubits), []
+                        )
+                        lin_comb_qgt_circuit.data.insert(j, lin_comb_qgt_circuit.data.pop())
+                        # insert gate_i to i-th position with two X gates at its sides
+                        lin_comb_qgt_circuit.append(XGate(), [qr_aux[0]], [])
+                        lin_comb_qgt_circuit.data.insert(i, lin_comb_qgt_circuit.data.pop())
+                        lin_comb_qgt_circuit.append(
+                            gate_i, [qr_aux[0]] + list(instruction_i.qubits), []
+                        )
+                        lin_comb_qgt_circuit.data.insert(i, lin_comb_qgt_circuit.data.pop())
+                        lin_comb_qgt_circuit.append(XGate(), [qr_aux[0]], [])
+                        lin_comb_qgt_circuit.data.insert(i, lin_comb_qgt_circuit.data.pop())
+                    else:
+                        # insert gate_i to i-th position
+                        lin_comb_qgt_circuit.append(
+                            gate_i, [qr_aux[0]] + list(instruction_i.qubits), []
+                        )
+                        lin_comb_qgt_circuit.data.insert(i, lin_comb_qgt_circuit.data.pop())
+                        # insert gate_j to j-th position with two X gates at its sides
+                        lin_comb_qgt_circuit.append(XGate(), [qr_aux[0]], [])
+                        lin_comb_qgt_circuit.data.insert(j, lin_comb_qgt_circuit.data.pop())
+                        lin_comb_qgt_circuit.append(
+                            gate_j, [qr_aux[0]] + list(instruction_j.qubits), []
+                        )
+                        lin_comb_qgt_circuit.data.insert(j, lin_comb_qgt_circuit.data.pop())
+                        lin_comb_qgt_circuit.append(XGate(), [qr_aux[0]], [])
+                        lin_comb_qgt_circuit.data.insert(j, lin_comb_qgt_circuit.data.pop())
+
+                    lin_comb_qgt_circuit.h(qr_aux)
+                    if add_measurement:
+                        lin_comb_qgt_circuit.measure(qr_aux, cr_aux)
+                    lin_comb_qgt_circuits[(p_i, p_j)] = lin_comb_qgt_circuit
+
+    return lin_comb_qgt_circuits
+
+
+def _make_lin_comb_observables(
+    observable: SparsePauliOp,
+    derivative_type: DerivativeType,
+) -> tuple[SparsePauliOp, SparsePauliOp | None]:
+    """Make the observable with an ancillary operator for the linear combination gradient.
+
+    Args:
+        observable: The observable.
+        derivative_type: The type of derivative. Can be either ``DerivativeType.REAL``
+            ``DerivativeType.IMAG``, or ``DerivativeType.COMPLEX``.
+
+    Returns:
+        The observable with an ancillary operator for the linear combination gradient.
+
+    Raises:
+        ValueError: If the derivative type is not supported.
+    """
+    if derivative_type == DerivativeType.REAL:
+        return observable.expand(SparsePauliOp.from_list([("Z", 1)])), None
+    elif derivative_type == DerivativeType.IMAG:
+        return observable.expand(SparsePauliOp.from_list([("Y", -1)])), None
+    elif derivative_type == DerivativeType.COMPLEX:
+        return observable.expand(SparsePauliOp.from_list([("Z", 1)])), observable.expand(
+            SparsePauliOp.from_list([("Y", -1)])
+        )
+    else:
+        raise ValueError(f"Derivative type {derivative_type} is not supported.")
+
+
+################################################################################
+## Preprocess
+################################################################################
+def _assign_unique_parameters(
+    circuit: QuantumCircuit,
+) -> GradientCircuit:
+    """Assign unique parameters to the circuit.
+
+    Args:
+        circuit: The circuit to assign unique parameters.
+
+    Returns:
+        The circuit with unique parameters and the mapping from the original parameters to the
+        unique parameters.
+    """
+    gradient_circuit = circuit.copy_empty_like(f"{circuit.name}_gradient")
+    parameter_map = defaultdict(list)
+    gradient_parameter_map = {}
+    num_gradient_parameters = 0
+    for instruction in circuit.data:
+        if instruction.operation.is_parameterized():
+            new_op_params = []
+            for angle in instruction.operation.params:
+                new_parameter = Parameter(f"__gθ{num_gradient_parameters}")
+                new_op_params.append(new_parameter)
+                num_gradient_parameters += 1
+                for parameter in angle.parameters:
+                    parameter_map[parameter].append((new_parameter, angle.gradient(parameter)))
+                gradient_parameter_map[new_parameter] = angle
+            instruction.operation.params = new_op_params
+        gradient_circuit.append(instruction.operation, instruction.qubits, instruction.clbits)
+    # For the global phase
+    gradient_circuit.global_phase = circuit.global_phase
+    if isinstance(gradient_circuit.global_phase, ParameterExpression):
+        substitution_map = {}
+        for parameter in gradient_circuit.global_phase.parameters:
+            if parameter in parameter_map:
+                substitution_map[parameter] = parameter_map[parameter][0][0]
+            else:
+                new_parameter = Parameter(f"__gθ{num_gradient_parameters}")
+                substitution_map[parameter] = new_parameter
+                parameter_map[parameter].append((new_parameter, 1))
+                num_gradient_parameters += 1
+        gradient_circuit.global_phase = gradient_circuit.global_phase.subs(substitution_map)
+    return GradientCircuit(gradient_circuit, parameter_map, gradient_parameter_map)
+
+
+def _make_gradient_parameter_values(
+    circuit: QuantumCircuit,
+    gradient_circuit: GradientCircuit,
+    parameter_values: np.ndarray | Sequence[float],
+) -> np.ndarray:
+    """Makes parameter values for the gradient circuit.
+
+    Args:
+        circuit: The original quantum circuit
+        gradient_circuit: The gradient circuit
+        parameter_values: The parameter values for the original circuit
+        parameter_set: The parameter set to calculate gradients
+
+    Returns:
+        The parameter values for the gradient circuit.
+    """
+    g_circuit = gradient_circuit.gradient_circuit
+    g_parameter_values = np.empty(len(g_circuit.parameters))
+    for i, g_parameter in enumerate(g_circuit.parameters):
+        expr = gradient_circuit.gradient_parameter_map[g_parameter]
+        bound_expr = expr.bind(
+            {p: parameter_values[circuit.parameters.data.index(p)] for p in expr.parameters}
+        )
+        g_parameter_values[i] = float(bound_expr)
+    return g_parameter_values
+
+
+def _make_gradient_parameters(
+    gradient_circuit: GradientCircuit,
+    parameters: Sequence[Parameter],
+) -> Sequence[Parameter]:
+    """Makes parameter set for the gradient circuit.
+
+    Args:
+        gradient_circuit: The gradient circuit
+        parameters: The parameters in the original circuit to calculate gradients
+
+    Returns:
+        The parameters in the gradient circuit to calculate gradients.
+    """
+    g_parameters = [
+        g_parameter
+        for parameter in parameters
+        for g_parameter, _ in gradient_circuit.parameter_map[parameter]
+    ]
+    # make g_parameters unique and return it.
+    return list(dict.fromkeys(g_parameters))
diff --git a/qiskit_machine_learning/kernels/algorithms/quantum_kernel_trainer.py b/qiskit_machine_learning/kernels/algorithms/quantum_kernel_trainer.py
index 4d272b2ac..97dbaf014 100644
--- a/qiskit_machine_learning/kernels/algorithms/quantum_kernel_trainer.py
+++ b/qiskit_machine_learning/kernels/algorithms/quantum_kernel_trainer.py
@@ -18,12 +18,11 @@
 
 import numpy as np
 
-from qiskit_algorithms.optimizers import Optimizer, SPSA, Minimizer
-from qiskit_algorithms.utils import algorithm_globals
-from qiskit_algorithms.variational_algorithm import VariationalResult
-from qiskit_machine_learning.utils.loss_functions import KernelLoss, SVCLoss
-
-from qiskit_machine_learning.kernels import TrainableKernel
+from ...optimizers import Optimizer, SPSA, Minimizer
+from ...utils import algorithm_globals
+from ...variational_algorithm import VariationalResult
+from ...utils.loss_functions import KernelLoss, SVCLoss
+from ...kernels import TrainableKernel
 
 
 class QuantumKernelTrainerResult(VariationalResult):
@@ -102,12 +101,12 @@ def __init__(
                 passed as the loss function, then the underlying
                 :class:`~qiskit_machine_learning.utils.loss_functions.SVCLoss` object will exhibit
                 default behavior.
-            optimizer: An instance of :class:`~qiskit_algorithms.optimizers.Optimizer` or a
+            optimizer: An instance of :class:`~qiskit_machine_learning.optimizers.Optimizer` or a
                 callable to be used in training. Refer to
-                :class:`~qiskit_algorithms.optimizers.Minimizer` for more information on the
+                :class:`~qiskit_machine_learning.optimizers.Minimizer` for more information on the
                 callable protocol. Since no analytical gradient is defined for kernel loss
                 functions, gradient-based optimizers are not recommended for training kernels. When
-                `None` defaults to :class:`~qiskit_algorithms.optimizers.SPSA`.
+                `None` defaults to :class:`~qiskit_machine_learning.optimizers.SPSA`.
             initial_point: Initial point from which the optimizer will begin.
 
         Raises:
@@ -200,27 +199,29 @@ def fit(
 
         # Randomly initialize the initial point if one was not passed
         if self._initial_point is None:
-            self._initial_point = algorithm_globals.random.random(num_params)
+            self._initial_point = algorithm_globals.random.random(num_params)  # type: ignore[assignment]
 
         # Perform kernel optimization
         loss_function = partial(
             self._loss.evaluate, quantum_kernel=self.quantum_kernel, data=data, labels=labels
         )
         if callable(self._optimizer):
-            opt_results = self._optimizer(fun=loss_function, x0=self._initial_point)
+            opt_results = self._optimizer(
+                fun=loss_function, x0=self._initial_point  # type: ignore[call-arg, arg-type]
+            )
         else:
             opt_results = self._optimizer.minimize(
                 fun=loss_function,
-                x0=self._initial_point,
+                x0=self._initial_point,  # type: ignore[arg-type]
             )
 
         # Return kernel training results
         result = QuantumKernelTrainerResult()
         result.optimizer_evals = opt_results.nfev
         result.optimal_value = opt_results.fun
-        result.optimal_point = opt_results.x
+        result.optimal_point = opt_results.x  # type: ignore[assignment]
         result.optimal_parameters = dict(
-            zip(self.quantum_kernel.training_parameters, opt_results.x)
+            zip(self.quantum_kernel.training_parameters, opt_results.x)  # type: ignore[arg-type]
         )
 
         # Return the QuantumKernel in optimized state
diff --git a/qiskit_machine_learning/kernels/fidelity_quantum_kernel.py b/qiskit_machine_learning/kernels/fidelity_quantum_kernel.py
index fc72b9635..e9769addc 100644
--- a/qiskit_machine_learning/kernels/fidelity_quantum_kernel.py
+++ b/qiskit_machine_learning/kernels/fidelity_quantum_kernel.py
@@ -19,7 +19,7 @@
 import numpy as np
 from qiskit import QuantumCircuit
 from qiskit.primitives import Sampler
-from qiskit_algorithms.state_fidelities import BaseStateFidelity, ComputeUncompute
+from ..state_fidelities import BaseStateFidelity, ComputeUncompute
 
 from .base_kernel import BaseKernel
 
@@ -29,7 +29,7 @@
 class FidelityQuantumKernel(BaseKernel):
     r"""
     An implementation of the quantum kernel interface based on the
-    :class:`~qiskit_algorithms.state_fidelities.BaseStateFidelity` algorithm.
+    :class:`~qiskit_machine_learning.state_fidelities.BaseStateFidelity` algorithm.
 
     Here, the kernel function is defined as the overlap of two quantum states defined by a
     parametrized quantum circuit (called feature map):
@@ -56,9 +56,9 @@ def __init__(
                 in the dataset, then the kernel will try to adjust the feature map to reflect the
                 number of features.
             fidelity: An instance of the
-                :class:`~qiskit_algorithms.state_fidelities.BaseStateFidelity` primitive to be used
+                :class:`~qiskit_machine_learning.state_fidelities.BaseStateFidelity` primitive to be used
                 to compute fidelity between states. Default is
-                :class:`~qiskit_algorithms.state_fidelities.ComputeUncompute` which is created on
+                :class:`~qiskit_machine_learning.state_fidelities.ComputeUncompute` which is created on
                 top of the reference sampler defined by :class:`~qiskit.primitives.Sampler`.
             enforce_psd: Project to the closest positive semidefinite matrix if ``x = y``.
                 Default ``True``.
@@ -229,8 +229,8 @@ def _get_kernel_entries(
                 job = self._fidelity.run(
                     [self._feature_map] * num_circuits,
                     [self._feature_map] * num_circuits,
-                    left_parameters,
-                    right_parameters,
+                    left_parameters,  # type: ignore[arg-type]
+                    right_parameters,  # type: ignore[arg-type]
                 )
                 kernel_entries = job.result().fidelities
             else:
@@ -249,8 +249,8 @@ def _get_kernel_entries(
                     job = self._fidelity.run(
                         [self._feature_map] * (end_idx - start_idx),
                         [self._feature_map] * (end_idx - start_idx),
-                        chunk_left_parameters,
-                        chunk_right_parameters,
+                        chunk_left_parameters,  # type: ignore[arg-type]
+                        chunk_right_parameters,  # type: ignore[arg-type]
                     )
                     # Extend the kernel_entries list with the results from this chunk
                     kernel_entries.extend(job.result().fidelities)
diff --git a/qiskit_machine_learning/kernels/fidelity_statevector_kernel.py b/qiskit_machine_learning/kernels/fidelity_statevector_kernel.py
index 1a15feef3..8fa70d381 100644
--- a/qiskit_machine_learning/kernels/fidelity_statevector_kernel.py
+++ b/qiskit_machine_learning/kernels/fidelity_statevector_kernel.py
@@ -20,7 +20,7 @@
 
 from qiskit import QuantumCircuit
 from qiskit.quantum_info import Statevector
-from qiskit_algorithms.utils import algorithm_globals
+from ..utils import algorithm_globals
 
 
 from .base_kernel import BaseKernel
diff --git a/qiskit_machine_learning/kernels/trainable_fidelity_quantum_kernel.py b/qiskit_machine_learning/kernels/trainable_fidelity_quantum_kernel.py
index 92d75c5f3..196113781 100644
--- a/qiskit_machine_learning/kernels/trainable_fidelity_quantum_kernel.py
+++ b/qiskit_machine_learning/kernels/trainable_fidelity_quantum_kernel.py
@@ -1,6 +1,6 @@
 # This code is part of a Qiskit project.
 #
-# (C) Copyright IBM 2022, 2023.
+# (C) Copyright IBM 2022, 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
@@ -19,7 +19,7 @@
 import numpy as np
 from qiskit import QuantumCircuit
 from qiskit.circuit import Parameter, ParameterVector
-from qiskit_algorithms.state_fidelities import BaseStateFidelity
+from ..state_fidelities import BaseStateFidelity
 
 from .fidelity_quantum_kernel import FidelityQuantumKernel, KernelIndices
 from .trainable_kernel import TrainableKernel
@@ -28,8 +28,8 @@
 class TrainableFidelityQuantumKernel(TrainableKernel, FidelityQuantumKernel):
     r"""
     An implementation of the quantum kernel that is based on the
-    :class:`~qiskit_algorithms.state_fidelities.BaseStateFidelity` algorithm and provides ability to
-    train it.
+    :class:`~qiskit_machine_learning.state_fidelities.BaseStateFidelity` algorithm
+    and provides ability to train it.
 
     Finding good quantum kernels for a specific machine learning task is a big challenge in quantum
     machine learning. One way to choose the kernel is to add trainable parameters to the feature
@@ -60,9 +60,9 @@ def __init__(
                 in the dataset, then the kernel will try to adjust the feature map to reflect the
                 number of features.
             fidelity: An instance of the
-                :class:`~qiskit_algorithms.state_fidelities.BaseStateFidelity` primitive to be used
+                :class:`~qiskit_machine_learning.state_fidelities.BaseStateFidelity` primitive to be used
                 to compute fidelity between states. Default is
-                :class:`~qiskit_algorithms.state_fidelities.ComputeUncompute` which is created on
+                :class:`~qiskit_machine_learning.state_fidelities.ComputeUncompute` which is created on
                 top of the reference sampler defined by :class:`~qiskit.primitives.Sampler`.
             training_parameters: Iterable containing :class:`~qiskit.circuit.Parameter` objects
                 which correspond to quantum gates on the feature map circuit which may be tuned.
diff --git a/qiskit_machine_learning/neural_networks/effective_dimension.py b/qiskit_machine_learning/neural_networks/effective_dimension.py
index 6a6feea12..1411b9bf8 100644
--- a/qiskit_machine_learning/neural_networks/effective_dimension.py
+++ b/qiskit_machine_learning/neural_networks/effective_dimension.py
@@ -18,8 +18,8 @@
 import numpy as np
 from scipy.special import logsumexp
 
-from qiskit_algorithms.utils import algorithm_globals
-from qiskit_machine_learning import QiskitMachineLearningError
+from ..utils import algorithm_globals
+from ..exceptions import QiskitMachineLearningError
 from .estimator_qnn import EstimatorQNN
 from .neural_network import NeuralNetwork
 
@@ -252,7 +252,6 @@ def _get_effective_dimension(
         normalized_fisher: np.ndarray,
         dataset_size: Union[List[int], np.ndarray, int],
     ) -> Union[np.ndarray, int]:
-
         if not isinstance(dataset_size, int) and len(dataset_size) > 1:
             # expand dims for broadcasting
             normalized_fisher = np.expand_dims(normalized_fisher, axis=0)
diff --git a/qiskit_machine_learning/neural_networks/estimator_qnn.py b/qiskit_machine_learning/neural_networks/estimator_qnn.py
index d0c8d2d7d..f55d82224 100644
--- a/qiskit_machine_learning/neural_networks/estimator_qnn.py
+++ b/qiskit_machine_learning/neural_networks/estimator_qnn.py
@@ -23,14 +23,14 @@
 from qiskit.primitives import BaseEstimator, Estimator, EstimatorResult
 from qiskit.quantum_info import SparsePauliOp
 from qiskit.quantum_info.operators.base_operator import BaseOperator
-from qiskit_algorithms.gradients import (
+from ..gradients import (
     BaseEstimatorGradient,
     EstimatorGradientResult,
     ParamShiftEstimatorGradient,
 )
 
-from qiskit_machine_learning.circuit.library import QNNCircuit
-from qiskit_machine_learning.exceptions import QiskitMachineLearningError
+from ..circuit.library import QNNCircuit
+from ..exceptions import QiskitMachineLearningError
 
 from .neural_network import NeuralNetwork
 
@@ -136,7 +136,7 @@ def __init__(
                 :class:`~qiskit_machine_learning.circuit.library.QNNCircuit` weight_parameters.
             gradient: The estimator gradient to be used for the backward pass.
                 If None, a default instance of the estimator gradient,
-                :class:`~qiskit_algorithms.gradients.ParamShiftEstimatorGradient`, will be used.
+                :class:`~qiskit_machine_learning.gradients.ParamShiftEstimatorGradient`, will be used.
             input_gradients: Determines whether to compute gradients with respect to input data.
                 Note that this parameter is ``False`` by default, and must be explicitly set to
                 ``True`` for a proper gradient computation when using
@@ -270,10 +270,12 @@ def _backward(
 
             job = None
             if self._input_gradients:
-                job = self.gradient.run(circuits, observables, param_values)
+                job = self.gradient.run(circuits, observables, param_values)  # type: ignore[arg-type]
             elif len(parameter_values[0]) > self._num_inputs:
                 params = [self._circuit.parameters[self._num_inputs :]] * num_circuits
-                job = self.gradient.run(circuits, observables, param_values, parameters=params)
+                job = self.gradient.run(
+                    circuits, observables, param_values, parameters=params  # type: ignore[arg-type]
+                )
 
             if job is not None:
                 try:
diff --git a/qiskit_machine_learning/neural_networks/sampler_qnn.py b/qiskit_machine_learning/neural_networks/sampler_qnn.py
index 61930e2fa..6982d2e87 100644
--- a/qiskit_machine_learning/neural_networks/sampler_qnn.py
+++ b/qiskit_machine_learning/neural_networks/sampler_qnn.py
@@ -19,19 +19,20 @@
 from typing import Callable, cast, Iterable, Sequence
 
 import numpy as np
+
 from qiskit.circuit import Parameter, QuantumCircuit
 from qiskit.primitives import BaseSampler, SamplerResult, Sampler
-from qiskit_algorithms.gradients import (
+
+import qiskit_machine_learning.optionals as _optionals
+from .neural_network import NeuralNetwork
+from ..gradients import (
     BaseSamplerGradient,
     ParamShiftSamplerGradient,
     SamplerGradientResult,
 )
+from ..circuit.library import QNNCircuit
+from ..exceptions import QiskitMachineLearningError
 
-from qiskit_machine_learning.circuit.library import QNNCircuit
-from qiskit_machine_learning.exceptions import QiskitMachineLearningError
-import qiskit_machine_learning.optionals as _optionals
-
-from .neural_network import NeuralNetwork
 
 if _optionals.HAS_SPARSE:
     # pylint: disable=import-error
@@ -164,7 +165,7 @@ def __init__(
                 ``2^circuit.num_qubits``.
             gradient: An optional sampler gradient to be used for the backward pass.
                 If ``None`` is given, a default instance of
-                :class:`~qiskit_algorithms.gradients.ParamShiftSamplerGradient` will be used.
+                :class:`~qiskit_machine_learning.gradients.ParamShiftSamplerGradient` will be used.
             input_gradients: Determines whether to compute gradients with respect to input data.
                  Note that this parameter is ``False`` by default, and must be explicitly set to
                  ``True`` for a proper gradient computation when using
@@ -328,7 +329,6 @@ def _postprocess_gradient(
             )
             weights_grad = DOK((num_samples, *self._output_shape, self._num_weights))
         else:
-
             input_grad = (
                 np.zeros((num_samples, *self._output_shape, self._num_inputs))
                 if self._input_gradients
@@ -413,10 +413,12 @@ def _backward(
 
             job = None
             if self._input_gradients:
-                job = self.gradient.run(circuits, parameter_values)
+                job = self.gradient.run(circuits, parameter_values)  # type: ignore[arg-type]
             elif len(parameter_values[0]) > self._num_inputs:
                 params = [self._circuit.parameters[self._num_inputs :]] * num_samples
-                job = self.gradient.run(circuits, parameter_values, parameters=params)
+                job = self.gradient.run(
+                    circuits, parameter_values, parameters=params  # type: ignore[arg-type]
+                )
 
             if job is not None:
                 try:
diff --git a/qiskit_machine_learning/optimizers/__init__.py b/qiskit_machine_learning/optimizers/__init__.py
new file mode 100644
index 000000000..6b3f0309e
--- /dev/null
+++ b/qiskit_machine_learning/optimizers/__init__.py
@@ -0,0 +1,170 @@
+# This code is part of a Qiskit project.
+#
+# (C) Copyright IBM 2018, 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
+# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
+#
+# Any modifications or derivative works of this code must retain this
+# copyright notice, and modified files need to carry a notice indicating
+# that they have been altered from the originals.
+
+"""
+Optimizers (:mod:`qiskit_machine_learning.optimizers`)
+================================================
+Classical Optimizers.
+
+This package contains a variety of classical optimizers and were designed for use by
+qiskit_algorithm's quantum variational algorithms, such as :class:`~qiskit_machine_learning.VQE`.
+Logically, these optimizers can be divided into two categories:
+
+`Local Optimizers`_
+  Given an optimization problem, a **local optimizer** is a function
+  that attempts to find an optimal value within the neighboring set of a candidate solution.
+
+`Global Optimizers`_
+  Given an optimization problem, a **global optimizer** is a function
+  that attempts to find an optimal value among all possible solutions.
+
+.. currentmodule:: qiskit_machine_learning.optimizers
+
+Optimizer Base Classes
+----------------------
+
+.. autosummary::
+   :toctree: ../stubs/
+   :nosignatures:
+
+   OptimizerResult
+   Optimizer
+   Minimizer
+
+Steppable Optimization
+----------------------
+
+.. autosummary::
+   :toctree: ../stubs/
+
+   optimizer_utils
+
+.. autosummary::
+   :toctree: ../stubs/
+   :nosignatures:
+
+   SteppableOptimizer
+   AskData
+   TellData
+   OptimizerState
+
+
+Local Optimizers
+----------------
+
+.. autosummary::
+   :toctree: ../stubs/
+   :nosignatures:
+
+   ADAM
+   AQGD
+   CG
+   COBYLA
+   L_BFGS_B
+   GSLS
+   GradientDescent
+   GradientDescentState
+   NELDER_MEAD
+   NFT
+   P_BFGS
+   POWELL
+   SLSQP
+   SPSA
+   QNSPSA
+   TNC
+   SciPyOptimizer
+   UMDA
+
+The optimizers from
+`scikit-quant <https://scikit-quant.readthedocs.io/en/latest/>`_ are not included in the
+Qiskit Machine Learning library.
+To continue using them, please import them from Qiskit Algorithms. Be aware that and a
+deprecation of the methods `snobfit`, `imfil` and `bobyqa` the was considered:
+https://github.com/qiskit-community/qiskit-algorithms/issues/84.
+
+
+Global Optimizers
+-----------------
+The global optimizers here all use `NLOpt <https://nlopt.readthedocs.io/en/latest/>`_ for their
+core function and can only be used if the optional dependent ``NLOpt`` package is installed.
+To install the ``NLOpt`` dependent package you can use ``pip install nlopt``.
+
+.. autosummary::
+   :toctree: ../stubs/
+   :nosignatures:
+
+   CRS
+   DIRECT_L
+   DIRECT_L_RAND
+   ESCH
+   ISRES
+
+"""
+
+from .adam_amsgrad import ADAM
+from .aqgd import AQGD
+from .cg import CG
+from .cobyla import COBYLA
+from .gsls import GSLS
+from .gradient_descent import GradientDescent, GradientDescentState
+from .l_bfgs_b import L_BFGS_B
+from .nelder_mead import NELDER_MEAD
+from .nft import NFT
+from .nlopts.crs import CRS
+from .nlopts.direct_l import DIRECT_L
+from .nlopts.direct_l_rand import DIRECT_L_RAND
+from .nlopts.esch import ESCH
+from .nlopts.isres import ISRES
+from .steppable_optimizer import SteppableOptimizer, AskData, TellData, OptimizerState
+from .optimizer import Minimizer, Optimizer, OptimizerResult, OptimizerSupportLevel
+from .p_bfgs import P_BFGS
+from .powell import POWELL
+from .qnspsa import QNSPSA
+from .scipy_optimizer import SciPyOptimizer
+from .slsqp import SLSQP
+from .spsa import SPSA
+from .tnc import TNC
+from .umda import UMDA
+
+__all__ = [
+    "Optimizer",
+    "OptimizerSupportLevel",
+    "SteppableOptimizer",
+    "AskData",
+    "TellData",
+    "OptimizerState",
+    "OptimizerResult",
+    "Minimizer",
+    "ADAM",
+    "AQGD",
+    "CG",
+    "COBYLA",
+    "GSLS",
+    "GradientDescent",
+    "GradientDescentState",
+    "L_BFGS_B",
+    "NELDER_MEAD",
+    "NFT",
+    "P_BFGS",
+    "POWELL",
+    "SciPyOptimizer",
+    "SLSQP",
+    "SPSA",
+    "QNSPSA",
+    "TNC",
+    "CRS",
+    "DIRECT_L",
+    "DIRECT_L_RAND",
+    "ESCH",
+    "ISRES",
+    "UMDA",
+]
diff --git a/qiskit_machine_learning/optimizers/adam_amsgrad.py b/qiskit_machine_learning/optimizers/adam_amsgrad.py
new file mode 100644
index 000000000..74e332c0b
--- /dev/null
+++ b/qiskit_machine_learning/optimizers/adam_amsgrad.py
@@ -0,0 +1,245 @@
+# This code is part of a Qiskit project.
+#
+# (C) Copyright IBM 2019, 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
+# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
+#
+# Any modifications or derivative works of this code must retain this
+# copyright notice, and modified files need to carry a notice indicating
+# that they have been altered from the originals.
+
+"""The Adam and AMSGRAD optimizers."""
+from __future__ import annotations
+
+from collections.abc import Callable
+from typing import Any
+import os
+
+import csv
+import numpy as np
+from .optimizer import Optimizer, OptimizerSupportLevel, OptimizerResult, POINT
+
+# pylint: disable=invalid-name
+
+
+class ADAM(Optimizer):
+    """Adam and AMSGRAD optimizers.
+
+    Adam [1] is a gradient-based optimization algorithm that is relies on adaptive estimates of
+    lower-order moments. The algorithm requires little memory and is invariant to diagonal
+    rescaling of the gradients. Furthermore, it is able to cope with non-stationary objective
+    functions and noisy and/or sparse gradients.
+
+    AMSGRAD [2] (a variant of Adam) uses a 'long-term memory' of past gradients and, thereby,
+    improves convergence properties.
+
+    References:
+
+        [1]: Kingma, Diederik & Ba, Jimmy (2014), Adam: A Method for Stochastic Optimization.
+             `arXiv:1412.6980 <https://arxiv.org/abs/1412.6980>`_
+
+        [2]: Sashank J. Reddi and Satyen Kale and Sanjiv Kumar (2018),
+             On the Convergence of Adam and Beyond.
+             `arXiv:1904.09237 <https://arxiv.org/abs/1904.09237>`_
+    """
+
+    _OPTIONS = [
+        "maxiter",
+        "tol",
+        "lr",
+        "beta_1",
+        "beta_2",
+        "noise_factor",
+        "eps",
+        "amsgrad",
+        "snapshot_dir",
+    ]
+
+    def __init__(
+        self,
+        maxiter: int = 10000,
+        tol: float = 1e-6,
+        lr: float = 1e-3,
+        beta_1: float = 0.9,
+        beta_2: float = 0.99,
+        noise_factor: float = 1e-8,
+        eps: float = 1e-10,
+        amsgrad: bool = False,
+        snapshot_dir: str | None = None,
+    ) -> None:
+        """
+        Args:
+            maxiter: Maximum number of iterations
+            tol: Tolerance for termination
+            lr: Value >= 0, Learning rate.
+            beta_1: Value in range 0 to 1, Generally close to 1.
+            beta_2: Value in range 0 to 1, Generally close to 1.
+            noise_factor: Value >= 0, Noise factor
+            eps : Value >=0, Epsilon to be used for finite differences if no analytic
+                gradient method is given.
+            amsgrad: True to use AMSGRAD, False if not
+            snapshot_dir: If not None save the optimizer's parameter
+                after every step to the given directory
+        """
+        super().__init__()
+        for k, v in list(locals().items()):
+            if k in self._OPTIONS:
+                self._options[k] = v
+        self._maxiter = maxiter
+        self._snapshot_dir = snapshot_dir
+        self._tol = tol
+        self._lr = lr
+        self._beta_1 = beta_1
+        self._beta_2 = beta_2
+        self._noise_factor = noise_factor
+        self._eps = eps
+        self._amsgrad = amsgrad
+
+        # runtime variables
+        self._t = 0  # time steps
+        self._m = np.zeros(1)
+        self._v = np.zeros(1)
+        if self._amsgrad:
+            self._v_eff = np.zeros(1)
+
+        if self._snapshot_dir is not None:
+            file_path = os.path.join(self._snapshot_dir, "adam_params.csv")
+            if not os.path.isfile(file_path):
+                # pylint: disable=unspecified-encoding
+                with open(file_path, mode="w") as csv_file:
+                    fieldnames = ["v", "v_eff", "m", "t"] if self._amsgrad else ["v", "m", "t"]
+                    writer = csv.DictWriter(csv_file, fieldnames=fieldnames)
+                    writer.writeheader()
+
+    @property
+    def settings(self) -> dict[str, Any]:
+        return {
+            "maxiter": self._maxiter,
+            "tol": self._tol,
+            "lr": self._lr,
+            "beta_1": self._beta_1,
+            "beta_2": self._beta_2,
+            "noise_factor": self._noise_factor,
+            "eps": self._eps,
+            "amsgrad": self._amsgrad,
+            "snapshot_dir": self._snapshot_dir,
+        }
+
+    def get_support_level(self):
+        """Return support level dictionary"""
+        return {
+            "gradient": OptimizerSupportLevel.supported,
+            "bounds": OptimizerSupportLevel.ignored,
+            "initial_point": OptimizerSupportLevel.supported,
+        }
+
+    def save_params(self, snapshot_dir: str) -> None:
+        """Save the current iteration parameters to a file called ``adam_params.csv``.
+
+        Note:
+
+            The current parameters are appended to the file, if it exists already.
+            The file is not overwritten.
+
+        Args:
+            snapshot_dir: The directory to store the file in.
+        """
+        # pylint: disable=unspecified-encoding
+        file_path = os.path.join(snapshot_dir, "adam_params.csv")
+
+        if not os.path.isfile(file_path):
+            raise FileNotFoundError(f"The file {file_path} does not exist.")
+
+        fieldnames = ["v", "v_eff", "m", "t"] if self._amsgrad else ["v", "m", "t"]
+
+        with open(file_path, mode="a", newline="") as csv_file:
+            writer = csv.DictWriter(csv_file, fieldnames=fieldnames)
+            row = {"v": self._v, "m": self._m, "t": self._t}
+            if self._amsgrad:
+                row["v_eff"] = self._v_eff
+            writer.writerow(row)
+
+    def load_params(self, load_dir: str) -> None:
+        """Load iteration parameters for a file called ``adam_params.csv``.
+
+        Args:
+            load_dir: The directory containing ``adam_params.csv``.
+        """
+        # pylint: disable=unspecified-encoding
+        file_path = os.path.join(load_dir, "adam_params.csv")
+
+        if not os.path.isfile(file_path):
+            raise FileNotFoundError(f"The file {file_path} does not exist.")
+
+        with open(file_path, mode="r", newline="") as csv_file:
+            reader = csv.DictReader(csv_file)
+
+            for line in reader:
+                self._v = np.fromstring(line["v"].strip("[]"), dtype=float, sep=" ")
+                if self._amsgrad:
+                    self._v_eff = np.fromstring(line["v_eff"].strip("[]"), dtype=float, sep=" ")
+                self._m = np.fromstring(line["m"].strip("[]"), dtype=float, sep=" ")
+                self._t = int(line["t"].strip("[]"))
+
+    def minimize(
+        self,
+        fun: Callable[[POINT], float],
+        x0: POINT,
+        jac: Callable[[POINT], POINT] | None = None,
+        bounds: list[tuple[float, float]] | None = None,
+    ) -> OptimizerResult:
+        """Minimize the scalar function.
+
+        Args:
+            fun: The scalar function to minimize.
+            x0: The initial point for the minimization.
+            jac: The gradient of the scalar function ``fun``.
+            bounds: Bounds for the variables of ``fun``. This argument might be ignored if the
+                optimizer does not support bounds.
+        Returns:
+            The result of the optimization, containing e.g. the result as attribute ``x``.
+        """
+        if jac is None:
+            jac = Optimizer.wrap_function(Optimizer.gradient_num_diff, (fun, self._eps))
+
+        derivative = jac(x0)
+        self._t = 0
+        self._m = np.zeros(np.shape(derivative))
+        self._v = np.zeros(np.shape(derivative))
+        if self._amsgrad:
+            self._v_eff = np.zeros(np.shape(derivative))
+
+        params = params_new = x0
+        while self._t < self._maxiter:
+            if self._t > 0:
+                derivative = jac(params)
+            self._t += 1
+            self._m = self._beta_1 * self._m + (1 - self._beta_1) * derivative
+            self._v = self._beta_2 * self._v + (1 - self._beta_2) * derivative * derivative
+            lr_eff = self._lr * np.sqrt(1 - self._beta_2**self._t) / (1 - self._beta_1**self._t)
+            if not self._amsgrad:
+                params_new = params - lr_eff * self._m.flatten() / (
+                    np.sqrt(self._v.flatten()) + self._noise_factor
+                )
+            else:
+                self._v_eff = np.maximum(self._v_eff, self._v)
+                params_new = params - lr_eff * self._m.flatten() / (
+                    np.sqrt(self._v_eff.flatten()) + self._noise_factor
+                )
+
+            if self._snapshot_dir is not None:
+                self.save_params(self._snapshot_dir)
+
+            # check termination
+            if np.linalg.norm(params - params_new) < self._tol:
+                break
+
+            params = params_new
+
+        result = OptimizerResult()
+        result.x = params_new
+        result.fun = fun(params_new)
+        result.nfev = self._t
+        return result
diff --git a/qiskit_machine_learning/optimizers/aqgd.py b/qiskit_machine_learning/optimizers/aqgd.py
new file mode 100644
index 000000000..ef5d0d703
--- /dev/null
+++ b/qiskit_machine_learning/optimizers/aqgd.py
@@ -0,0 +1,374 @@
+# This code is part of a Qiskit project.
+#
+# (C) Copyright IBM 2019, 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
+# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
+#
+# Any modifications or derivative works of this code must retain this
+# copyright notice, and modified files need to carry a notice indicating
+# that they have been altered from the originals.
+
+"""Analytical Quantum Gradient Descent (AQGD) optimizer."""
+
+from __future__ import annotations
+import logging
+from collections.abc import Callable
+from typing import Any
+
+import numpy as np
+
+from ..utils.validation import validate_range_exclusive_max
+from .optimizer import Optimizer, OptimizerSupportLevel, OptimizerResult, POINT
+from ..exceptions import AlgorithmError
+
+logger = logging.getLogger(__name__)
+
+
+class AQGD(Optimizer):
+    """Analytic Quantum Gradient Descent (AQGD) with Epochs optimizer.
+    Performs gradient descent optimization with a momentum term, analytic gradients,
+    and customized step length schedule for parameterized quantum gates, i.e.
+    Pauli Rotations. See, for example:
+
+    * K. Mitarai, M. Negoro, M. Kitagawa, and K. Fujii. (2018).
+      Quantum circuit learning. Phys. Rev. A 98, 032309.
+      https://arxiv.org/abs/1803.00745
+
+    * Maria Schuld, Ville Bergholm, Christian Gogolin, Josh Izaac, Nathan Killoran. (2019).
+      Evaluating analytic gradients on quantum hardware. Phys. Rev. A 99, 032331.
+      https://arxiv.org/abs/1811.11184
+
+    for further details on analytic gradients of parameterized quantum gates.
+
+    Gradients are computed "analytically" using the quantum circuit when evaluating
+    the objective function.
+
+    """
+
+    _OPTIONS = ["maxiter", "eta", "tol", "disp", "momentum", "param_tol", "averaging"]
+
+    def __init__(
+        self,
+        maxiter: int | list[int] = 1000,
+        eta: float | list[float] = 1.0,
+        tol: float = 1e-6,  # this is tol
+        momentum: float | list[float] = 0.25,
+        param_tol: float = 1e-6,
+        averaging: int = 10,
+        max_evals_grouped: int = 1,
+    ) -> None:
+        """
+        Performs Analytical Quantum Gradient Descent (AQGD) with Epochs.
+
+        Args:
+            maxiter: Maximum number of iterations (full gradient steps)
+            eta: The coefficient of the gradient update. Increasing this value
+                results in larger step sizes: param = previous_param - eta * deriv
+            tol: Tolerance for change in windowed average of objective values.
+                Convergence occurs when either objective tolerance is met OR parameter
+                tolerance is met.
+            momentum: Bias towards the previous gradient momentum in current
+                update. Must be within the bounds: [0,1)
+            param_tol: Tolerance for change in norm of parameters.
+            averaging: Length of window over which to average objective values for objective
+                convergence criterion
+            max_evals_grouped: Max number of default gradient evaluations performed simultaneously.
+
+        Raises:
+            AlgorithmError: If the length of ``maxiter``, `momentum``, and ``eta`` is not the same.
+        """
+        super().__init__()
+        if isinstance(maxiter, int):
+            maxiter = [maxiter]
+        if isinstance(eta, (int, float)):
+            eta = [eta]
+        if isinstance(momentum, (int, float)):
+            momentum = [momentum]
+        if len(maxiter) != len(eta) or len(maxiter) != len(momentum):
+            raise AlgorithmError(
+                "AQGD input parameter length mismatch. Parameters `maxiter`, "
+                "`eta`, and `momentum` must have the same length."
+            )
+        for m in momentum:
+            validate_range_exclusive_max("momentum", m, 0, 1)
+
+        self._eta = eta
+        self._maxiter = maxiter
+        self._momenta_coeff = momentum
+        self._param_tol = param_tol
+        self._tol = tol
+        self._averaging = averaging
+        self.set_max_evals_grouped(max_evals_grouped)
+
+        # state
+        self._avg_objval: float | None = None
+        self._prev_param: np.ndarray | None = None
+        self._eval_count = 0  # function evaluations
+        self._prev_loss: list[float] = []
+        self._prev_grad: list[list[float]] = []
+
+    def get_support_level(self) -> dict[str, OptimizerSupportLevel]:
+        """Support level dictionary
+
+        Returns:
+            Dict[str, int]: gradient, bounds and initial point
+                            support information that is ignored/required.
+        """
+        return {
+            "gradient": OptimizerSupportLevel.ignored,
+            "bounds": OptimizerSupportLevel.ignored,
+            "initial_point": OptimizerSupportLevel.required,
+        }
+
+    @property
+    def settings(self) -> dict[str, Any]:
+        return {
+            "maxiter": self._maxiter,
+            "eta": self._eta,
+            "momentum": self._momenta_coeff,
+            "param_tol": self._param_tol,
+            "tol": self._tol,
+            "averaging": self._averaging,
+        }
+
+    def _compute_objective_fn_and_gradient(
+        self, params: np.ndarray | list[float], obj: Callable
+    ) -> tuple[float, np.ndarray]:
+        """
+        Obtains the objective function value for params and the analytical quantum derivatives of
+        the objective function with respect to each parameter. Requires
+        2*(number parameters) + 1 objective evaluations
+
+        Args:
+            params: Current value of the parameters to evaluate the objective function
+            obj: Objective function of interest
+
+        Returns:
+            Tuple containing the objective value and array of gradients for the given parameter set.
+        """
+        num_params = len(params)
+        param_sets_to_eval = params + np.concatenate(
+            (
+                np.zeros((1, num_params)),  # copy of the parameters as is
+                np.eye(num_params) * np.pi / 2,  # copy of the parameters with the positive shift
+                -np.eye(num_params) * np.pi / 2,
+            ),  # copy of the parameters with the negative shift
+            axis=0,
+        )
+        # Evaluate,
+        # reshaping to flatten, as expected by objective function
+        if self._max_evals_grouped > 1:
+            batches = [
+                param_sets_to_eval[i : i + self._max_evals_grouped]
+                for i in range(0, len(param_sets_to_eval), self._max_evals_grouped)
+            ]
+            values = np.array(np.concatenate([obj(b) for b in batches]))
+        else:
+            batches = param_sets_to_eval
+            values = np.array([obj(b) for b in batches])
+
+        # Update number of objective function evaluations
+        self._eval_count += 2 * num_params + 1
+
+        # return the objective function value
+        obj_value = values[0]
+
+        # return the gradient values
+        gradient = 0.5 * (values[1 : num_params + 1] - values[1 + num_params :])
+        return obj_value, gradient
+
+    def _update(
+        self,
+        params: np.ndarray,
+        gradient: np.ndarray,
+        mprev: np.ndarray,
+        step_size: float,
+        momentum_coeff: float,
+    ) -> tuple[np.ndarray, np.ndarray]:
+        """
+        Updates full parameter array based on a step that is a convex
+        combination of the gradient and previous momentum
+
+        Args:
+            params: Current value of the parameters to evaluate the objective function at
+            gradient: Gradient of objective wrt parameters
+            mprev: Momentum vector for each parameter
+            step_size: The scaling of step to take
+            momentum_coeff: Bias towards previous momentum vector when updating current
+                momentum/step vector
+
+        Returns:
+            Tuple of the updated parameter and momentum vectors respectively.
+        """
+        # Momentum update:
+        # Convex combination of previous momentum and current gradient estimate
+        mnew = (1 - momentum_coeff) * gradient + momentum_coeff * mprev
+        params -= step_size * mnew
+        return params, mnew
+
+    def _converged_objective(self, objval: float, tol: float, window_size: int) -> bool:
+        """
+        Tests convergence based on the change in a moving windowed average of past objective values
+
+        Args:
+            objval: Current value of the objective function
+            tol: tolerance below which (average) objective function change must be
+            window_size: size of averaging window
+
+        Returns:
+            Bool indicating whether or not the optimization has converged.
+        """
+        # If we haven't reached the required window length,
+        # append the current value, but we haven't converged
+        if len(self._prev_loss) < window_size:
+            self._prev_loss.append(objval)
+            return False
+
+        # Update last value in list with current value
+        self._prev_loss.append(objval)
+        # (length now = n+1)
+
+        # Calculate previous windowed average
+        # and current windowed average of objective values
+        prev_avg = np.mean(self._prev_loss[:window_size])
+        curr_avg = np.mean(self._prev_loss[1 : window_size + 1])
+        self._avg_objval = curr_avg  # type: ignore[assignment]
+
+        # Update window of objective values
+        # (Remove earliest value)
+        self._prev_loss.pop(0)
+
+        if np.absolute(prev_avg - curr_avg) < tol:
+            # converged
+            logger.info("Previous obj avg: %f\nCurr obj avg: %f", prev_avg, curr_avg)
+            return True
+        return False
+
+    def _converged_parameter(self, parameter: np.ndarray, tol: float) -> bool:
+        """
+        Tests convergence based on change in parameter
+
+        Args:
+            parameter: current parameter values
+            tol: tolerance for change in norm of parameters
+
+        Returns:
+            Bool indicating whether or not the optimization has converged
+        """
+        if self._prev_param is None:
+            self._prev_param = np.copy(parameter)
+            return False
+
+        order = np.inf
+        p_change = np.linalg.norm(self._prev_param - parameter, ord=order)
+        if p_change < tol:
+            # converged
+            logger.info("Change in parameters (%f norm): %f", order, p_change)
+            return True
+        return False
+
+    def _converged_alt(self, gradient: list[float], tol: float, window_size: int) -> bool:
+        """
+        Tests convergence from norm of windowed average of gradients
+
+        Args:
+            gradient: current gradient
+            tol: tolerance for average gradient norm
+            window_size: size of averaging window
+
+        Returns:
+            Bool indicating whether or not the optimization has converged
+        """
+        # If we haven't reached the required window length,
+        # append the current value, but we haven't converged
+        if len(self._prev_grad) < window_size - 1:
+            self._prev_grad.append(gradient)
+            return False
+
+        # Update last value in list with current value
+        self._prev_grad.append(gradient)
+        # (length now = n)
+
+        # Calculate previous windowed average
+        # and current windowed average of objective values
+        avg_grad = np.mean(self._prev_grad, axis=0)
+
+        # Update window of values
+        # (Remove earliest value)
+        self._prev_grad.pop(0)
+
+        if np.linalg.norm(avg_grad, ord=np.inf) < tol:
+            # converged
+            logger.info("Avg. grad. norm: %f", np.linalg.norm(avg_grad, ord=np.inf))
+            return True
+        return False
+
+    def minimize(
+        self,
+        fun: Callable[[POINT], float],
+        x0: POINT,
+        jac: Callable[[POINT], POINT] | None = None,
+        bounds: list[tuple[float, float]] | None = None,
+    ) -> OptimizerResult:
+        params = np.asarray(x0)
+        momentum = np.zeros(shape=(params.size,))
+        # empty out history of previous objectives/gradients/parameters
+        # (in case this object is re-used)
+        self._prev_loss = []
+        self._prev_grad = []
+        self._prev_param = None
+        self._eval_count = 0  # function evaluations
+
+        iter_count = 0
+        logger.info("Initial Params: %s", params)
+        epoch = 0
+        converged = False
+        for eta, mom_coeff in zip(self._eta, self._momenta_coeff):
+            logger.info("Epoch: %4d | Stepsize: %6.4f | Momentum: %6.4f", epoch, eta, mom_coeff)
+
+            sum_max_iters = sum(self._maxiter[0 : epoch + 1])
+            while iter_count < sum_max_iters:
+                # update the iteration count
+                iter_count += 1
+
+                # Check for parameter convergence before potentially costly function evaluation
+                converged = self._converged_parameter(params, self._param_tol)
+                if converged:
+                    break
+                # Calculate objective function and estimate of analytical gradient
+                if jac is None:
+                    objval, gradient = self._compute_objective_fn_and_gradient(params, fun)
+                else:
+                    objval = fun(params)
+                    gradient = jac(params)  # type: ignore[assignment]
+
+                logger.info(
+                    " Iter: %4d | Obj: %11.6f | Grad Norm: %f",
+                    iter_count,
+                    objval,
+                    np.linalg.norm(gradient, ord=np.inf),
+                )
+
+                # Check for objective convergence
+                converged = self._converged_objective(objval, self._tol, self._averaging)
+                if converged:
+                    break
+
+                # Update parameters and momentum
+                params, momentum = self._update(params, gradient, momentum, eta, mom_coeff)
+            # end inner iteration
+            # if converged, end iterating over epochs
+            if converged:
+                break
+            epoch += 1
+        # end epoch iteration
+
+        result = OptimizerResult()
+        result.x = params
+        result.fun = objval
+        result.nfev = self._eval_count
+        result.nit = iter_count
+
+        return result
diff --git a/qiskit_machine_learning/optimizers/cg.py b/qiskit_machine_learning/optimizers/cg.py
new file mode 100644
index 000000000..bb060389a
--- /dev/null
+++ b/qiskit_machine_learning/optimizers/cg.py
@@ -0,0 +1,70 @@
+# This code is part of a Qiskit project.
+#
+# (C) Copyright IBM 2018, 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
+# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
+#
+# Any modifications or derivative works of this code must retain this
+# copyright notice, and modified files need to carry a notice indicating
+# that they have been altered from the originals.
+
+"""Conjugate Gradient optimizer."""
+
+from __future__ import annotations
+
+from .scipy_optimizer import SciPyOptimizer
+
+
+class CG(SciPyOptimizer):
+    """Conjugate Gradient optimizer.
+
+    CG is an algorithm for the numerical solution of systems of linear equations whose matrices are
+    symmetric and positive-definite. It is an *iterative algorithm* in that it uses an initial
+    guess to generate a sequence of improving approximate solutions for a problem,
+    in which each approximation is derived from the previous ones.  It is often used to solve
+    unconstrained optimization problems, such as energy minimization.
+
+    Uses scipy.optimize.minimize CG.
+    For further detail, please refer to
+    https://docs.scipy.org/doc/scipy/reference/generated/scipy.optimize.minimize.html
+    """
+
+    _OPTIONS = ["maxiter", "disp", "gtol", "eps"]
+
+    # pylint: disable=unused-argument
+    def __init__(
+        self,
+        maxiter: int = 20,
+        disp: bool = False,
+        gtol: float = 1e-5,
+        tol: float | None = None,
+        eps: float = 1.4901161193847656e-08,
+        options: dict | None = None,
+        max_evals_grouped: int = 1,
+        **kwargs,
+    ) -> None:
+        """
+        Args:
+            maxiter: Maximum number of iterations to perform.
+            disp: Set to True to print convergence messages.
+            gtol: Gradient norm must be less than gtol before successful termination.
+            tol: Tolerance for termination.
+            eps: If jac is approximated, use this value for the step size.
+            options: A dictionary of solver options.
+            max_evals_grouped: Max number of default gradient evaluations performed simultaneously.
+            kwargs: additional kwargs for scipy.optimize.minimize.
+        """
+        if options is None:
+            options = {}
+        for k, v in list(locals().items()):
+            if k in self._OPTIONS:
+                options[k] = v
+        super().__init__(
+            method="CG",
+            options=options,
+            tol=tol,
+            max_evals_grouped=max_evals_grouped,
+            **kwargs,
+        )
diff --git a/qiskit_machine_learning/optimizers/cobyla.py b/qiskit_machine_learning/optimizers/cobyla.py
new file mode 100644
index 000000000..d7710b1e3
--- /dev/null
+++ b/qiskit_machine_learning/optimizers/cobyla.py
@@ -0,0 +1,59 @@
+# This code is part of a Qiskit project.
+#
+# (C) Copyright IBM 2018, 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
+# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
+#
+# Any modifications or derivative works of this code must retain this
+# copyright notice, and modified files need to carry a notice indicating
+# that they have been altered from the originals.
+
+"""Constrained Optimization By Linear Approximation optimizer."""
+
+from __future__ import annotations
+
+from .scipy_optimizer import SciPyOptimizer
+
+
+class COBYLA(SciPyOptimizer):
+    """
+    Constrained Optimization By Linear Approximation optimizer.
+
+    COBYLA is a numerical optimization method for constrained problems
+    where the derivative of the objective function is not known.
+
+    Uses scipy.optimize.minimize COBYLA.
+    For further detail, please refer to
+    https://docs.scipy.org/doc/scipy/reference/generated/scipy.optimize.minimize.html
+    """
+
+    _OPTIONS = ["maxiter", "disp", "rhobeg"]
+
+    # pylint: disable=unused-argument
+    def __init__(
+        self,
+        maxiter: int = 1000,
+        disp: bool = False,
+        rhobeg: float = 1.0,
+        tol: float | None = None,
+        options: dict | None = None,
+        **kwargs,
+    ) -> None:
+        """
+        Args:
+            maxiter: Maximum number of function evaluations.
+            disp: Set to True to print convergence messages.
+            rhobeg: Reasonable initial changes to the variables.
+            tol: Final accuracy in the optimization (not precisely guaranteed).
+                 This is a lower bound on the size of the trust region.
+            options: A dictionary of solver options.
+            kwargs: additional kwargs for scipy.optimize.minimize.
+        """
+        if options is None:
+            options = {}
+        for k, v in list(locals().items()):
+            if k in self._OPTIONS:
+                options[k] = v
+        super().__init__(method="COBYLA", options=options, tol=tol, **kwargs)
diff --git a/qiskit_machine_learning/optimizers/gradient_descent.py b/qiskit_machine_learning/optimizers/gradient_descent.py
new file mode 100644
index 000000000..e33aacec0
--- /dev/null
+++ b/qiskit_machine_learning/optimizers/gradient_descent.py
@@ -0,0 +1,397 @@
+# This code is part of a Qiskit project.
+#
+# (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
+# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
+#
+# Any modifications or derivative works of this code must retain this
+# copyright notice, and modified files need to carry a notice indicating
+# that they have been altered from the originals.
+
+"""A standard gradient descent optimizer."""
+from __future__ import annotations
+
+from collections.abc import Generator
+from dataclasses import dataclass, field
+from typing import Any, Callable, SupportsFloat
+import numpy as np
+from .optimizer import Optimizer, OptimizerSupportLevel, OptimizerResult, POINT
+from .steppable_optimizer import AskData, TellData, OptimizerState, SteppableOptimizer
+from .optimizer_utils import LearningRate
+
+CALLBACK = Callable[[int, np.ndarray, float, SupportsFloat], None]
+
+
+@dataclass
+class GradientDescentState(OptimizerState):
+    """State of :class:`~.GradientDescent`.
+
+    Dataclass with all the information of an optimizer plus the learning_rate and the stepsize.
+    """
+
+    stepsize: float | None
+    """Norm of the gradient on the last step."""
+
+    learning_rate: LearningRate = field(compare=False)
+    """Learning rate at the current step of the optimization process.
+
+    It behaves like a generator, (use ``next(learning_rate)`` to get the learning rate for the
+    next step) but it can also return  the current learning rate with ``learning_rate.current``.
+
+    """
+
+
+class GradientDescent(SteppableOptimizer):
+    r"""The gradient descent minimization routine.
+
+    For a function :math:`f` and an initial point :math:`\vec\theta_0`, the standard (or "vanilla")
+    gradient descent method is an iterative scheme to find the minimum :math:`\vec\theta^*` of
+    :math:`f` by updating the parameters in the direction of the negative gradient of :math:`f`
+
+    .. math::
+
+        \vec\theta_{n+1} = \vec\theta_{n} - \eta_n \vec\nabla f(\vec\theta_{n}),
+
+    for a small learning rate :math:`\eta_n > 0`.
+
+    You can either provide the analytic gradient :math:`\vec\nabla f` as ``jac``
+    in the :meth:`~.minimize` method, or, if you do not provide it, use a finite difference
+    approximation of the gradient. To adapt the size of the perturbation in the finite difference
+    gradients, set the ``perturbation`` property in the initializer.
+
+    This optimizer supports a callback function. If provided in the initializer, the optimizer
+    will call the callback in each iteration with the following information in this order:
+    current number of function values, current parameters, current function value, norm of current
+    gradient.
+
+    Examples:
+
+        A minimum example that will use finite difference gradients with a default perturbation
+        of 0.01 and a default learning rate of 0.01.
+
+        .. code-block:: python
+
+            from qiskit_machine_learning.optimizers import GradientDescent
+
+            def f(x):
+                return (np.linalg.norm(x) - 1) ** 2
+
+            initial_point = np.array([1, 0.5, -0.2])
+
+            optimizer = GradientDescent(maxiter=100)
+
+            result = optimizer.minimize(fun=fun, x0=initial_point)
+
+            print(f"Found minimum {result.x} at a value"
+                "of {result.fun} using {result.nfev} evaluations.")
+
+        An example where the learning rate is an iterator and we supply the analytic gradient.
+        Note how much faster this convergences (i.e. less ``nfev``) compared to the previous
+        example.
+
+        .. code-block:: python
+
+            from qiskit_machine_learning.optimizers import GradientDescent
+
+            def learning_rate():
+                power = 0.6
+                constant_coeff = 0.1
+                def power_law():
+                    n = 0
+                    while True:
+                        yield constant_coeff * (n ** power)
+                        n += 1
+
+                return power_law()
+
+            def f(x):
+                return (np.linalg.norm(x) - 1) ** 2
+
+            def grad_f(x):
+                return 2 * (np.linalg.norm(x) - 1) * x / np.linalg.norm(x)
+
+            initial_point = np.array([1, 0.5, -0.2])
+
+            optimizer = GradientDescent(maxiter=100, learning_rate=learning_rate)
+            result = optimizer.minimize(fun=fun, jac=grad_f, x0=initial_point)
+
+            print(f"Found minimum {result.x} at a value"
+            "of {result.fun} using {result.nfev} evaluations.")
+
+
+    An other example where the evaluation of the function has a chance of failing. The user, with
+    specific knowledge about his function can catch this errors and handle them before passing the
+    result to the optimizer.
+
+        .. code-block:: python
+
+            import random
+            import numpy as np
+            from qiskit_machine_learning.optimizers import GradientDescent
+
+            def objective(x):
+                if random.choice([True, False]):
+                    return None
+                else:
+                    return (np.linalg.norm(x) - 1) ** 2
+
+            def grad(x):
+                if random.choice([True, False]):
+                    return None
+                else:
+                    return 2 * (np.linalg.norm(x) - 1) * x / np.linalg.norm(x)
+
+
+            initial_point = np.random.normal(0, 1, size=(100,))
+
+            optimizer = GradientDescent(maxiter=20)
+            optimizer.start(x0=initial_point, fun=objective, jac=grad)
+
+            while optimizer.continue_condition():
+                ask_data = optimizer.ask()
+                evaluated_gradient = None
+
+                while evaluated_gradient is None:
+                    evaluated_gradient = grad(ask_data.x_center)
+                    optimizer.state.njev += 1
+
+                optimizer.state.nit += 1
+
+                tell_data = TellData(eval_jac=evaluated_gradient)
+                optimizer.tell(ask_data=ask_data, tell_data=tell_data)
+
+            result = optimizer.create_result()
+
+    Users that aren't dealing with complicated functions and who are more familiar with step by step
+    optimization algorithms can use the :meth:`~.step` method which wraps the :meth:`~.ask`
+    and :meth:`~.tell` methods. In the same spirit the method :meth:`~.minimize` will optimize the
+    function and return the result.
+
+    To see other libraries that use this interface one can visit:
+    https://optuna.readthedocs.io/en/stable/tutorial/20_recipes/009_ask_and_tell.html
+
+    """
+
+    def __init__(
+        self,
+        maxiter: int = 100,
+        learning_rate: (
+            float | list[float] | np.ndarray | Callable[[], Generator[float, None, None]]
+        ) = 0.01,
+        tol: float = 1e-7,
+        callback: CALLBACK | None = None,
+        perturbation: float | None = None,
+    ) -> None:
+        """
+        Args:
+            maxiter: The maximum number of iterations.
+            learning_rate: A constant, list, array or factory of generators yielding learning rates
+                           for the parameter updates. See the docstring for an example.
+            tol: If the norm of the parameter update is smaller than this threshold, the
+                optimizer has converged.
+            perturbation: If no gradient is passed to :meth:`~.minimize` the gradient is
+                approximated with a forward finite difference scheme with ``perturbation``
+                perturbation in both directions (defaults to 1e-2 if required).
+                Ignored when we have an explicit function for the gradient.
+        Raises:
+            ValueError: If ``learning_rate`` is an array and its length is less than ``maxiter``.
+        """
+        super().__init__(maxiter=maxiter)
+        self.callback = callback
+        self._state: GradientDescentState | None = None
+        self._perturbation = perturbation
+        self._tol = tol
+        # if learning rate is an array, check it is sufficiently long.
+        if isinstance(learning_rate, (list, np.ndarray)):
+            if len(learning_rate) < maxiter:
+                raise ValueError(
+                    f"Length of learning_rate ({len(learning_rate)}) "
+                    f"is smaller than maxiter ({maxiter})."
+                )
+        self.learning_rate = learning_rate
+
+    @property  # type: ignore[override]
+    def state(self) -> GradientDescentState:
+        """Return the current state of the optimizer."""
+        return self._state
+
+    @state.setter
+    def state(self, state: GradientDescentState) -> None:
+        """Set the current state of the optimizer."""
+        self._state = state
+
+    @property
+    def tol(self) -> float:
+        """Returns the tolerance of the optimizer.
+
+        Any step with smaller stepsize than this value will stop the optimization."""
+        return self._tol
+
+    @tol.setter
+    def tol(self, tol: float) -> None:
+        """Set the tolerance."""
+        self._tol = tol
+
+    @property
+    def perturbation(self) -> float | None:
+        """Returns the perturbation.
+
+        This is the perturbation used in the finite difference gradient approximation.
+        """
+        return self._perturbation
+
+    @perturbation.setter
+    def perturbation(self, perturbation: float | None) -> None:
+        """Set the perturbation."""
+        self._perturbation = perturbation
+
+    def _callback_wrapper(self) -> None:
+        """
+        Wraps the callback function to accommodate GradientDescent.
+
+        Will call :attr:`~.callback` and pass the following arguments:
+        current number of function values, current parameters, current function value,
+        norm of current gradient.
+        """
+        if self.callback is not None:
+            self.callback(
+                self.state.nfev,
+                self.state.x,  # type: ignore[arg-type]
+                self.state.fun(self.state.x),
+                self.state.stepsize,
+            )
+
+    @property
+    def settings(self) -> dict[str, Any]:
+        # if learning rate or perturbation are custom iterators expand them
+        learning_rate = self.learning_rate
+        if callable(self.learning_rate):
+            iterator = self.learning_rate()
+            learning_rate = np.array([next(iterator) for _ in range(self.maxiter)])
+
+        return {
+            "maxiter": self.maxiter,
+            "tol": self.tol,
+            "learning_rate": learning_rate,
+            "perturbation": self.perturbation,
+            "callback": self.callback,
+        }
+
+    def ask(self) -> AskData:
+        """Returns an object with the data needed to evaluate the gradient.
+
+        If this object contains a gradient function the gradient can be evaluated directly. Otherwise
+        approximate it with a finite difference scheme.
+        """
+        return AskData(
+            x_jac=self.state.x,
+        )
+
+    def tell(self, ask_data: AskData, tell_data: TellData) -> None:
+        """
+        Updates :attr:`.~GradientDescentState.x` by an amount proportional to the learning
+        rate and value of the gradient at that point.
+
+        Args:
+            ask_data: The data used to evaluate the function.
+            tell_data: The data from the function evaluation.
+
+        Raises:
+            ValueError: If the gradient passed doesn't have the right dimension.
+        """
+        if np.shape(self.state.x) != np.shape(tell_data.eval_jac):  # type: ignore[arg-type]
+            raise ValueError("The gradient does not have the correct dimension")
+        # pylint: disable=attribute-defined-outside-init
+        self.state.x = self.state.x - next(self.state.learning_rate) * tell_data.eval_jac
+        self.state.stepsize = np.linalg.norm(tell_data.eval_jac)  # type: ignore[arg-type, assignment]
+        self.state.nit += 1
+
+    def evaluate(self, ask_data: AskData) -> TellData:
+        """Evaluates the gradient.
+
+        It does so either by evaluating an analytic gradient or by approximating it with a
+        finite difference scheme. It will either add ``1`` to the number of gradient evaluations or add
+        ``N+1`` to the number of function evaluations (Where N is the dimension of the gradient).
+
+        Args:
+            ask_data: It contains the point where the gradient is to be evaluated and the gradient
+                      function or, in its absence, the objective function to perform a finite difference
+                      approximation.
+
+        Returns:
+            The data containing the gradient evaluation.
+        """
+        if self.state.jac is None:
+            eps = 0.01 if (self.perturbation is None) else self.perturbation
+            grad = Optimizer.gradient_num_diff(
+                x_center=ask_data.x_jac,
+                f=self.state.fun,
+                epsilon=eps,
+                max_evals_grouped=self._max_evals_grouped,
+            )
+            self.state.nfev += 1 + len(ask_data.x_jac)  # type: ignore[arg-type]
+        else:
+            grad = self.state.jac(ask_data.x_jac)  # type: ignore[arg-type]
+            self.state.njev += 1
+
+        return TellData(eval_jac=grad)
+
+    def create_result(self) -> OptimizerResult:
+        """Creates a result of the optimization process.
+
+        This result contains the best point, the best function value, the number of function/gradient
+        evaluations and the number of iterations.
+
+        Returns:
+            The result of the optimization process.
+        """
+        result = OptimizerResult()
+        result.x = self.state.x
+        result.fun = self.state.fun(self.state.x)
+        result.nfev = self.state.nfev
+        result.njev = self.state.njev
+        result.nit = self.state.nit
+        return result
+
+    def start(
+        self,
+        fun: Callable[[POINT], float],
+        x0: POINT,
+        jac: Callable[[POINT], POINT] | None = None,
+        bounds: list[tuple[float, float]] | None = None,
+    ) -> None:
+        self.state = GradientDescentState(
+            fun=fun,
+            jac=jac,
+            x=np.asarray(x0),
+            nit=0,
+            nfev=0,
+            njev=0,
+            learning_rate=LearningRate(learning_rate=self.learning_rate),
+            stepsize=None,
+        )
+
+    def continue_condition(self) -> bool:
+        """
+        Condition that indicates the optimization process should come to an end.
+
+        When the stepsize is smaller than the tolerance, the optimization process is considered
+        finished.
+
+        Returns:
+            ``True`` if the optimization process should continue, ``False`` otherwise.
+        """
+        if self.state.stepsize is None:
+            return True
+        else:
+            return (self.state.stepsize > self.tol) and super().continue_condition()
+
+    def get_support_level(self):
+        """Get the support level dictionary."""
+        return {
+            "gradient": OptimizerSupportLevel.supported,
+            "bounds": OptimizerSupportLevel.ignored,
+            "initial_point": OptimizerSupportLevel.required,
+        }
diff --git a/qiskit_machine_learning/optimizers/gsls.py b/qiskit_machine_learning/optimizers/gsls.py
new file mode 100644
index 000000000..6f2a36e30
--- /dev/null
+++ b/qiskit_machine_learning/optimizers/gsls.py
@@ -0,0 +1,374 @@
+# This code is part of a Qiskit project.
+#
+# (C) Copyright IBM 2018, 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
+# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
+#
+# Any modifications or derivative works of this code must retain this
+# copyright notice, and modified files need to carry a notice indicating
+# that they have been altered from the originals.
+
+"""Line search with Gaussian-smoothed samples on a sphere."""
+
+from __future__ import annotations
+
+from collections.abc import Callable
+from typing import Any
+
+import numpy as np
+
+from ..utils import algorithm_globals
+from .optimizer import Optimizer, OptimizerSupportLevel, OptimizerResult, POINT
+
+
+class GSLS(Optimizer):
+    """Gaussian-smoothed Line Search.
+
+    An implementation of the line search algorithm described in
+    https://arxiv.org/pdf/1905.01332.pdf, using gradient approximation
+    based on Gaussian-smoothed samples on a sphere.
+
+    .. note::
+
+        This component has some function that is normally random. If you want to reproduce behavior
+        then you should set the random number generator seed in the algorithm_globals
+        (``qiskit_machine_learning.utils.algorithm_globals.random_seed = seed``).
+    """
+
+    _OPTIONS = [
+        "maxiter",
+        "max_eval",
+        "disp",
+        "sampling_radius",
+        "sample_size_factor",
+        "initial_step_size",
+        "min_step_size",
+        "step_size_multiplier",
+        "armijo_parameter",
+        "min_gradient_norm",
+        "max_failed_rejection_sampling",
+    ]
+
+    # pylint: disable=unused-argument
+    def __init__(
+        self,
+        maxiter: int = 10000,
+        max_eval: int = 10000,
+        disp: bool = False,
+        sampling_radius: float = 1.0e-6,
+        sample_size_factor: int = 1,
+        initial_step_size: float = 1.0e-2,
+        min_step_size: float = 1.0e-10,
+        step_size_multiplier: float = 0.4,
+        armijo_parameter: float = 1.0e-1,
+        min_gradient_norm: float = 1e-8,
+        max_failed_rejection_sampling: int = 50,
+    ) -> None:
+        """
+        Args:
+            maxiter: Maximum number of iterations.
+            max_eval: Maximum number of evaluations.
+            disp: Set to True to display convergence messages.
+            sampling_radius: Sampling radius to determine gradient estimate.
+            sample_size_factor: The size of the sample set at each iteration is this number
+                multiplied by the dimension of the problem, rounded to the nearest integer.
+            initial_step_size: Initial step size for the descent algorithm.
+            min_step_size: Minimum step size for the descent algorithm.
+            step_size_multiplier: Step size reduction after unsuccessful steps, in the
+                interval (0, 1).
+            armijo_parameter: Armijo parameter for sufficient decrease criterion, in the
+                interval (0, 1).
+            min_gradient_norm: If the gradient norm is below this threshold, the algorithm stops.
+            max_failed_rejection_sampling: Maximum number of attempts to sample points within
+                bounds.
+        """
+        super().__init__()
+        for k, v in list(locals().items()):
+            if k in self._OPTIONS:
+                self._options[k] = v
+
+    def get_support_level(self) -> dict[str, int]:
+        """Return support level dictionary.
+
+        Returns:
+            A dictionary containing the support levels for different options.
+        """
+        return {
+            "gradient": OptimizerSupportLevel.ignored,
+            "bounds": OptimizerSupportLevel.supported,
+            "initial_point": OptimizerSupportLevel.required,
+        }
+
+    @property
+    def settings(self) -> dict[str, Any]:
+        return {key: self._options.get(key, None) for key in self._OPTIONS}
+
+    def minimize(
+        self,
+        fun: Callable[[POINT], float],
+        x0: POINT,
+        jac: Callable[[POINT], POINT] | None = None,
+        bounds: list[tuple[float, float]] | None = None,
+    ) -> OptimizerResult:
+        if not isinstance(x0, np.ndarray):
+            x0 = np.asarray(x0)
+
+        if bounds is None:
+            var_lb = np.array([-np.inf] * x0.size)
+            var_ub = np.array([np.inf] * x0.size)
+        else:
+            var_lb = np.array([l if l is not None else -np.inf for (l, _) in bounds])
+            var_ub = np.array([u if u is not None else np.inf for (_, u) in bounds])
+
+        x, fun_, nfev, _ = self.ls_optimize(x0.size, fun, x0, var_lb, var_ub)
+
+        result = OptimizerResult()
+        result.x = x
+        result.fun = fun_
+        result.nfev = nfev
+
+        return result
+
+    def ls_optimize(
+        self,
+        n: int,
+        obj_fun: Callable[[POINT], float],
+        initial_point: np.ndarray,
+        var_lb: np.ndarray,
+        var_ub: np.ndarray,
+    ) -> tuple[np.ndarray, float, int, float]:
+        """Run the line search optimization.
+
+        Args:
+            n: Dimension of the problem.
+            obj_fun: Objective function.
+            initial_point: Initial point.
+            var_lb: Vector of lower bounds on the decision variables. Vector elements can be -np.inf
+                    if the corresponding variable is unbounded from below.
+            var_ub: Vector of upper bounds on the decision variables. Vector elements can be np.inf
+                    if the corresponding variable is unbounded from below.
+
+        Returns:
+            Final iterate as a vector, corresponding objective function value,
+            number of evaluations, and norm of the gradient estimate.
+
+        Raises:
+            ValueError: If the number of dimensions mismatches the size of the initial point or
+                the length of the lower or upper bound.
+        """
+        if len(initial_point) != n:
+            raise ValueError("Size of the initial point mismatches the number of dimensions.")
+        if len(var_lb) != n:
+            raise ValueError("Length of the lower bound mismatches the number of dimensions.")
+        if len(var_ub) != n:
+            raise ValueError("Length of the upper bound mismatches the number of dimensions.")
+
+        # Initialize counters and data
+        iter_count = 0
+        n_evals = 0
+        prev_iter_successful = True
+        prev_directions, prev_sample_set_x, prev_sample_set_y = None, None, None
+        consecutive_fail_iter = 0
+        alpha = self._options["initial_step_size"]
+        grad_norm: float = np.inf
+        sample_set_size = int(round(self._options["sample_size_factor"] * n))
+
+        # Initial point
+        x = initial_point
+        x_value = obj_fun(x)
+        n_evals += 1
+        while iter_count < self._options["maxiter"] and n_evals < self._options["max_eval"]:
+            # Determine set of sample points
+            directions, sample_set_x = self.sample_set(n, x, var_lb, var_ub, sample_set_size)
+
+            if n_evals + len(sample_set_x) + 1 >= self._options["max_eval"]:
+                # The evaluation budget is too small to allow for
+                # another full iteration; we therefore exit now
+                break
+
+            sample_set_y = np.array([obj_fun(point) for point in sample_set_x])
+            n_evals += len(sample_set_x)
+
+            # Expand sample set if we could not improve
+            if not prev_iter_successful:
+                directions = np.vstack((prev_directions, directions))
+                sample_set_x = np.vstack((prev_sample_set_x, sample_set_x))
+                sample_set_y = np.hstack((prev_sample_set_y, sample_set_y))
+
+            # Find gradient approximation and candidate point
+            grad = self.gradient_approximation(
+                n, x, x_value, directions, sample_set_x, sample_set_y
+            )
+            grad_norm = float(np.linalg.norm(grad))
+            new_x = np.clip(x - alpha * grad, var_lb, var_ub)
+            new_x_value = obj_fun(new_x)
+            n_evals += 1
+
+            # Print information
+            if self._options["disp"]:
+                print(f"Iter {iter_count:d}")
+                print(f"Point {x} obj {x_value}")
+                print(f"Gradient {grad}")
+                print(f"Grad norm {grad_norm} new_x_value {new_x_value} step_size {alpha}")
+                print(f"Direction {directions}")
+
+            # Test Armijo condition for sufficient decrease
+            if new_x_value <= x_value - self._options["armijo_parameter"] * alpha * grad_norm:
+                # Accept point
+                x, x_value = new_x, new_x_value
+                alpha /= 2 * self._options["step_size_multiplier"]
+                prev_iter_successful = True
+                consecutive_fail_iter = 0
+
+                # Reset sample set
+                prev_directions = None
+                prev_sample_set_x = None
+                prev_sample_set_y = None
+            else:
+                # Do not accept point
+                alpha *= self._options["step_size_multiplier"]
+                prev_iter_successful = False
+                consecutive_fail_iter += 1
+
+                # Store sample set to enlarge it
+                prev_directions = directions
+                prev_sample_set_x, prev_sample_set_y = sample_set_x, sample_set_y
+
+            iter_count += 1
+
+            # Check termination criterion
+            if (
+                grad_norm <= self._options["min_gradient_norm"]
+                or alpha <= self._options["min_step_size"]
+            ):
+                break
+
+        return x, x_value, n_evals, grad_norm
+
+    def sample_points(
+        self, n: int, x: np.ndarray, num_points: int
+    ) -> tuple[np.ndarray, np.ndarray]:
+        """Sample ``num_points`` points around ``x`` on the ``n``-sphere of specified radius.
+
+        The radius of the sphere is ``self._options['sampling_radius']``.
+
+        Args:
+            n: Dimension of the problem.
+            x: Point around which the sample set is constructed.
+            num_points: Number of points in the sample set.
+
+        Returns:
+            A tuple containing the sampling points and the directions.
+        """
+        normal_samples = algorithm_globals.random.normal(size=(num_points, n))
+        row_norms = np.linalg.norm(normal_samples, axis=1, keepdims=True)
+        directions = normal_samples / row_norms
+        points = x + self._options["sampling_radius"] * directions
+
+        return points, directions
+
+    def sample_set(
+        self, n: int, x: np.ndarray, var_lb: np.ndarray, var_ub: np.ndarray, num_points: int
+    ) -> tuple[np.ndarray, np.ndarray]:
+        """Construct sample set of given size.
+
+        Args:
+            n: Dimension of the problem.
+            x: Point around which the sample set is constructed.
+            var_lb: Vector of lower bounds on the decision variables. Vector elements can be -np.inf
+                if the corresponding variable is unbounded from below.
+            var_ub: Vector of lower bounds on the decision variables. Vector elements can be np.inf
+                if the corresponding variable is unbounded from above.
+            num_points: Number of points in the sample set.
+
+        Returns:
+            Matrices of (unit-norm) sample directions and sample points, one per row.
+            Both matrices are 2D arrays of floats.
+
+        Raises:
+            RuntimeError: If not enough samples could be generated within the bounds.
+        """
+        # Generate points uniformly on the sphere
+        points, directions = self.sample_points(n, x, num_points)
+
+        # Check bounds
+        if (points >= var_lb).all() and (points <= var_ub).all():
+            # If all points are within bounds, return them
+            return directions, (x + self._options["sampling_radius"] * directions)
+        else:
+            # Otherwise we perform rejection sampling until we have
+            # enough points that satisfy the bounds
+            indices = np.where((points >= var_lb).all(axis=1) & (points <= var_ub).all(axis=1))[0]
+            accepted = directions[indices]
+            num_trials = 0
+
+            while (
+                len(accepted) < num_points
+                and num_trials < self._options["max_failed_rejection_sampling"]
+            ):
+                # Generate points uniformly on the sphere
+                points, directions = self.sample_points(n, x, num_points)
+                indices = np.where((points >= var_lb).all(axis=1) & (points <= var_ub).all(axis=1))[
+                    0
+                ]
+                accepted = np.vstack((accepted, directions[indices]))
+                num_trials += 1
+
+            # When we are at a corner point, the expected fraction of acceptable points may be
+            # exponential small in the dimension of the problem. Thus, if we keep failing and
+            # do not have enough points by now, we switch to a different method that guarantees
+            # finding enough points, but they may not be uniformly distributed.
+            if len(accepted) < num_points:
+                points, directions = self.sample_points(n, x, num_points)
+                to_be_flipped = (points < var_lb) | (points > var_ub)
+                directions *= np.where(to_be_flipped, -1, 1)
+                points = x + self._options["sampling_radius"] * directions
+                indices = np.where((points >= var_lb).all(axis=1) & (points <= var_ub).all(axis=1))[
+                    0
+                ]
+                accepted = np.vstack((accepted, directions[indices]))
+
+            # If we still do not have enough sampling points, we have failed.
+            if len(accepted) < num_points:
+                raise RuntimeError(
+                    "Could not generate enough samples within bounds; try smaller radius."
+                )
+
+            return (
+                accepted[:num_points],
+                x + self._options["sampling_radius"] * accepted[:num_points],
+            )
+
+    def gradient_approximation(
+        self,
+        n: int,
+        x: np.ndarray,
+        x_value: float,
+        directions: np.ndarray,
+        sample_set_x: np.ndarray,
+        sample_set_y: np.ndarray,
+    ) -> np.ndarray:
+        """Construct gradient approximation from given sample.
+
+        Args:
+            n: Dimension of the problem.
+            x: Point around which the sample set was constructed.
+            x_value: Objective function value at x.
+            directions: Directions of the sample points wrt the central point x, as a 2D array.
+            sample_set_x: x-coordinates of the sample set, one point per row, as a 2D array.
+            sample_set_y: Objective function values of the points in sample_set_x, as a 1D array.
+
+        Returns:
+            Gradient approximation at x, as a 1D array.
+        """
+        ffd = sample_set_y - x_value
+        gradient = (
+            float(n)
+            / len(sample_set_y)
+            * np.sum(
+                ffd.reshape(len(sample_set_y), 1) / self._options["sampling_radius"] * directions, 0
+            )
+        )
+        return gradient
diff --git a/qiskit_machine_learning/optimizers/l_bfgs_b.py b/qiskit_machine_learning/optimizers/l_bfgs_b.py
new file mode 100644
index 000000000..0560e454d
--- /dev/null
+++ b/qiskit_machine_learning/optimizers/l_bfgs_b.py
@@ -0,0 +1,88 @@
+# This code is part of a Qiskit project.
+#
+# (C) Copyright IBM 2018, 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
+# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
+#
+# Any modifications or derivative works of this code must retain this
+# copyright notice, and modified files need to carry a notice indicating
+# that they have been altered from the originals.
+
+"""Limited-memory BFGS Bound optimizer."""
+
+from __future__ import annotations
+from typing import SupportsFloat
+
+import numpy as np
+
+from .scipy_optimizer import SciPyOptimizer
+
+
+class L_BFGS_B(SciPyOptimizer):  # pylint: disable=invalid-name
+    """
+    Limited-memory BFGS Bound optimizer.
+
+    The target goal of Limited-memory Broyden-Fletcher-Goldfarb-Shanno Bound (L-BFGS-B)
+    is to minimize the value of a differentiable scalar function :math:`f`.
+    This optimizer is a quasi-Newton method, meaning that, in contrast to Newtons's method,
+    it does not require :math:`f`'s Hessian (the matrix of :math:`f`'s second derivatives)
+    when attempting to compute :math:`f`'s minimum value.
+
+    Like BFGS, L-BFGS is an iterative method for solving unconstrained, non-linear optimization
+    problems, but approximates BFGS using a limited amount of computer memory.
+    L-BFGS starts with an initial estimate of the optimal value, and proceeds iteratively
+    to refine that estimate with a sequence of better estimates.
+
+    The derivatives of :math:`f` are used to identify the direction of steepest descent,
+    and also to form an estimate of the Hessian matrix (second derivative) of :math:`f`.
+    L-BFGS-B extends L-BFGS to handle simple, per-variable bound constraints.
+
+    Uses ``scipy.optimize.fmin_l_bfgs_b``.
+    For further detail, please refer to
+    https://docs.scipy.org/doc/scipy/reference/optimize.minimize-lbfgsb.html
+    """
+
+    _OPTIONS = ["maxfun", "maxiter", "ftol", "iprint", "eps"]
+
+    # pylint: disable=unused-argument
+    def __init__(
+        self,
+        maxfun: int = 15000,
+        maxiter: int = 15000,
+        ftol: SupportsFloat = 10 * np.finfo(float).eps,
+        iprint: int = -1,
+        eps: float = 1e-08,
+        options: dict | None = None,
+        max_evals_grouped: int = 1,
+        **kwargs,
+    ):
+        r"""
+        Args:
+            maxfun: Maximum number of function evaluations.
+            maxiter: Maximum number of iterations.
+            ftol: The iteration stops when
+                :math:`(f^k - f^{k+1}) / \max\{|f^k|, |f^{k+1}|,1\} \leq \text{ftol}`.
+            iprint: Controls the frequency of output. ``iprint < 0`` means no output;
+                ``iprint = 0`` print only one line at the last iteration; ``0 < iprint < 99``
+                print also :math:`f` and :math:`|\text{proj} g|` every iprint iterations;
+                ``iprint = 99`` print details of every iteration except n-vectors; ``iprint = 100``
+                print also the changes of active set and final :math:`x`; ``iprint > 100`` print
+                details of every iteration including :math:`x` and :math:`g`.
+            eps: If jac is approximated, use this value for the step size.
+            options: A dictionary of solver options.
+            max_evals_grouped: Max number of default gradient evaluations performed simultaneously.
+            kwargs: additional kwargs for ``scipy.optimize.minimize``.
+        """
+        if options is None:
+            options = {}
+        for k, v in list(locals().items()):
+            if k in self._OPTIONS:
+                options[k] = v
+        super().__init__(
+            method="L-BFGS-B",
+            options=options,
+            max_evals_grouped=max_evals_grouped,
+            **kwargs,
+        )
diff --git a/qiskit_machine_learning/optimizers/nelder_mead.py b/qiskit_machine_learning/optimizers/nelder_mead.py
new file mode 100644
index 000000000..8109b3f48
--- /dev/null
+++ b/qiskit_machine_learning/optimizers/nelder_mead.py
@@ -0,0 +1,73 @@
+# This code is part of a Qiskit project.
+#
+# (C) Copyright IBM 2018, 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
+# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
+#
+# Any modifications or derivative works of this code must retain this
+# copyright notice, and modified files need to carry a notice indicating
+# that they have been altered from the originals.
+
+"""Nelder-Mead optimizer."""
+from __future__ import annotations
+
+
+from .scipy_optimizer import SciPyOptimizer
+
+
+class NELDER_MEAD(SciPyOptimizer):  # pylint: disable=invalid-name
+    """
+    Nelder-Mead optimizer.
+
+    The Nelder-Mead algorithm performs unconstrained optimization; it ignores bounds
+    or constraints.  It is used to find the minimum or maximum of an objective function
+    in a multidimensional space.  It is based on the Simplex algorithm. Nelder-Mead
+    is robust in many applications, especially when the first and second derivatives of the
+    objective function are not known.
+
+    However, if the numerical computation of the derivatives can be trusted to be accurate,
+    other algorithms using the first and/or second derivatives information might be preferred to
+    Nelder-Mead for their better performance in the general case, especially in consideration of
+    the fact that the Nelder–Mead technique is a heuristic search method that can converge to
+    non-stationary points.
+
+    Uses scipy.optimize.minimize Nelder-Mead.
+    For further detail, please refer to
+    See https://docs.scipy.org/doc/scipy/reference/generated/scipy.optimize.minimize.html
+    """
+
+    _OPTIONS = ["maxiter", "maxfev", "disp", "xatol", "adaptive"]
+
+    # pylint: disable=unused-argument
+    def __init__(
+        self,
+        maxiter: int | None = None,
+        maxfev: int = 1000,
+        disp: bool = False,
+        xatol: float = 0.0001,
+        tol: float | None = None,
+        adaptive: bool = False,
+        options: dict | None = None,
+        **kwargs,
+    ) -> None:
+        """
+        Args:
+            maxiter: Maximum allowed number of iterations. If both maxiter and maxfev are set,
+                minimization will stop at the first reached.
+            maxfev: Maximum allowed number of function evaluations. If both maxiter and
+                maxfev are set, minimization will stop at the first reached.
+            disp: Set to True to print convergence messages.
+            xatol: Absolute error in xopt between iterations that is acceptable for convergence.
+            tol: Tolerance for termination.
+            adaptive: Adapt algorithm parameters to dimensionality of problem.
+            options: A dictionary of solver options.
+            kwargs: additional kwargs for scipy.optimize.minimize.
+        """
+        if options is None:
+            options = {}
+        for k, v in list(locals().items()):
+            if k in self._OPTIONS:
+                options[k] = v
+        super().__init__(method="Nelder-Mead", options=options, tol=tol, **kwargs)
diff --git a/qiskit_machine_learning/optimizers/nft.py b/qiskit_machine_learning/optimizers/nft.py
new file mode 100644
index 000000000..b76bfc983
--- /dev/null
+++ b/qiskit_machine_learning/optimizers/nft.py
@@ -0,0 +1,168 @@
+# This code is part of a Qiskit project.
+#
+# (C) Copyright IBM 2019, 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
+# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
+#
+# Any modifications or derivative works of this code must retain this
+# copyright notice, and modified files need to carry a notice indicating
+# that they have been altered from the originals.
+
+"""Nakanishi-Fujii-Todo algorithm."""
+from __future__ import annotations
+
+
+import numpy as np
+from scipy.optimize import OptimizeResult
+
+from .scipy_optimizer import SciPyOptimizer
+
+
+class NFT(SciPyOptimizer):
+    """
+    Nakanishi-Fujii-Todo algorithm.
+
+    See https://arxiv.org/abs/1903.12166
+    """
+
+    _OPTIONS = ["maxiter", "maxfev", "disp", "reset_interval"]
+
+    # pylint: disable=unused-argument
+    def __init__(
+        self,
+        maxiter: int | None = None,
+        maxfev: int = 1024,
+        disp: bool = False,
+        reset_interval: int = 32,
+        options: dict | None = None,
+        **kwargs,
+    ) -> None:
+        """
+        Built out using scipy framework, for details, please refer to
+        https://docs.scipy.org/doc/scipy/reference/generated/scipy.optimize.minimize.html.
+
+        Args:
+            maxiter: Maximum number of iterations to perform.
+            maxfev: Maximum number of function evaluations to perform.
+            disp: disp
+            reset_interval: The minimum estimates directly once
+                            in ``reset_interval`` times.
+            options: A dictionary of solver options.
+            kwargs: additional kwargs for scipy.optimize.minimize.
+
+        Notes:
+            In this optimization method, the optimization function have to satisfy
+            three conditions written in [1]_.
+
+        References:
+            .. [1] K. M. Nakanishi, K. Fujii, and S. Todo. 2019.
+                Sequential minimal optimization for quantum-classical hybrid algorithms.
+                arXiv preprint arXiv:1903.12166.
+        """
+        if options is None:
+            options = {}
+        for k, v in list(locals().items()):
+            if k in self._OPTIONS:
+                options[k] = v
+        super().__init__(method=nakanishi_fujii_todo, options=options, **kwargs)
+
+
+# pylint: disable=invalid-name
+def nakanishi_fujii_todo(
+    fun, x0, args=(), maxiter=None, maxfev=1024, reset_interval=32, eps=1e-32, callback=None, **_
+):
+    """
+    Find the global minimum of a function using the nakanishi_fujii_todo
+    algorithm [1].
+    Args:
+        fun (callable): ``f(x, *args)``
+            Function to be optimized.  ``args`` can be passed as an optional item
+            in the dict ``minimizer_kwargs``.
+            This function must satisfy the three condition written in Ref. [1].
+        x0 (ndarray): shape (n,)
+            Initial guess. Array of real elements of size (n,),
+            where 'n' is the number of independent variables.
+        args (tuple, optional):
+            Extra arguments passed to the objective function.
+        maxiter (int):
+            Maximum number of iterations to perform.
+            Default: None.
+        maxfev (int):
+            Maximum number of function evaluations to perform.
+            Default: 1024.
+        reset_interval (int):
+            The minimum estimates directly once in ``reset_interval`` times.
+            Default: 32.
+        eps (float): eps
+        **_ : additional options
+        callback (callable, optional):
+            Called after each iteration.
+    Returns:
+        OptimizeResult:
+            The optimization result represented as a ``OptimizeResult`` object.
+            Important attributes are: ``x`` the solution array. See
+            `OptimizeResult` for a description of other attributes.
+    Notes:
+        In this optimization method, the optimization function have to satisfy
+        three conditions written in [1].
+    References:
+        .. [1] K. M. Nakanishi, K. Fujii, and S. Todo. 2019.
+        Sequential minimal optimization for quantum-classical hybrid algorithms.
+        arXiv preprint arXiv:1903.12166.
+    """
+
+    x0 = np.asarray(x0)
+    recycle_z0 = None
+    niter = 0
+    funcalls = 0
+
+    while True:
+        idx = niter % x0.size
+
+        if reset_interval > 0:
+            if niter % reset_interval == 0:
+                recycle_z0 = None
+
+        if recycle_z0 is None:
+            z0 = fun(np.copy(x0), *args)
+            funcalls += 1
+        else:
+            z0 = recycle_z0
+
+        p = np.copy(x0)
+        p[idx] = x0[idx] + np.pi / 2
+        z1 = fun(p, *args)
+        funcalls += 1
+
+        p = np.copy(x0)
+        p[idx] = x0[idx] - np.pi / 2
+        z3 = fun(p, *args)
+        funcalls += 1
+
+        z2 = z1 + z3 - z0
+        c = (z1 + z3) / 2
+        a = np.sqrt((z0 - z2) ** 2 + (z1 - z3) ** 2) / 2
+        b = np.arctan((z1 - z3) / ((z0 - z2) + eps * (z0 == z2))) + x0[idx]
+        b += 0.5 * np.pi + 0.5 * np.pi * np.sign((z0 - z2) + eps * (z0 == z2))
+
+        x0[idx] = b
+        recycle_z0 = c - a
+
+        niter += 1
+
+        if callback is not None:
+            callback(np.copy(x0))
+
+        if maxfev is not None:
+            if funcalls >= maxfev:
+                break
+
+        if maxiter is not None:
+            if niter >= maxiter:
+                break
+
+    return OptimizeResult(
+        fun=fun(np.copy(x0), *args), x=x0, nit=niter, nfev=funcalls, success=(niter > 1)
+    )
diff --git a/qiskit_machine_learning/optimizers/nlopts/__init__.py b/qiskit_machine_learning/optimizers/nlopts/__init__.py
new file mode 100644
index 000000000..59bf5f86c
--- /dev/null
+++ b/qiskit_machine_learning/optimizers/nlopts/__init__.py
@@ -0,0 +1,20 @@
+# This code is part of a Qiskit project.
+#
+# (C) Copyright IBM 2018, 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
+# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
+#
+# Any modifications or derivative works of this code must retain this
+# copyright notice, and modified files need to carry a notice indicating
+# that they have been altered from the originals.
+
+"""NLopt based global optimizers"""
+
+from .crs import CRS
+from .direct_l import DIRECT_L
+from .direct_l_rand import DIRECT_L_RAND
+from .esch import ESCH
+from .isres import ISRES
+from .nloptimizer import NLoptOptimizer
diff --git a/qiskit_machine_learning/optimizers/nlopts/crs.py b/qiskit_machine_learning/optimizers/nlopts/crs.py
new file mode 100644
index 000000000..c28209d89
--- /dev/null
+++ b/qiskit_machine_learning/optimizers/nlopts/crs.py
@@ -0,0 +1,35 @@
+# This code is part of a Qiskit project.
+#
+# (C) Copyright IBM 2018, 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
+# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
+#
+# Any modifications or derivative works of this code must retain this
+# copyright notice, and modified files need to carry a notice indicating
+# that they have been altered from the originals.
+
+"""Controlled Random Search (CRS) with local mutation optimizer."""
+
+from .nloptimizer import NLoptOptimizer, NLoptOptimizerType
+
+
+class CRS(NLoptOptimizer):
+    """
+    Controlled Random Search (CRS) with local mutation optimizer.
+
+    Controlled Random Search (CRS) with local mutation is part of the family of the CRS optimizers.
+    The CRS optimizers start with a random population of points, and randomly evolve these points
+    by heuristic rules. In the case of CRS with local mutation, the evolution is a randomized
+    version of the :class:`NELDER_MEAD` local optimizer.
+
+
+    NLopt global optimizer, derivative-free.
+    For further detail, please refer to
+    https://nlopt.readthedocs.io/en/latest/NLopt_Algorithms/#controlled-random-search-crs-with-local-mutation
+    """
+
+    def get_nlopt_optimizer(self) -> NLoptOptimizerType:
+        """Return NLopt optimizer type"""
+        return NLoptOptimizerType.GN_CRS2_LM
diff --git a/qiskit_machine_learning/optimizers/nlopts/direct_l.py b/qiskit_machine_learning/optimizers/nlopts/direct_l.py
new file mode 100644
index 000000000..35e595973
--- /dev/null
+++ b/qiskit_machine_learning/optimizers/nlopts/direct_l.py
@@ -0,0 +1,34 @@
+# This code is part of a Qiskit project.
+#
+# (C) Copyright IBM 2018, 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
+# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
+#
+# Any modifications or derivative works of this code must retain this
+# copyright notice, and modified files need to carry a notice indicating
+# that they have been altered from the originals.
+
+"""DIviding RECTangles Locally-biased optimizer."""
+
+from .nloptimizer import NLoptOptimizer, NLoptOptimizerType
+
+
+class DIRECT_L(NLoptOptimizer):  # pylint: disable=invalid-name
+    """
+    DIviding RECTangles Locally-biased optimizer.
+
+    DIviding RECTangles (DIRECT) is a deterministic-search algorithms based on systematic division
+    of the search domain into increasingly smaller hyper-rectangles.
+    The DIRECT-L version is a "locally biased" variant of DIRECT that makes the algorithm more
+    biased towards local search, so that it is more efficient for functions with few local minima.
+
+    NLopt global optimizer, derivative-free.
+    For further detail, please refer to
+    http://nlopt.readthedocs.io/en/latest/NLopt_Algorithms/#direct-and-direct-l
+    """
+
+    def get_nlopt_optimizer(self) -> NLoptOptimizerType:
+        """Return NLopt optimizer type"""
+        return NLoptOptimizerType.GN_DIRECT_L
diff --git a/qiskit_machine_learning/optimizers/nlopts/direct_l_rand.py b/qiskit_machine_learning/optimizers/nlopts/direct_l_rand.py
new file mode 100644
index 000000000..0d0d0fd9d
--- /dev/null
+++ b/qiskit_machine_learning/optimizers/nlopts/direct_l_rand.py
@@ -0,0 +1,32 @@
+# This code is part of a Qiskit project.
+#
+# (C) Copyright IBM 2018, 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
+# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
+#
+# Any modifications or derivative works of this code must retain this
+# copyright notice, and modified files need to carry a notice indicating
+# that they have been altered from the originals.
+
+"""DIviding RECTangles Locally-biased Randomized optimizer."""
+
+from .nloptimizer import NLoptOptimizer, NLoptOptimizerType
+
+
+class DIRECT_L_RAND(NLoptOptimizer):  # pylint: disable=invalid-name
+    """
+    DIviding RECTangles Locally-biased Randomized optimizer.
+
+    DIRECT-L RAND is the "locally biased" variant with some randomization in near-tie decisions.
+    See also :class:`DIRECT_L`
+
+    NLopt global optimizer, derivative-free.
+    For further detail, please refer to
+    http://nlopt.readthedocs.io/en/latest/NLopt_Algorithms/#direct-and-direct-l
+    """
+
+    def get_nlopt_optimizer(self) -> NLoptOptimizerType:
+        """Return NLopt optimizer type"""
+        return NLoptOptimizerType.GN_DIRECT_L_RAND
diff --git a/qiskit_machine_learning/optimizers/nlopts/esch.py b/qiskit_machine_learning/optimizers/nlopts/esch.py
new file mode 100644
index 000000000..28a619101
--- /dev/null
+++ b/qiskit_machine_learning/optimizers/nlopts/esch.py
@@ -0,0 +1,33 @@
+# This code is part of a Qiskit project.
+#
+# (C) Copyright IBM 2018, 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
+# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
+#
+# Any modifications or derivative works of this code must retain this
+# copyright notice, and modified files need to carry a notice indicating
+# that they have been altered from the originals.
+
+"""ESCH evolutionary optimizer."""
+
+from .nloptimizer import NLoptOptimizer, NLoptOptimizerType
+
+
+class ESCH(NLoptOptimizer):
+    """
+    ESCH evolutionary optimizer.
+
+    ESCH is an evolutionary algorithm for global optimization that supports bound constraints only.
+    Specifically, it does not support nonlinear constraints.
+
+    NLopt global optimizer, derivative-free.
+    For further detail, please refer to
+
+    http://nlopt.readthedocs.io/en/latest/NLopt_Algorithms/#esch-evolutionary-algorithm
+    """
+
+    def get_nlopt_optimizer(self) -> NLoptOptimizerType:
+        """Return NLopt optimizer type"""
+        return NLoptOptimizerType.GN_ESCH
diff --git a/qiskit_machine_learning/optimizers/nlopts/isres.py b/qiskit_machine_learning/optimizers/nlopts/isres.py
new file mode 100644
index 000000000..5353a4b67
--- /dev/null
+++ b/qiskit_machine_learning/optimizers/nlopts/isres.py
@@ -0,0 +1,39 @@
+# This code is part of a Qiskit project.
+#
+# (C) Copyright IBM 2018, 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
+# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
+#
+# Any modifications or derivative works of this code must retain this
+# copyright notice, and modified files need to carry a notice indicating
+# that they have been altered from the originals.
+
+"""Improved Stochastic Ranking Evolution Strategy optimizer."""
+
+from .nloptimizer import NLoptOptimizer, NLoptOptimizerType
+
+
+class ISRES(NLoptOptimizer):
+    """
+    Improved Stochastic Ranking Evolution Strategy optimizer.
+
+    Improved Stochastic Ranking Evolution Strategy (ISRES) is an algorithm for
+    non-linearly constrained global optimization. It has heuristics to escape local optima,
+    even though convergence to a global optima is not guaranteed. The evolution strategy is based
+    on a combination of a mutation rule and differential variation. The fitness ranking is simply
+    via the objective function for problems without nonlinear constraints. When nonlinear
+    constraints are included, the `stochastic ranking proposed by Runarsson and Yao
+    <https://notendur.hi.is/tpr/software/sres/Tec311r.pdf>`__
+    is employed. This method supports arbitrary nonlinear inequality and equality constraints, in
+    addition to the bound constraints.
+
+    NLopt global optimizer, derivative-free.
+    For further detail, please refer to
+    http://nlopt.readthedocs.io/en/latest/NLopt_Algorithms/#isres-improved-stochastic-ranking-evolution-strategy
+    """
+
+    def get_nlopt_optimizer(self) -> NLoptOptimizerType:
+        """Return NLopt optimizer type"""
+        return NLoptOptimizerType.GN_ISRES
diff --git a/qiskit_machine_learning/optimizers/nlopts/nloptimizer.py b/qiskit_machine_learning/optimizers/nlopts/nloptimizer.py
new file mode 100644
index 000000000..d04e1d4d1
--- /dev/null
+++ b/qiskit_machine_learning/optimizers/nlopts/nloptimizer.py
@@ -0,0 +1,131 @@
+# This code is part of a Qiskit project.
+#
+# (C) Copyright IBM 2018, 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
+# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
+#
+# Any modifications or derivative works of this code must retain this
+# copyright notice, and modified files need to carry a notice indicating
+# that they have been altered from the originals.
+
+"""Minimize using objective function"""
+from __future__ import annotations
+
+from collections.abc import Callable
+from enum import Enum
+from abc import abstractmethod
+import logging
+import numpy as np
+
+from ...utils import optionals as _optionals
+from ..optimizer import Optimizer, OptimizerSupportLevel, OptimizerResult, POINT
+
+logger = logging.getLogger(__name__)
+
+
+class NLoptOptimizerType(Enum):
+    """NLopt Valid Optimizer"""
+
+    GN_CRS2_LM = 1
+    GN_DIRECT_L_RAND = 2
+    GN_DIRECT_L = 3
+    GN_ESCH = 4
+    GN_ISRES = 5
+
+
+@_optionals.HAS_NLOPT.require_in_instance
+class NLoptOptimizer(Optimizer):
+    """
+    NLopt global optimizer base class
+    """
+
+    _OPTIONS = ["max_evals"]
+
+    def __init__(self, max_evals: int = 1000) -> None:  # pylint: disable=unused-argument
+        """
+        Args:
+            max_evals: Maximum allowed number of function evaluations.
+
+        Raises:
+            MissingOptionalLibraryError: NLopt library not installed.
+        """
+        import nlopt  # pylint: disable=import-error
+
+        super().__init__()
+        for k, v in list(locals().items()):
+            if k in self._OPTIONS:
+                self._options[k] = v
+
+        self._optimizer_names = {
+            NLoptOptimizerType.GN_CRS2_LM: nlopt.GN_CRS2_LM,
+            NLoptOptimizerType.GN_DIRECT_L_RAND: nlopt.GN_DIRECT_L_RAND,
+            NLoptOptimizerType.GN_DIRECT_L: nlopt.GN_DIRECT_L,
+            NLoptOptimizerType.GN_ESCH: nlopt.GN_ESCH,
+            NLoptOptimizerType.GN_ISRES: nlopt.GN_ISRES,
+        }
+
+    @abstractmethod
+    def get_nlopt_optimizer(self) -> NLoptOptimizerType:
+        """return NLopt optimizer enum type"""
+        raise NotImplementedError
+
+    def get_support_level(self):
+        """return support level dictionary"""
+        return {
+            "gradient": OptimizerSupportLevel.ignored,
+            "bounds": OptimizerSupportLevel.supported,
+            "initial_point": OptimizerSupportLevel.required,
+        }
+
+    @property
+    def settings(self):
+        return {"max_evals": self._options.get("max_evals", 1000)}
+
+    def minimize(
+        self,
+        fun: Callable[[POINT], float],
+        x0: POINT,
+        jac: Callable[[POINT], POINT] | None = None,
+        bounds: list[tuple[float, float]] | None = None,
+    ) -> OptimizerResult:
+        import nlopt  # pylint: disable=import-error
+
+        x0 = np.asarray(x0)
+
+        if bounds is None:
+            bounds = [(None, None)] * x0.size
+
+        threshold = 3 * np.pi
+        low = [(l if l is not None else -threshold) for (l, u) in bounds]
+        high = [(u if u is not None else threshold) for (l, u) in bounds]
+
+        name = self._optimizer_names[self.get_nlopt_optimizer()]
+        opt = nlopt.opt(name, len(low))
+        logger.debug(opt.get_algorithm_name())
+
+        opt.set_lower_bounds(low)
+        opt.set_upper_bounds(high)
+
+        eval_count = 0
+
+        def wrap_objfunc_global(x, _grad):
+            nonlocal eval_count
+            eval_count += 1
+            return fun(x)
+
+        opt.set_min_objective(wrap_objfunc_global)
+        opt.set_maxeval(self._options.get("max_evals", 1000))
+
+        xopt = opt.optimize(x0)
+        minf = opt.last_optimum_value()
+
+        logger.debug("Global minimize found %s eval count %s", minf, eval_count)
+
+        result = OptimizerResult()
+        result.x = xopt
+        result.fun = minf
+        result.nfev = eval_count
+
+        return result
diff --git a/qiskit_machine_learning/optimizers/optimizer.py b/qiskit_machine_learning/optimizers/optimizer.py
new file mode 100644
index 000000000..b9effe9b8
--- /dev/null
+++ b/qiskit_machine_learning/optimizers/optimizer.py
@@ -0,0 +1,389 @@
+# This code is part of a Qiskit project.
+#
+# (C) Copyright IBM 2018, 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
+# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
+#
+# Any modifications or derivative works of this code must retain this
+# copyright notice, and modified files need to carry a notice indicating
+# that they have been altered from the originals.
+
+"""Optimizer interface"""
+
+from __future__ import annotations
+
+from abc import ABC, abstractmethod
+from collections.abc import Callable
+from enum import IntEnum
+import logging
+from typing import Any, Union, Protocol
+
+import numpy as np
+import scipy
+
+from ..algorithm_result import AlgorithmResult
+
+logger = logging.getLogger(__name__)
+
+POINT = Union[float, np.ndarray]  # pylint: disable=invalid-name
+
+
+class OptimizerResult(AlgorithmResult):
+    """The result of an optimization routine."""
+
+    def __init__(self) -> None:
+        super().__init__()
+        self._x: POINT | None = None  # pylint: disable=invalid-name
+        self._fun: float | None = None
+        self._jac: POINT | None = None
+        self._nfev: int | None = None
+        self._njev: int | None = None
+        self._nit: int | None = None
+
+    @property
+    def x(self) -> POINT | None:
+        """The final point of the minimization."""
+        return self._x
+
+    @x.setter
+    def x(self, x: POINT | None) -> None:
+        """Set the final point of the minimization."""
+        self._x = x
+
+    @property
+    def fun(self) -> float | None:
+        """The final value of the minimization."""
+        return self._fun
+
+    @fun.setter
+    def fun(self, fun: float | None) -> None:
+        """Set the final value of the minimization."""
+        self._fun = fun
+
+    @property
+    def jac(self) -> POINT | None:
+        """The final gradient of the minimization."""
+        return self._jac
+
+    @jac.setter
+    def jac(self, jac: POINT | None) -> None:
+        """Set the final gradient of the minimization."""
+        self._jac = jac
+
+    @property
+    def nfev(self) -> int | None:
+        """The total number of function evaluations."""
+        return self._nfev
+
+    @nfev.setter
+    def nfev(self, nfev: int | None) -> None:
+        """Set the total number of function evaluations."""
+        self._nfev = nfev
+
+    @property
+    def njev(self) -> int | None:
+        """The total number of gradient evaluations."""
+        return self._njev
+
+    @njev.setter
+    def njev(self, njev: int | None) -> None:
+        """Set the total number of gradient evaluations."""
+        self._njev = njev
+
+    @property
+    def nit(self) -> int | None:
+        """The total number of iterations."""
+        return self._nit
+
+    @nit.setter
+    def nit(self, nit: int | None) -> None:
+        """Set the total number of iterations."""
+        self._nit = nit
+
+
+class Minimizer(Protocol):
+    """Callable Protocol for minimizer.
+
+    This interface is based on `SciPy's optimize module
+    <https://docs.scipy.org/doc/scipy/reference/generated/scipy.optimize.minimize.html>`__.
+
+     This protocol defines a callable taking the following parameters:
+
+         fun
+             The objective function to minimize (for example the energy in the case of the VQE).
+         x0
+             The initial point for the optimization.
+         jac
+             The gradient of the objective function.
+         bounds
+             Parameters bounds for the optimization. Note that these might not be supported
+             by all optimizers.
+
+     and which returns a minimization result object (either SciPy's or Qiskit's).
+    """
+
+    # pylint: disable=invalid-name
+    def __call__(
+        self,
+        fun: Callable[[np.ndarray], float],
+        x0: np.ndarray,
+        jac: Callable[[np.ndarray], np.ndarray] | None,
+        bounds: list[tuple[float, float]] | None,
+    ) -> scipy.optimize.OptimizeResult | OptimizerResult:
+        """Minimize the objective function.
+
+        This interface is based on `SciPy's optimize module <https://docs.scipy.org/doc
+        /scipy/reference/generated/scipy.optimize.minimize.html>`__.
+
+        Args:
+            fun: The objective function to minimize (for example the energy in the case of the VQE).
+            x0: The initial point for the optimization.
+            jac: The gradient of the objective function.
+            bounds: Parameters bounds for the optimization. Note that these might not be supported
+                by all optimizers.
+
+        Returns:
+             The minimization result object (either SciPy's or Qiskit's).
+        """
+        ...  # pylint: disable=unnecessary-ellipsis
+
+
+class OptimizerSupportLevel(IntEnum):
+    """Support Level enum for features such as bounds, gradient and initial point"""
+
+    # pylint: disable=invalid-name
+    not_supported = 0  # Does not support the corresponding parameter in optimize()
+    ignored = 1  # Feature can be passed as non None but will be ignored
+    supported = 2  # Feature is supported
+    required = 3  # Feature is required and must be given, None is invalid
+
+
+class Optimizer(ABC):
+    """Base class for optimization algorithm."""
+
+    @abstractmethod
+    def __init__(self):
+        """
+        Initialize the optimization algorithm, setting the support
+        level for _gradient_support_level, _bound_support_level,
+        _initial_point_support_level, and empty options.
+        """
+        self._gradient_support_level = self.get_support_level()["gradient"]
+        self._bounds_support_level = self.get_support_level()["bounds"]
+        self._initial_point_support_level = self.get_support_level()["initial_point"]
+        self._options = {}
+        self._max_evals_grouped = None
+
+    @abstractmethod
+    def get_support_level(self):
+        """Return support level dictionary"""
+        raise NotImplementedError
+
+    def set_options(self, **kwargs):
+        """
+        Sets or updates values in the options dictionary.
+
+        The options dictionary may be used internally by a given optimizer to
+        pass additional optional values for the underlying optimizer/optimization
+        function used. The options dictionary may be initially populated with
+        a set of key/values when the given optimizer is constructed.
+
+        Args:
+            kwargs (dict): options, given as name=value.
+        """
+        for name, value in kwargs.items():
+            self._options[name] = value
+        logger.debug("options: %s", self._options)
+
+    # pylint: disable=invalid-name
+    @staticmethod
+    def gradient_num_diff(x_center, f, epsilon, max_evals_grouped=None):
+        """
+        We compute the gradient with the numeric differentiation in the parallel way,
+        around the point x_center.
+
+        Args:
+            x_center (ndarray): point around which we compute the gradient
+            f (func): the function of which the gradient is to be computed.
+            epsilon (float): the epsilon used in the numeric differentiation.
+            max_evals_grouped (int): max evals grouped, defaults to 1 (i.e. no batching).
+        Returns:
+            grad: the gradient computed
+
+        """
+        if max_evals_grouped is None:  # no batching by default
+            max_evals_grouped = 1
+
+        forig = f(*((x_center,)))
+        grad = []
+        ei = np.zeros((len(x_center),), float)
+        todos = []
+        for k in range(len(x_center)):
+            ei[k] = 1.0
+            d = epsilon * ei
+            todos.append(x_center + d)
+            ei[k] = 0.0
+
+        counter = 0
+        chunk = []
+        chunks = []
+        length = len(todos)
+        # split all points to chunks, where each chunk has batch_size points
+        for i in range(length):
+            x = todos[i]
+            chunk.append(x)
+            counter += 1
+            # the last one does not have to reach batch_size
+            if counter == max_evals_grouped or i == length - 1:
+                chunks.append(chunk)
+                chunk = []
+                counter = 0
+
+        for chunk in chunks:  # eval the chunks in order
+            parallel_parameters = np.concatenate(chunk)
+            todos_results = f(parallel_parameters)  # eval the points in a chunk (order preserved)
+            if isinstance(todos_results, float):
+                grad.append((todos_results - forig) / epsilon)
+            else:
+                for todor in todos_results:
+                    grad.append((todor - forig) / epsilon)
+
+        return np.array(grad)
+
+    @staticmethod
+    def wrap_function(function, args):
+        """
+        Wrap the function to implicitly inject the args at the call of the function.
+
+        Args:
+            function (func): the target function
+            args (tuple): the args to be injected
+        Returns:
+            function_wrapper: wrapper
+        """
+
+        def function_wrapper(*wrapper_args):
+            return function(*(wrapper_args + args))
+
+        return function_wrapper
+
+    @property
+    def setting(self):
+        """Return setting"""
+        ret = f"Optimizer: {self.__class__.__name__}\n"
+        params = ""
+        for key, value in self.__dict__.items():
+            if key[0] == "_":
+                params += f"-- {key[1:]}: {value}\n"
+        ret += f"{params}"
+        return ret
+
+    @property
+    def settings(self) -> dict[str, Any]:
+        """The optimizer settings in a dictionary format.
+
+        The settings can for instance be used for JSON-serialization (if all settings are
+        serializable, which e.g. doesn't hold per default for callables), such that the
+        optimizer object can be reconstructed as
+
+        .. code-block::
+
+            settings = optimizer.settings
+            # JSON serialize and send to another server
+            optimizer = OptimizerClass(**settings)
+
+        """
+        raise NotImplementedError("The settings method is not implemented per default.")
+
+    @abstractmethod
+    def minimize(
+        self,
+        fun: Callable[[POINT], float],
+        x0: POINT,
+        jac: Callable[[POINT], POINT] | None = None,
+        bounds: list[tuple[float, float]] | None = None,
+    ) -> OptimizerResult:
+        """Minimize the scalar function.
+
+        Args:
+            fun: The scalar function to minimize.
+            x0: The initial point for the minimization.
+            jac: The gradient of the scalar function ``fun``.
+            bounds: Bounds for the variables of ``fun``. This argument might be ignored if the
+                optimizer does not support bounds.
+
+        Returns:
+            The result of the optimization, containing e.g. the result as attribute ``x``.
+        """
+        raise NotImplementedError()
+
+    @property
+    def gradient_support_level(self):
+        """Returns gradient support level"""
+        return self._gradient_support_level
+
+    @property
+    def is_gradient_ignored(self):
+        """Returns is gradient ignored"""
+        return self._gradient_support_level == OptimizerSupportLevel.ignored
+
+    @property
+    def is_gradient_supported(self):
+        """Returns is gradient supported"""
+        return self._gradient_support_level != OptimizerSupportLevel.not_supported
+
+    @property
+    def is_gradient_required(self):
+        """Returns is gradient required"""
+        return self._gradient_support_level == OptimizerSupportLevel.required
+
+    @property
+    def bounds_support_level(self):
+        """Returns bounds support level"""
+        return self._bounds_support_level
+
+    @property
+    def is_bounds_ignored(self):
+        """Returns is bounds ignored"""
+        return self._bounds_support_level == OptimizerSupportLevel.ignored
+
+    @property
+    def is_bounds_supported(self):
+        """Returns is bounds supported"""
+        return self._bounds_support_level != OptimizerSupportLevel.not_supported
+
+    @property
+    def is_bounds_required(self):
+        """Returns is bounds required"""
+        return self._bounds_support_level == OptimizerSupportLevel.required
+
+    @property
+    def initial_point_support_level(self):
+        """Returns initial point support level"""
+        return self._initial_point_support_level
+
+    @property
+    def is_initial_point_ignored(self):
+        """Returns is initial point ignored"""
+        return self._initial_point_support_level == OptimizerSupportLevel.ignored
+
+    @property
+    def is_initial_point_supported(self):
+        """Returns is initial point supported"""
+        return self._initial_point_support_level != OptimizerSupportLevel.not_supported
+
+    @property
+    def is_initial_point_required(self):
+        """Returns is initial point required"""
+        return self._initial_point_support_level == OptimizerSupportLevel.required
+
+    def print_options(self):
+        """Print algorithm-specific options."""
+        for name in sorted(self._options):
+            logger.debug("%s = %s", name, str(self._options[name]))
+
+    def set_max_evals_grouped(self, limit):
+        """Set max evals grouped"""
+        self._max_evals_grouped = limit
diff --git a/qiskit_machine_learning/optimizers/optimizer_utils/__init__.py b/qiskit_machine_learning/optimizers/optimizer_utils/__init__.py
new file mode 100644
index 000000000..fb251fe03
--- /dev/null
+++ b/qiskit_machine_learning/optimizers/optimizer_utils/__init__.py
@@ -0,0 +1,27 @@
+# This code is part of a Qiskit project.
+#
+# (C) Copyright IBM 2022, 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
+# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
+#
+# Any modifications or derivative works of this code must retain this
+# copyright notice, and modified files need to carry a notice indicating
+# that they have been altered from the originals.
+"""Utils for optimizers
+
+Optimizer Utils (:mod:`qiskit_machine_learning.optimizers.optimizer_utils`)
+=====================================================================
+
+.. autosummary::
+   :toctree: ../stubs/
+   :nosignatures:
+
+   LearningRate
+
+"""
+
+from .learning_rate import LearningRate
+
+__all__ = ["LearningRate"]
diff --git a/qiskit_machine_learning/optimizers/optimizer_utils/learning_rate.py b/qiskit_machine_learning/optimizers/optimizer_utils/learning_rate.py
new file mode 100644
index 000000000..a9c159492
--- /dev/null
+++ b/qiskit_machine_learning/optimizers/optimizer_utils/learning_rate.py
@@ -0,0 +1,87 @@
+# This code is part of a Qiskit project.
+#
+# (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
+# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
+#
+# Any modifications or derivative works of this code must retain this
+# copyright notice, and modified files need to carry a notice indicating
+# that they have been altered from the originals.
+
+"""A class to represent the Learning Rate."""
+from __future__ import annotations
+
+from collections.abc import Generator, Callable
+from itertools import tee
+import numpy as np
+
+
+class LearningRate(Generator):
+    """Represents a Learning Rate.
+    Will be an attribute of :class:`~.GradientDescentState`. Note that :class:`~.GradientDescent` also
+    has a learning rate. That learning rate can be a float, a list, an array, a function returning
+    a generator and will be used to create a generator to be used during the
+    optimization process.
+    This class wraps ``Generator`` so that we can also access the last yielded value.
+    """
+
+    def __init__(
+        self,
+        learning_rate: (
+            float | list[float] | np.ndarray | Callable[[], Generator[float, None, None]]
+        ),
+    ):
+        """
+        Args:
+            learning_rate: Used to create a generator to iterate on.
+        """
+        if isinstance(learning_rate, (float, int)):
+            self._gen = constant(learning_rate)
+        elif isinstance(learning_rate, Generator):
+            learning_rate, self._gen = tee(learning_rate)
+        elif isinstance(learning_rate, (list, np.ndarray)):
+            self._gen = (eta for eta in learning_rate)
+        else:
+            self._gen = learning_rate()
+
+        self._current: float | None = None
+
+    def send(self, value):
+        """Send a value into the generator.
+        Return next yielded value or raise StopIteration.
+        """
+        self._current = next(self._gen)
+        return self.current
+
+    def throw(self, typ, val=None, tb=None):
+        """Raise an exception in the generator.
+        Return next yielded value or raise StopIteration.
+        """
+        if val is None:
+            if tb is None:
+                raise typ
+            val = typ()
+        if tb is not None:
+            val = val.with_traceback(tb)
+        raise val
+
+    @property
+    def current(self):
+        """Returns the current value of the learning rate."""
+        return self._current
+
+
+def constant(learning_rate: float = 0.01) -> Generator[float, None, None]:
+    """Returns a python generator that always yields the same value.
+
+    Args:
+        learning_rate: The value to yield.
+
+    Yields:
+        The learning rate for the next iteration.
+    """
+
+    while True:
+        yield learning_rate
diff --git a/qiskit_machine_learning/optimizers/p_bfgs.py b/qiskit_machine_learning/optimizers/p_bfgs.py
new file mode 100644
index 000000000..c70d7697d
--- /dev/null
+++ b/qiskit_machine_learning/optimizers/p_bfgs.py
@@ -0,0 +1,183 @@
+# This code is part of a Qiskit project.
+#
+# (C) Copyright IBM 2018, 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
+# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
+#
+# Any modifications or derivative works of this code must retain this
+# copyright notice, and modified files need to carry a notice indicating
+# that they have been altered from the originals.
+
+"""Parallelized Limited-memory BFGS optimizer"""
+from __future__ import annotations
+
+import logging
+import multiprocessing
+import platform
+from collections.abc import Callable
+from typing import SupportsFloat
+
+import numpy as np
+
+from ..utils import algorithm_globals
+from ..utils.validation import validate_min
+
+from .optimizer import OptimizerResult, POINT
+from .scipy_optimizer import SciPyOptimizer
+
+logger = logging.getLogger(__name__)
+
+
+class P_BFGS(SciPyOptimizer):  # pylint: disable=invalid-name
+    """
+    Parallelized Limited-memory BFGS optimizer.
+
+    P-BFGS is a parallelized version of :class:`L_BFGS_B` with which it shares the same parameters.
+    P-BFGS can be useful when the target hardware is a quantum simulator running on a classical
+    machine. This allows the multiple processes to use simulation to potentially reach a minimum
+    faster. The parallelization may also help the optimizer avoid getting stuck at local optima.
+
+    Uses scipy.optimize.fmin_l_bfgs_b.
+    For further detail, please refer to
+    https://docs.scipy.org/doc/scipy/reference/generated/scipy.optimize.fmin_l_bfgs_b.html
+
+    .. note::
+
+        This component has some function that is normally random. If you want to reproduce behavior
+        then you should set the random number generator seed in the algorithm_globals
+        (``qiskit_machine_learning.utils.algorithm_globals.random_seed = seed``).
+    """
+
+    _OPTIONS = ["maxfun", "ftol", "iprint"]
+
+    # pylint: disable=unused-argument
+    def __init__(
+        self,
+        maxfun: int = 1000,
+        ftol: SupportsFloat = 10 * np.finfo(float).eps,
+        iprint: int = -1,
+        max_processes: int | None = None,
+        options: dict | None = None,
+        max_evals_grouped: int = 1,
+        **kwargs,
+    ) -> None:
+        r"""
+        Args:
+            maxfun: Maximum number of function evaluations.
+            ftol: The iteration stops when (f\^k - f\^{k+1})/max{\|f\^k\|,\|f\^{k+1}\|,1} <= ftol.
+            iprint: Controls the frequency of output. iprint < 0 means no output;
+                iprint = 0 print only one line at the last iteration; 0 < iprint < 99
+                print also f and \|proj g\| every iprint iterations; iprint = 99 print
+                details of every iteration except n-vectors; iprint = 100 print also the
+                changes of active set and final x; iprint > 100 print details of
+                every iteration including x and g.
+            max_processes: maximum number of processes allowed, has a min. value of 1 if not None.
+            options: A dictionary of solver options.
+            max_evals_grouped: Max number of default gradient evaluations performed simultaneously.
+            kwargs: additional kwargs for scipy.optimize.minimize.
+        """
+        if max_processes:
+            validate_min("max_processes", max_processes, 1)
+
+        if options is None:
+            options = {}
+        for k, v in list(locals().items()):
+            if k in self._OPTIONS:
+                options[k] = v
+        super().__init__(
+            method="L-BFGS-B",
+            options=options,
+            max_evals_grouped=max_evals_grouped,
+            **kwargs,
+        )
+        self._max_processes = max_processes
+
+    def minimize(
+        self,
+        fun: Callable[[POINT], float],
+        x0: POINT,
+        jac: Callable[[POINT], POINT] | None = None,
+        bounds: list[tuple[float, float]] | None = None,
+    ) -> OptimizerResult:
+        x0 = np.asarray(x0)
+
+        num_procs = multiprocessing.cpu_count() - 1
+        num_procs = (
+            num_procs if self._max_processes is None else min(num_procs, self._max_processes)
+        )
+        num_procs = num_procs if num_procs >= 0 else 0
+
+        if platform.system() == "Darwin":
+            # Changed in version 3.8: On macOS, the spawn start method is now the
+            # default. The fork start method should be considered unsafe as it can
+            # lead to crashes.
+            # However P_BFGS doesn't support spawn, so we revert to single process.
+            num_procs = 0
+            logger.warning(
+                "For MacOS, python >= 3.8, using only current process. "
+                "Multiple core use not supported."
+            )
+        elif platform.system() == "Windows":
+            num_procs = 0
+            logger.warning(
+                "For Windows, using only current process. Multiple core use not supported."
+            )
+
+        queue: multiprocessing.queues.Queue[tuple[POINT, float, int]] = multiprocessing.Queue()
+
+        # TODO: are automatic bounds a good idea? What if the circuit parameters are not
+        # just from plain Pauli rotations but have a coefficient?
+
+        # bounds for additional initial points in case bounds has any None values
+        threshold = 2 * np.pi
+        if bounds is None:
+            bounds = [(-threshold, threshold)] * x0.size
+        low = [(l if l is not None else -threshold) for (l, u) in bounds]
+        high = [(u if u is not None else threshold) for (l, u) in bounds]
+
+        def optimize_runner(_queue, _i_pt):  # Multi-process sampling
+            _sol, _opt, _nfev = self._optimize(fun, _i_pt, jac, bounds)
+            _queue.put((_sol, _opt, _nfev))
+
+        # Start off as many other processes running the optimize (can be 0)
+        processes = []
+        for _ in range(num_procs):
+            i_pt = algorithm_globals.random.uniform(low, high)  # Another random point in bounds
+            proc = multiprocessing.Process(target=optimize_runner, args=(queue, i_pt))
+            processes.append(proc)
+            proc.start()
+
+        # While the one optimize in this process below runs the other processes will
+        # be running too. This one runs
+        # with the supplied initial point. The process ones have their own random one
+        sol, opt, nfev = self._optimize(fun, x0, jac, bounds)
+
+        for proc in processes:
+            # For each other process we wait now for it to finish and see if it has
+            # a better result than above
+            proc.join()
+            p_sol, p_opt, p_nfev = queue.get()
+            if p_opt < opt:
+                sol, opt = p_sol, p_opt
+            nfev += p_nfev
+
+        result = OptimizerResult()
+        result.x = sol
+        result.fun = opt
+        result.nfev = nfev
+
+        return result
+
+    def _optimize(
+        self,
+        objective_function,
+        initial_point,
+        gradient_function=None,
+        variable_bounds=None,
+    ) -> tuple[POINT, float, int]:
+        result = super().minimize(
+            objective_function, initial_point, gradient_function, variable_bounds
+        )
+        return result.x, result.fun, result.nfev
diff --git a/qiskit_machine_learning/optimizers/powell.py b/qiskit_machine_learning/optimizers/powell.py
new file mode 100644
index 000000000..96842db36
--- /dev/null
+++ b/qiskit_machine_learning/optimizers/powell.py
@@ -0,0 +1,64 @@
+# This code is part of a Qiskit project.
+#
+# (C) Copyright IBM 2018, 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
+# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
+#
+# Any modifications or derivative works of this code must retain this
+# copyright notice, and modified files need to carry a notice indicating
+# that they have been altered from the originals.
+
+"""Powell optimizer."""
+from __future__ import annotations
+
+from .scipy_optimizer import SciPyOptimizer
+
+
+class POWELL(SciPyOptimizer):
+    """
+    Powell optimizer.
+
+    The Powell algorithm performs unconstrained optimization; it ignores bounds or
+    constraints. Powell is a *conjugate direction method*: it performs sequential one-dimensional
+    minimization along each directional vector, which is updated at
+    each iteration of the main minimization loop. The function being minimized need not be
+    differentiable, and no derivatives are taken.
+
+    Uses scipy.optimize.minimize Powell.
+    For further detail, please refer to
+    See https://docs.scipy.org/doc/scipy/reference/generated/scipy.optimize.minimize.html
+    """
+
+    _OPTIONS = ["maxiter", "maxfev", "disp", "xtol"]
+
+    # pylint: disable=unused-argument
+    def __init__(
+        self,
+        maxiter: int | None = None,
+        maxfev: int = 1000,
+        disp: bool = False,
+        xtol: float = 0.0001,
+        tol: float | None = None,
+        options: dict | None = None,
+        **kwargs,
+    ) -> None:
+        """
+        Args:
+            maxiter: Maximum allowed number of iterations. If both maxiter and maxfev
+                are set, minimization will stop at the first reached.
+            maxfev: Maximum allowed number of function evaluations. If both maxiter and
+                maxfev are set, minimization will stop at the first reached.
+            disp: Set to True to print convergence messages.
+            xtol: Relative error in solution xopt acceptable for convergence.
+            tol: Tolerance for termination.
+            options: A dictionary of solver options.
+            kwargs: additional kwargs for scipy.optimize.minimize.
+        """
+        if options is None:
+            options = {}
+        for k, v in list(locals().items()):
+            if k in self._OPTIONS:
+                options[k] = v
+        super().__init__("Powell", options=options, tol=tol, **kwargs)
diff --git a/qiskit_machine_learning/optimizers/qnspsa.py b/qiskit_machine_learning/optimizers/qnspsa.py
new file mode 100644
index 000000000..408bcd8a4
--- /dev/null
+++ b/qiskit_machine_learning/optimizers/qnspsa.py
@@ -0,0 +1,273 @@
+# This code is part of a Qiskit project.
+#
+# (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
+# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
+#
+# Any modifications or derivative works of this code must retain this
+# copyright notice, and modified files need to carry a notice indicating
+# that they have been altered from the originals.
+
+"""The QN-SPSA optimizer."""
+
+from __future__ import annotations
+
+from collections.abc import Iterator
+from typing import Any, Callable
+
+import numpy as np
+from qiskit.circuit import QuantumCircuit
+
+from qiskit.primitives import BaseSampler
+from ..state_fidelities import ComputeUncompute
+
+from .spsa import SPSA, CALLBACK, TERMINATIONCHECKER, _batch_evaluate
+
+# the function to compute the fidelity
+FIDELITY = Callable[[np.ndarray, np.ndarray], float]
+
+
+class QNSPSA(SPSA):
+    r"""The Quantum Natural SPSA (QN-SPSA) optimizer.
+
+    The QN-SPSA optimizer [1] is a stochastic optimizer that belongs to the family of gradient
+    descent methods. This optimizer is based on SPSA but attempts to improve the convergence by
+    sampling the **natural gradient** instead of the vanilla, first-order gradient. It achieves
+    this by approximating Hessian of the ``fidelity`` of the ansatz circuit.
+
+    Compared to natural gradients, which require :math:`\mathcal{O}(d^2)` expectation value
+    evaluations for a circuit with :math:`d` parameters, QN-SPSA only requires
+    :math:`\mathcal{O}(1)` and can therefore significantly speed up the natural gradient calculation
+    by sacrificing some accuracy. Compared to SPSA, QN-SPSA requires 4 additional function
+    evaluations of the fidelity.
+
+    The stochastic approximation of the natural gradient can be systematically improved by
+    increasing the number of ``resamplings``. This leads to a Monte Carlo-style convergence to
+    the exact, analytic value.
+
+    .. note::
+
+        This component has some function that is normally random. If you want to reproduce behavior
+        then you should set the random number generator seed in the algorithm_globals
+        (``qiskit_machine_learning.utils.algorithm_globals.random_seed = seed``).
+
+    Examples:
+
+        This short example runs QN-SPSA for the ground state calculation of the ``Z ^ Z``
+        observable where the ansatz is a ``PauliTwoDesign`` circuit.
+
+        .. code-block:: python
+
+            import numpy as np
+            from qiskit_machine_learning.optimizers import QNSPSA
+            from qiskit.circuit.library import PauliTwoDesign
+            from qiskit.primitives import Estimator, Sampler
+            from qiskit.quantum_info import Pauli
+
+            # problem setup
+            ansatz = PauliTwoDesign(2, reps=1, seed=2)
+            observable = Pauli("ZZ")
+            initial_point = np.random.random(ansatz.num_parameters)
+
+            # loss function
+            estimator = Estimator()
+
+            def loss(x):
+                result = estimator.run([ansatz], [observable], [x]).result()
+                return np.real(result.values[0])
+
+            # fidelity for estimation of the geometric tensor
+            sampler = Sampler()
+            fidelity = QNSPSA.get_fidelity(ansatz, sampler)
+
+            # run QN-SPSA
+            qnspsa = QNSPSA(fidelity, maxiter=300)
+            result = qnspsa.optimize(ansatz.num_parameters, loss, initial_point=initial_point)
+
+    References:
+
+        [1] J. Gacon et al, "Simultaneous Perturbation Stochastic Approximation of the Quantum
+        Fisher Information", `arXiv:2103.09232 <https://arxiv.org/abs/2103.09232>`_
+
+    """
+
+    def __init__(
+        self,
+        fidelity: FIDELITY,
+        maxiter: int = 100,
+        blocking: bool = True,
+        allowed_increase: float | None = None,
+        learning_rate: float | Callable[[], Iterator] | None = None,
+        perturbation: float | Callable[[], Iterator] | None = None,
+        resamplings: int | dict[int, int] = 1,
+        perturbation_dims: int | None = None,
+        regularization: float | None = None,
+        hessian_delay: int = 0,
+        lse_solver: Callable[[np.ndarray, np.ndarray], np.ndarray] | None = None,
+        initial_hessian: np.ndarray | None = None,
+        callback: CALLBACK | None = None,
+        termination_checker: TERMINATIONCHECKER | None = None,
+    ) -> None:
+        r"""
+        Args:
+            fidelity: A function to compute the fidelity of the ansatz state with itself for
+                two different sets of parameters.
+            maxiter: The maximum number of iterations. Note that this is not the maximal number
+                of function evaluations.
+            blocking: If True, only accepts updates that improve the loss (up to some allowed
+                increase, see next argument).
+            allowed_increase: If ``blocking`` is ``True``, this argument determines by how much
+                the loss can increase with the proposed parameters and still be accepted.
+                If ``None``, the allowed increases is calibrated automatically to be twice the
+                approximated standard deviation of the loss function.
+            learning_rate: The update step is the learning rate is multiplied with the gradient.
+                If the learning rate is a float, it remains constant over the course of the
+                optimization. It can also be a callable returning an iterator which yields the
+                learning rates for each optimization step.
+                If ``learning_rate`` is set ``perturbation`` must also be provided.
+            perturbation: Specifies the magnitude of the perturbation for the finite difference
+                approximation of the gradients. Can be either a float or a generator yielding
+                the perturbation magnitudes per step.
+                If ``perturbation`` is set ``learning_rate`` must also be provided.
+            resamplings: The number of times the gradient (and Hessian) is sampled using a random
+                direction to construct a gradient estimate. Per default the gradient is estimated
+                using only one random direction. If an integer, all iterations use the same number
+                of resamplings. If a dictionary, this is interpreted as
+                ``{iteration: number of resamplings per iteration}``.
+            perturbation_dims: The number of perturbed dimensions. Per default, all dimensions
+                are perturbed, but a smaller, fixed number can be perturbed. If set, the perturbed
+                dimensions are chosen uniformly at random.
+            regularization: To ensure the preconditioner is symmetric and positive definite, the
+                identity times a small coefficient is added to it. This generator yields that
+                coefficient.
+            hessian_delay: Start multiplying the gradient with the inverse Hessian only after a
+                certain number of iterations. The Hessian is still evaluated and therefore this
+                argument can be useful to first get a stable average over the last iterations before
+                using it as preconditioner.
+            lse_solver: The method to solve for the inverse of the Hessian. Per default an
+                exact LSE solver is used, but can e.g. be overwritten by a minimization routine.
+            initial_hessian: The initial guess for the Hessian. By default the identity matrix
+                is used.
+            callback: A callback function passed information in each iteration step. The
+                information is, in this order: the parameters, the function value, the number
+                of function evaluations, the stepsize, whether the step was accepted.
+            termination_checker: A callback function executed at the end of each iteration step. The
+                arguments are, in this order: the parameters, the function value, the number
+                of function evaluations, the stepsize, whether the step was accepted. If the callback
+                returns True, the optimization is terminated.
+                To prevent additional evaluations of the objective method, if the objective has not yet
+                been evaluated, the objective is estimated by taking the mean of the objective
+                evaluations used in the estimate of the gradient.
+
+
+        """
+        super().__init__(
+            maxiter,
+            blocking,
+            allowed_increase,
+            # trust region *must* be false for natural gradients to work
+            trust_region=False,
+            learning_rate=learning_rate,
+            perturbation=perturbation,
+            resamplings=resamplings,
+            callback=callback,
+            second_order=True,
+            hessian_delay=hessian_delay,
+            lse_solver=lse_solver,
+            regularization=regularization,
+            perturbation_dims=perturbation_dims,
+            initial_hessian=initial_hessian,
+            termination_checker=termination_checker,
+        )
+
+        self.fidelity = fidelity
+
+    def _point_sample(self, loss, x, eps, delta1, delta2):
+        loss_points = [x + eps * delta1, x - eps * delta1]
+        fidelity_points = [
+            (x, x + eps * delta1),
+            (x, x - eps * delta1),
+            (x, x + eps * (delta1 + delta2)),
+            (x, x + eps * (-delta1 + delta2)),
+        ]
+        self._nfev += 6
+
+        loss_values = _batch_evaluate(loss, loss_points, self._max_evals_grouped)
+        fidelity_values = _batch_evaluate(
+            self.fidelity, fidelity_points, self._max_evals_grouped, unpack_points=True
+        )
+
+        # compute the gradient approximation and additionally return the loss function evaluations
+        gradient_estimate = (loss_values[0] - loss_values[1]) / (2 * eps) * delta1
+
+        # compute the preconditioner point estimate
+        fidelity_values = np.asarray(fidelity_values, dtype=float)
+        diff = fidelity_values[2] - fidelity_values[0]
+        diff = diff - (fidelity_values[3] - fidelity_values[1])
+        diff = diff / (2 * eps**2)
+
+        rank_one = np.outer(delta1, delta2)
+        # -0.5 factor comes from the fact that we need -0.5 * fidelity
+        hessian_estimate = -0.5 * diff * (rank_one + rank_one.T) / 2
+
+        return np.mean(loss_values), gradient_estimate, hessian_estimate
+
+    @property
+    def settings(self) -> dict[str, Any]:
+        """The optimizer settings in a dictionary format."""
+        # re-use serialization from SPSA
+        settings = super().settings
+        settings.update({"fidelity": self.fidelity})
+
+        # remove SPSA-specific arguments not in QNSPSA
+        settings.pop("trust_region")
+        settings.pop("second_order")
+
+        return settings
+
+    @staticmethod
+    def get_fidelity(
+        circuit: QuantumCircuit,
+        *,
+        sampler: BaseSampler | None = None,
+    ) -> Callable[[np.ndarray, np.ndarray], float]:
+        r"""Get a function to compute the fidelity of ``circuit`` with itself.
+
+        Let ``circuit`` be a parameterized quantum circuit performing the operation
+        :math:`U(\theta)` given a set of parameters :math:`\theta`. Then this method returns
+        a function to evaluate
+
+        .. math::
+
+            F(\theta, \phi) = \big|\langle 0 | U^\dagger(\theta) U(\phi) |0\rangle  \big|^2.
+
+        The output of this function can be used as input for the ``fidelity`` to the
+        :class:`~.QNSPSA` optimizer.
+
+        Args:
+            circuit: The circuit preparing the parameterized ansatz.
+            sampler: A sampler primitive to sample from a quantum state.
+
+        Returns:
+            A handle to the function :math:`F`.
+
+        """
+        fid = ComputeUncompute(sampler)
+
+        num_parameters = circuit.num_parameters
+
+        def fidelity(values_x, values_y):
+            values_x = np.reshape(values_x, (-1, num_parameters)).tolist()
+            batch_size_x = len(values_x)
+
+            values_y = np.reshape(values_y, (-1, num_parameters)).tolist()
+            batch_size_y = len(values_y)
+
+            result = fid.run(
+                batch_size_x * [circuit], batch_size_y * [circuit], values_x, values_y
+            ).result()
+            return np.asarray(result.fidelities)
+
+        return fidelity
diff --git a/qiskit_machine_learning/optimizers/scipy_optimizer.py b/qiskit_machine_learning/optimizers/scipy_optimizer.py
new file mode 100644
index 000000000..631649863
--- /dev/null
+++ b/qiskit_machine_learning/optimizers/scipy_optimizer.py
@@ -0,0 +1,177 @@
+# This code is part of a Qiskit project.
+#
+# (C) Copyright IBM 2018, 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
+# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
+#
+# Any modifications or derivative works of this code must retain this
+# copyright notice, and modified files need to carry a notice indicating
+# that they have been altered from the originals.
+
+"""Wrapper class of scipy.optimize.minimize."""
+from __future__ import annotations
+
+from collections.abc import Callable
+from typing import Any
+
+import numpy as np
+from scipy.optimize import minimize
+
+from ..utils.validation import validate_min
+from .optimizer import Optimizer, OptimizerSupportLevel, OptimizerResult, POINT
+
+
+class SciPyOptimizer(Optimizer):
+    """A general Qiskit Optimizer wrapping scipy.optimize.minimize.
+
+    For further detail, please refer to
+    https://docs.scipy.org/doc/scipy/reference/generated/scipy.optimize.minimize.html
+    """
+
+    _bounds_support_methods = {"l-bfgs-b", "tnc", "slsqp", "powell", "trust-constr"}
+    _gradient_support_methods = {
+        "cg",
+        "bfgs",
+        "newton-cg",
+        "l-bfgs-b",
+        "tnc",
+        "slsqp",
+        "dogleg",
+        "trust-ncg",
+        "trust-krylov",
+        "trust-exact",
+        "trust-constr",
+    }
+
+    def __init__(
+        self,
+        method: str | Callable,
+        options: dict[str, Any] | None = None,
+        max_evals_grouped: int = 1,
+        **kwargs,
+    ):
+        """
+        Args:
+            method: Type of solver.
+            options: A dictionary of solver options.
+            kwargs: additional kwargs for scipy.optimize.minimize.
+            max_evals_grouped: Max number of default gradient evaluations performed simultaneously.
+        """
+        self._method = method.lower() if isinstance(method, str) else method
+        # Set support level
+        if self._method in self._bounds_support_methods:
+            self._bounds_support_level = OptimizerSupportLevel.supported
+        else:
+            self._bounds_support_level = OptimizerSupportLevel.ignored
+        if self._method in self._gradient_support_methods:
+            self._gradient_support_level = OptimizerSupportLevel.supported
+        else:
+            self._gradient_support_level = OptimizerSupportLevel.ignored
+        self._initial_point_support_level = OptimizerSupportLevel.required
+
+        self._options = options if options is not None else {}
+        validate_min("max_evals_grouped", max_evals_grouped, 1)
+        self._max_evals_grouped = max_evals_grouped
+        self._kwargs = kwargs
+
+    def get_support_level(self):
+        """Return support level dictionary"""
+        return {
+            "gradient": self._gradient_support_level,
+            "bounds": self._bounds_support_level,
+            "initial_point": self._initial_point_support_level,
+        }
+
+    @property
+    def settings(self) -> dict[str, Any]:
+        options = self._options.copy()
+        if hasattr(self, "_OPTIONS"):
+            # all _OPTIONS should be keys in self._options, but add a failsafe here
+            attributes = [
+                option
+                for option in self._OPTIONS  # pylint: disable=no-member
+                if option in options.keys()
+            ]
+
+            settings = {attr: options.pop(attr) for attr in attributes}
+        else:
+            settings = {}
+
+        settings["max_evals_grouped"] = self._max_evals_grouped
+        settings["options"] = options
+        settings.update(self._kwargs)
+
+        # the subclasses don't need the "method" key as the class type specifies the method
+        if self.__class__ == SciPyOptimizer:
+            settings["method"] = self._method
+
+        return settings
+
+    def minimize(
+        self,
+        fun: Callable[[POINT], float],
+        x0: POINT,
+        jac: Callable[[POINT], POINT] | None = None,
+        bounds: list[tuple[float, float]] | None = None,
+    ) -> OptimizerResult:
+        # Remove ignored parameters to suppress the warning of scipy.optimize.minimize
+        if self.is_bounds_ignored:
+            bounds = None
+        if self.is_gradient_ignored:
+            jac = None
+
+        if self.is_gradient_supported and jac is None and self._max_evals_grouped > 1:
+            if "eps" in self._options:
+                epsilon = self._options["eps"]
+            else:
+                epsilon = (
+                    1e-8 if self._method in {"l-bfgs-b", "tnc"} else np.sqrt(np.finfo(float).eps)
+                )
+            jac = Optimizer.wrap_function(
+                Optimizer.gradient_num_diff, (fun, epsilon, self._max_evals_grouped)
+            )
+
+        # Workaround for L_BFGS_B because it does not accept np.ndarray.
+        # See https://github.com/Qiskit/qiskit/pull/6373.
+        if jac is not None and self._method == "l-bfgs-b":
+            jac = self._wrap_gradient(jac)
+
+        # Starting in scipy 1.9.0 maxiter is deprecated and maxfun (added in 1.5.0)
+        # should be used instead
+        swapped_deprecated_args = False
+        if self._method == "tnc" and "maxiter" in self._options:
+            swapped_deprecated_args = True
+            self._options["maxfun"] = self._options.pop("maxiter")
+
+        raw_result = minimize(
+            fun=fun,
+            x0=x0,
+            method=self._method,
+            jac=jac,
+            bounds=bounds,
+            options=self._options,
+            **self._kwargs,
+        )
+        if swapped_deprecated_args:
+            self._options["maxiter"] = self._options.pop("maxfun")
+
+        result = OptimizerResult()
+        result.x = raw_result.x
+        result.fun = raw_result.fun
+        result.nfev = raw_result.nfev
+        result.njev = raw_result.get("njev", None)
+        result.nit = raw_result.get("nit", None)
+
+        return result
+
+    @staticmethod
+    def _wrap_gradient(gradient_function):
+        def wrapped_gradient(x):
+            gradient = gradient_function(x)
+            if isinstance(gradient, np.ndarray):
+                return gradient.tolist()
+            return gradient
+
+        return wrapped_gradient
diff --git a/qiskit_machine_learning/optimizers/slsqp.py b/qiskit_machine_learning/optimizers/slsqp.py
new file mode 100644
index 000000000..facbfdbce
--- /dev/null
+++ b/qiskit_machine_learning/optimizers/slsqp.py
@@ -0,0 +1,73 @@
+# This code is part of a Qiskit project.
+#
+# (C) Copyright IBM 2018, 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
+# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
+#
+# Any modifications or derivative works of this code must retain this
+# copyright notice, and modified files need to carry a notice indicating
+# that they have been altered from the originals.
+
+"""Sequential Least SQuares Programming optimizer"""
+from __future__ import annotations
+
+
+from .scipy_optimizer import SciPyOptimizer
+
+
+class SLSQP(SciPyOptimizer):
+    """
+    Sequential Least SQuares Programming optimizer.
+
+    SLSQP minimizes a function of several variables with any combination of bounds, equality
+    and inequality constraints. The method wraps the SLSQP Optimization subroutine originally
+    implemented by Dieter Kraft.
+
+    SLSQP is ideal for mathematical problems for which the objective function and the constraints
+    are twice continuously differentiable. Note that the wrapper handles infinite values in bounds
+    by converting them into large floating values.
+
+    Uses scipy.optimize.minimize SLSQP.
+    For further detail, please refer to
+    See https://docs.scipy.org/doc/scipy/reference/generated/scipy.optimize.minimize.html
+    """
+
+    _OPTIONS = ["maxiter", "disp", "ftol", "eps"]
+
+    # pylint: disable=unused-argument
+    def __init__(
+        self,
+        maxiter: int = 100,
+        disp: bool = False,
+        ftol: float = 1e-06,
+        tol: float | None = None,
+        eps: float = 1.4901161193847656e-08,
+        options: dict | None = None,
+        max_evals_grouped: int = 1,
+        **kwargs,
+    ) -> None:
+        """
+        Args:
+            maxiter: Maximum number of iterations.
+            disp: Set to True to print convergence messages.
+            ftol: Precision goal for the value of f in the stopping criterion.
+            tol: Tolerance for termination.
+            eps: Step size used for numerical approximation of the Jacobian.
+            options: A dictionary of solver options.
+            max_evals_grouped: Max number of default gradient evaluations performed simultaneously.
+            kwargs: additional kwargs for scipy.optimize.minimize.
+        """
+        if options is None:
+            options = {}
+        for k, v in list(locals().items()):
+            if k in self._OPTIONS:
+                options[k] = v
+        super().__init__(
+            "SLSQP",
+            options=options,
+            tol=tol,
+            max_evals_grouped=max_evals_grouped,
+            **kwargs,
+        )
diff --git a/qiskit_machine_learning/optimizers/spsa.py b/qiskit_machine_learning/optimizers/spsa.py
new file mode 100644
index 000000000..c6579811e
--- /dev/null
+++ b/qiskit_machine_learning/optimizers/spsa.py
@@ -0,0 +1,771 @@
+# This code is part of a Qiskit project.
+#
+# (C) Copyright IBM 2018, 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
+# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
+#
+# Any modifications or derivative works of this code must retain this
+# copyright notice, and modified files need to carry a notice indicating
+# that they have been altered from the originals.
+
+"""Simultaneous Perturbation Stochastic Approximation (SPSA) optimizer.
+
+This implementation allows both standard first-order and second-order SPSA.
+"""
+from __future__ import annotations
+
+from collections import deque
+from collections.abc import Iterator
+from typing import Callable, Any, SupportsFloat
+import logging
+import warnings
+from time import time
+
+import scipy
+import numpy as np
+
+from ..utils import algorithm_globals
+
+from .optimizer import Optimizer, OptimizerSupportLevel, OptimizerResult, POINT
+
+# number of function evaluations, parameters, loss, stepsize, accepted
+CALLBACK = Callable[[int, np.ndarray, float, SupportsFloat, bool], None]
+TERMINATIONCHECKER = Callable[[int, np.ndarray, float, SupportsFloat, bool], bool]
+
+logger = logging.getLogger(__name__)
+
+
+class SPSA(Optimizer):
+    """Simultaneous Perturbation Stochastic Approximation (SPSA) optimizer.
+
+    SPSA [1] is an gradient descent method for optimizing systems with multiple unknown parameters.
+    As an optimization method, it is appropriately suited to large-scale population models,
+    adaptive modeling, and simulation optimization.
+
+    .. seealso::
+
+        Many examples are presented at the `SPSA Web site <http://www.jhuapl.edu/SPSA>`__.
+
+    The main feature of SPSA is the stochastic gradient approximation, which requires only two
+    measurements of the objective function, regardless of the dimension of the optimization
+    problem.
+
+    Additionally, to standard first-order SPSA, where only gradient information is used, this
+    implementation also allows second-order SPSA (2-SPSA) [2]. In 2-SPSA we also estimate the
+    Hessian of the loss with a stochastic approximation and multiply the gradient with the
+    inverse Hessian to take local curvature into account and improve convergence.
+    Notably this Hessian estimate requires only a constant number of function evaluations
+    unlike an exact evaluation of the Hessian, which scales quadratically in the number of
+    function evaluations.
+
+    .. note::
+
+        SPSA can be used in the presence of noise, and it is therefore indicated in situations
+        involving measurement uncertainty on a quantum computation when finding a minimum.
+        If you are executing a variational algorithm using a Quantum ASseMbly Language (QASM)
+        simulator or a real device, SPSA would be the most recommended choice among the optimizers
+        provided here.
+
+    The optimization process can include a calibration phase if neither the ``learning_rate`` nor
+    ``perturbation`` is provided, which requires additional functional evaluations.
+    (Note that either both or none must be set.) For further details on the automatic calibration,
+    please refer to the supplementary information section IV. of [3].
+
+    .. note::
+
+        This component has some function that is normally random. If you want to reproduce behavior
+        then you should set the random number generator seed in the algorithm_globals
+        (``qiskit_machine_learning.utils.algorithm_globals.random_seed = seed``).
+
+
+    Examples:
+
+        This short example runs SPSA for the ground state calculation of the ``Z ^ Z``
+        observable where the ansatz is a ``PauliTwoDesign`` circuit.
+
+        .. code-block:: python
+
+            import numpy as np
+            from qiskit_machine_learning.optimizers import SPSA
+            from qiskit.circuit.library import PauliTwoDesign
+            from qiskit.primitives import Estimator
+            from qiskit.quantum_info import SparsePauliOp
+
+            ansatz = PauliTwoDesign(2, reps=1, seed=2)
+            observable = SparsePauliOp("ZZ")
+            initial_point = np.random.random(ansatz.num_parameters)
+            estimator = Estimator()
+
+            def loss(x):
+                job = estimator.run([ansatz], [observable], [x])
+                return job.result().values[0]
+
+            spsa = SPSA(maxiter=300)
+            result = spsa.minimize(loss, x0=initial_point)
+
+        To use the Hessian information, i.e. 2-SPSA, you can add `second_order=True` to the
+        initializer of the `SPSA` class, the rest of the code remains the same.
+
+        .. code-block:: python
+
+            two_spsa = SPSA(maxiter=300, second_order=True)
+            result = two_spsa.minimize(loss, x0=initial_point)
+
+        The `termination_checker` can be used to implement a custom termination criterion.
+
+        .. code-block:: python
+
+            import numpy as np
+            from qiskit_machine_learning.optimizers import SPSA
+
+            def objective(x):
+                return np.linalg.norm(x) + .04*np.random.rand(1)
+
+            class TerminationChecker:
+
+                def __init__(self, N : int):
+                    self.N = N
+                    self.values = []
+
+                def __call__(self, nfev, parameters, value, stepsize, accepted) -> bool:
+                    self.values.append(value)
+
+                    if len(self.values) > self.N:
+                        last_values = self.values[-self.N:]
+                        pp = np.polyfit(range(self.N), last_values, 1)
+                        slope = pp[0] / self.N
+
+                        if slope > 0:
+                            return True
+                    return False
+
+            spsa = SPSA(maxiter=200, termination_checker=TerminationChecker(10))
+            result = spsa.minimize(objective, x0=[0.5, 0.5])
+            print(f'SPSA completed after {result.nit} iterations')
+
+    References:
+
+        [1]: J. C. Spall (1998). An Overview of the Simultaneous Perturbation Method for Efficient
+        Optimization, Johns Hopkins APL Technical Digest, 19(4), 482–492.
+        `Online at jhuapl.edu. <https://www.jhuapl.edu/SPSA/PDF-SPSA/Spall_An_Overview.PDF>`_
+
+        [2]: J. C. Spall (1997). Accelerated second-order stochastic optimization using only
+        function measurements, Proceedings of the 36th IEEE Conference on Decision and Control,
+        1417-1424 vol.2. `Online at IEEE.org. <https://ieeexplore.ieee.org/document/657661>`_
+
+        [3]: A. Kandala et al. (2017). Hardware-efficient Variational Quantum Eigensolver for
+        Small Molecules and Quantum Magnets. Nature 549, pages242–246(2017).
+        `arXiv:1704.05018v2 <https://arxiv.org/pdf/1704.05018v2.pdf#section*.11>`_
+
+    """
+
+    def __init__(
+        self,
+        maxiter: int = 100,
+        blocking: bool = False,
+        allowed_increase: float | None = None,
+        trust_region: bool = False,
+        learning_rate: float | np.ndarray | Callable[[], Iterator] | None = None,
+        perturbation: float | np.ndarray | Callable[[], Iterator] | None = None,
+        last_avg: int = 1,
+        resamplings: int | dict[int, int] = 1,
+        perturbation_dims: int | None = None,
+        second_order: bool = False,
+        regularization: float | None = None,
+        hessian_delay: int = 0,
+        lse_solver: Callable[[np.ndarray, np.ndarray], np.ndarray] | None = None,
+        initial_hessian: np.ndarray | None = None,
+        callback: CALLBACK | None = None,
+        termination_checker: TERMINATIONCHECKER | None = None,
+    ) -> None:
+        r"""
+        Args:
+            maxiter: The maximum number of iterations. Note that this is not the maximal number
+                of function evaluations.
+            blocking: If True, only accepts updates that improve the loss (up to some allowed
+                increase, see next argument).
+            allowed_increase: If ``blocking`` is ``True``, this argument determines by how much
+                the loss can increase with the proposed parameters and still be accepted.
+                If ``None``, the allowed increases is calibrated automatically to be twice the
+                approximated standard deviation of the loss function.
+            trust_region: If ``True``, restricts the norm of the update step to be :math:`\leq 1`.
+            learning_rate: The update step is the learning rate is multiplied with the gradient.
+                If the learning rate is a float, it remains constant over the course of the
+                optimization. If a NumPy array, the :math:`i`-th element is the learning rate for
+                the :math:`i`-th iteration. It can also be a callable returning an iterator which
+                yields the learning rates for each optimization step.
+                If ``learning_rate`` is set ``perturbation`` must also be provided.
+            perturbation: Specifies the magnitude of the perturbation for the finite difference
+                approximation of the gradients. See ``learning_rate`` for the supported types.
+                If ``perturbation`` is set ``learning_rate`` must also be provided.
+            last_avg: Return the average of the ``last_avg`` parameters instead of just the
+                last parameter values.
+            resamplings: The number of times the gradient (and Hessian) is sampled using a random
+                direction to construct a gradient estimate. Per default the gradient is estimated
+                using only one random direction. If an integer, all iterations use the same number
+                of resamplings. If a dictionary, this is interpreted as
+                ``{iteration: number of resamplings per iteration}``.
+            perturbation_dims: The number of perturbed dimensions. Per default, all dimensions
+                are perturbed, but a smaller, fixed number can be perturbed. If set, the perturbed
+                dimensions are chosen uniformly at random.
+            second_order: If True, use 2-SPSA instead of SPSA. In 2-SPSA, the Hessian is estimated
+                additionally to the gradient, and the gradient is preconditioned with the inverse
+                of the Hessian to improve convergence.
+            regularization: To ensure the preconditioner is symmetric and positive definite, the
+                identity times a small coefficient is added to it. This generator yields that
+                coefficient.
+            hessian_delay: Start multiplying the gradient with the inverse Hessian only after a
+                certain number of iterations. The Hessian is still evaluated and therefore this
+                argument can be useful to first get a stable average over the last iterations before
+                using it as preconditioner.
+            lse_solver: The method to solve for the inverse of the Hessian. Per default an
+                exact LSE solver is used, but can e.g. be overwritten by a minimization routine.
+            initial_hessian: The initial guess for the Hessian. By default the identity matrix
+                is used.
+            callback: A callback function passed information in each iteration step. The
+                information is, in this order: the number of function evaluations, the parameters,
+                the function value, the stepsize, whether the step was accepted.
+            termination_checker: A callback function executed at the end of each iteration step. The
+                arguments are, in this order: the parameters, the function value, the number
+                of function evaluations, the stepsize, whether the step was accepted. If the callback
+                returns True, the optimization is terminated.
+                To prevent additional evaluations of the objective method, if the objective has not yet
+                been evaluated, the objective is estimated by taking the mean of the objective
+                evaluations used in the estimate of the gradient.
+
+
+        Raises:
+            ValueError: If ``learning_rate`` or ``perturbation`` is an array with less elements
+                than the number of iterations.
+
+
+        """
+        super().__init__()
+
+        # general optimizer arguments
+        self.maxiter = maxiter
+        self.trust_region = trust_region
+        self.callback = callback
+        self.termination_checker = termination_checker
+
+        # if learning rate and perturbation are arrays, check they are sufficiently long
+        for attr, name in zip([learning_rate, perturbation], ["learning_rate", "perturbation"]):
+            if isinstance(attr, (list, np.ndarray)):
+                if len(attr) < maxiter:
+                    raise ValueError(f"Length of {name} is smaller than maxiter ({maxiter}).")
+
+        self.learning_rate = learning_rate
+        self.perturbation = perturbation
+
+        # SPSA specific arguments
+        self.blocking = blocking
+        self.allowed_increase = allowed_increase
+        self.last_avg = last_avg
+        self.resamplings = resamplings
+        self.perturbation_dims = perturbation_dims
+
+        # 2-SPSA specific arguments
+        if regularization is None:
+            regularization = 0.01
+
+        self.second_order = second_order
+        self.hessian_delay = hessian_delay
+        self.lse_solver = lse_solver
+        self.regularization = regularization
+        self.initial_hessian = initial_hessian
+
+        # runtime arguments
+        self._nfev: int | None = None  # the number of function evaluations
+        self._smoothed_hessian: np.ndarray | None = None  # smoothed average of the Hessians
+
+    @staticmethod
+    def calibrate(
+        loss: Callable[[np.ndarray], float],
+        initial_point: np.ndarray,
+        c: float = 0.2,
+        stability_constant: float = 0,
+        target_magnitude: float | None = None,  # 2 pi / 10
+        alpha: float = 0.602,
+        gamma: float = 0.101,
+        modelspace: bool = False,
+        max_evals_grouped: int = 1,
+    ) -> tuple[Callable, Callable]:
+        r"""Calibrate SPSA parameters with a power series as learning rate and perturbation coeffs.
+
+        The power series are:
+
+        .. math::
+
+            a_k = \frac{a}{(A + k + 1)^\alpha}, c_k = \frac{c}{(k + 1)^\gamma}
+
+        Args:
+            loss: The loss function.
+            initial_point: The initial guess of the iteration.
+            c: The initial perturbation magnitude.
+            stability_constant: The value of `A`.
+            target_magnitude: The target magnitude for the first update step, defaults to
+                :math:`2\pi / 10`.
+            alpha: The exponent of the learning rate power series.
+            gamma: The exponent of the perturbation power series.
+            modelspace: Whether the target magnitude is the difference of parameter values
+                or function values (= model space).
+            max_evals_grouped: The number of grouped evaluations supported by the loss function.
+                Defaults to 1, i.e. no grouping.
+
+        Returns:
+            tuple(generator, generator): A tuple of power series generators, the first one for the
+                learning rate and the second one for the perturbation.
+        """
+        logger.info("SPSA: Starting calibration of learning rate and perturbation.")
+        if target_magnitude is None:
+            target_magnitude = 2 * np.pi / 10
+
+        dim = len(initial_point)
+
+        # compute the average magnitude of the first step
+        steps = 25
+        points = []
+        for _ in range(steps):
+            # compute the random direction
+            pert = bernoulli_perturbation(dim)
+            points += [initial_point + c * pert, initial_point - c * pert]
+
+        losses = _batch_evaluate(loss, points, max_evals_grouped)
+
+        avg_magnitudes = 0.0
+        for i in range(steps):
+            delta = losses[2 * i] - losses[2 * i + 1]
+            avg_magnitudes += np.abs(delta / (2 * c))
+
+        avg_magnitudes /= steps
+
+        if modelspace:
+            a = target_magnitude / (avg_magnitudes**2)
+        else:
+            a = target_magnitude / avg_magnitudes
+
+        # compute the rescaling factor for correct first learning rate
+        if a < 1e-10:
+            warnings.warn(f"Calibration failed, using {target_magnitude} for `a`")
+            a = target_magnitude
+
+        logger.info("Finished calibration:")
+        logger.info(
+            " -- Learning rate: a / ((A + n) ^ alpha) with a = %s, A = %s, alpha = %s",
+            a,
+            stability_constant,
+            alpha,
+        )
+        logger.info(" -- Perturbation: c / (n ^ gamma) with c = %s, gamma = %s", c, gamma)
+
+        # set up the power series
+        def learning_rate():
+            return powerseries(a, alpha, stability_constant)
+
+        def perturbation():
+            return powerseries(c, gamma)
+
+        return learning_rate, perturbation
+
+    @staticmethod
+    def estimate_stddev(
+        loss: Callable[[np.ndarray], float],
+        initial_point: np.ndarray,
+        avg: int = 25,
+        max_evals_grouped: int = 1,
+    ) -> float:
+        """Estimate the standard deviation of the loss function."""
+        losses = _batch_evaluate(loss, avg * [initial_point], max_evals_grouped)
+        return np.std(losses)
+
+    @property
+    def settings(self) -> dict[str, Any]:
+        # if learning rate or perturbation are custom iterators expand them
+        if callable(self.learning_rate):
+            iterator = self.learning_rate()
+            learning_rate = np.array([next(iterator) for _ in range(self.maxiter)])
+        else:
+            learning_rate = self.learning_rate  # type: ignore[assignment]
+
+        if callable(self.perturbation):
+            iterator = self.perturbation()
+            perturbation = np.array([next(iterator) for _ in range(self.maxiter)])
+        else:
+            perturbation = self.perturbation  # type: ignore[assignment]
+
+        return {
+            "maxiter": self.maxiter,
+            "learning_rate": learning_rate,
+            "perturbation": perturbation,
+            "trust_region": self.trust_region,
+            "blocking": self.blocking,
+            "allowed_increase": self.allowed_increase,
+            "resamplings": self.resamplings,
+            "perturbation_dims": self.perturbation_dims,
+            "second_order": self.second_order,
+            "hessian_delay": self.hessian_delay,
+            "regularization": self.regularization,
+            "lse_solver": self.lse_solver,
+            "initial_hessian": self.initial_hessian,
+            "callback": self.callback,
+            "termination_checker": self.termination_checker,
+        }
+
+    def _point_sample(self, loss, x, eps, delta1, delta2):
+        """A single sample of the gradient at position ``x`` in direction ``delta``."""
+        # points to evaluate
+        points = [x + eps * delta1, x - eps * delta1]
+        self._nfev += 2
+
+        if self.second_order:
+            points += [x + eps * (delta1 + delta2), x + eps * (-delta1 + delta2)]
+            self._nfev += 2
+
+        # batch evaluate the points (if possible)
+        values = _batch_evaluate(loss, points, self._max_evals_grouped)
+
+        plus = values[0]
+        minus = values[1]
+        gradient_sample = (plus - minus) / (2 * eps) * delta1
+
+        hessian_sample = None
+        if self.second_order:
+            diff = (values[2] - plus) - (values[3] - minus)
+            diff /= 2 * eps**2
+
+            rank_one = np.outer(delta1, delta2)
+            hessian_sample = diff * (rank_one + rank_one.T) / 2
+
+        return np.mean(values), gradient_sample, hessian_sample
+
+    def _point_estimate(self, loss, x, eps, num_samples):
+        """The gradient estimate at point x."""
+        # set up variables to store averages
+        value_estimate = 0
+        gradient_estimate = np.zeros(x.size)
+        hessian_estimate = np.zeros((x.size, x.size))
+
+        # iterate over the directions
+        deltas1 = [
+            bernoulli_perturbation(x.size, self.perturbation_dims) for _ in range(num_samples)
+        ]
+
+        if self.second_order:
+            deltas2 = [
+                bernoulli_perturbation(x.size, self.perturbation_dims) for _ in range(num_samples)
+            ]
+        else:
+            deltas2 = None
+
+        for i in range(num_samples):
+            delta1 = deltas1[i]
+            delta2 = deltas2[i] if self.second_order else None
+
+            value_sample, gradient_sample, hessian_sample = self._point_sample(
+                loss, x, eps, delta1, delta2
+            )
+            value_estimate += value_sample
+            gradient_estimate += gradient_sample
+
+            if self.second_order:
+                hessian_estimate += hessian_sample
+
+        return (
+            value_estimate / num_samples,
+            gradient_estimate / num_samples,
+            hessian_estimate / num_samples,
+        )
+
+    def _compute_update(self, loss, x, k, eps, lse_solver):
+        # compute the perturbations
+        if isinstance(self.resamplings, dict):
+            num_samples = self.resamplings.get(k, 1)
+        else:
+            num_samples = self.resamplings
+
+        # accumulate the number of samples
+        value, gradient, hessian = self._point_estimate(loss, x, eps, num_samples)
+
+        # precondition gradient with inverse Hessian, if specified
+        if self.second_order:
+            smoothed = k / (k + 1) * self._smoothed_hessian + 1 / (k + 1) * hessian
+            self._smoothed_hessian = smoothed
+
+            if k > self.hessian_delay:
+                spd_hessian = _make_spd(smoothed, self.regularization)
+
+                # solve for the gradient update
+                gradient = np.real(lse_solver(spd_hessian, gradient))
+
+        return value, gradient
+
+    def minimize(
+        self,
+        fun: Callable[[POINT], float],
+        x0: POINT,
+        jac: Callable[[POINT], POINT] | None = None,
+        bounds: list[tuple[float, float]] | None = None,
+    ) -> OptimizerResult:
+        # ensure learning rate and perturbation are correctly set: either none or both
+        # this happens only here because for the calibration the loss function is required
+        x0 = np.asarray(x0)
+        if self.learning_rate is None and self.perturbation is None:
+            get_eta, get_eps = self.calibrate(fun, x0, max_evals_grouped=self._max_evals_grouped)
+        else:
+            get_eta, get_eps = _validate_pert_and_learningrate(
+                self.perturbation, self.learning_rate
+            )
+        eta, eps = get_eta(), get_eps()
+
+        lse_solver = self.lse_solver
+        if self.lse_solver is None:
+            lse_solver = np.linalg.solve
+
+        # prepare some initials
+        x = np.asarray(x0)
+        if self.initial_hessian is None:
+            self._smoothed_hessian = np.identity(x.size)
+        else:
+            self._smoothed_hessian = self.initial_hessian
+
+        self._nfev = 0
+
+        # if blocking is enabled we need to keep track of the function values
+        if self.blocking:
+            fx = fun(x)  # pylint: disable=invalid-name
+
+            self._nfev += 1
+            if self.allowed_increase is None:
+                self.allowed_increase = 2 * self.estimate_stddev(
+                    fun, x, max_evals_grouped=self._max_evals_grouped
+                )
+
+        logger.info("SPSA: Starting optimization.")
+        start = time()
+
+        # keep track of the last few steps to return their average
+        last_steps = deque([x])
+
+        # use a local variable and while loop to keep track of the number of iterations
+        # if the termination checker terminates early
+        k = 0
+        while k < self.maxiter:
+            k += 1
+            iteration_start = time()
+            # compute update
+            fx_estimate, update = self._compute_update(fun, x, k, next(eps), lse_solver)
+
+            # trust region
+            if self.trust_region:
+                norm = np.linalg.norm(update)
+                if norm > 1:  # stop from dividing by 0
+                    update = update / norm
+
+            # compute next parameter value
+            update = update * next(eta)
+            x_next = x - update
+            fx_next = None
+
+            # blocking
+            if self.blocking:
+                self._nfev += 1
+                fx_next = fun(x_next)
+
+                if fx + self.allowed_increase <= fx_next:  # accept only if loss improved
+                    if self.callback is not None:
+                        self.callback(
+                            self._nfev,  # number of function evals
+                            x_next,  # next parameters
+                            fx_next,  # loss at next parameters
+                            np.linalg.norm(update),  # size of the update step
+                            False,
+                        )  # not accepted
+
+                    logger.info(
+                        "Iteration %s/%s rejected in %s.",
+                        k,
+                        self.maxiter + 1,
+                        time() - iteration_start,
+                    )
+                    continue
+                fx = fx_next  # pylint: disable=invalid-name
+
+            logger.info(
+                "Iteration %s/%s done in %s.", k, self.maxiter + 1, time() - iteration_start
+            )
+
+            if self.callback is not None:
+                # if we didn't evaluate the function yet, do it now
+                if not self.blocking:
+                    self._nfev += 1
+                    fx_next = fun(x_next)
+
+                self.callback(
+                    self._nfev,  # number of function evals
+                    x_next,  # next parameters
+                    fx_next,  # loss at next parameters
+                    np.linalg.norm(update),  # size of the update step
+                    True,
+                )  # accepted
+
+            # update parameters
+            x = x_next
+
+            # update the list of the last ``last_avg`` parameters
+            if self.last_avg > 1:
+                last_steps.append(x_next)
+                if len(last_steps) > self.last_avg:
+                    last_steps.popleft()
+
+            if self.termination_checker is not None:
+                fx_check = fx_estimate if fx_next is None else fx_next
+                if self.termination_checker(
+                    self._nfev, x_next, fx_check, np.linalg.norm(update), True
+                ):
+                    logger.info("terminated optimization at {k}/{self.maxiter} iterations")
+                    break
+
+        logger.info("SPSA: Finished in %s", time() - start)
+
+        if self.last_avg > 1:
+            x = np.mean(np.asarray(last_steps), axis=0)
+
+        result = OptimizerResult()
+        result.x = x
+        result.fun = fun(x)
+        result.nfev = self._nfev
+        result.nit = k
+
+        return result
+
+    def get_support_level(self):
+        """Get the support level dictionary."""
+        return {
+            "gradient": OptimizerSupportLevel.ignored,
+            "bounds": OptimizerSupportLevel.ignored,
+            "initial_point": OptimizerSupportLevel.required,
+        }
+
+
+def bernoulli_perturbation(dim, perturbation_dims=None):
+    """Get a Bernoulli random perturbation."""
+    if perturbation_dims is None:
+        return 1 - 2 * algorithm_globals.random.binomial(1, 0.5, size=dim)
+
+    pert = 1 - 2 * algorithm_globals.random.binomial(1, 0.5, size=perturbation_dims)
+    indices = algorithm_globals.random.choice(
+        list(range(dim)), size=perturbation_dims, replace=False
+    )
+    result = np.zeros(dim)
+    result[indices] = pert
+
+    return result
+
+
+def powerseries(eta=0.01, power=2, offset=0):
+    """Yield a series decreasing by a power law."""
+
+    n = 1
+    while True:
+        yield eta / ((n + offset) ** power)
+        n += 1
+
+
+def constant(eta=0.01):
+    """Yield a constant series."""
+
+    while True:
+        yield eta
+
+
+def _batch_evaluate(function, points, max_evals_grouped, unpack_points=False):
+    """Evaluate a function on all points with batches of max_evals_grouped.
+
+    The points are a list of inputs, as ``[in1, in2, in3, ...]``. If the individual
+    inputs are tuples (because the function takes multiple inputs), set ``unpack_points`` to ``True``.
+    """
+
+    # if the function cannot handle lists of points as input, cover this case immediately
+    if max_evals_grouped is None or max_evals_grouped == 1:
+        # support functions with multiple arguments where the points are given in a tuple
+        return [
+            function(*point) if isinstance(point, tuple) else function(point) for point in points
+        ]
+
+    num_points = len(points)
+
+    # get the number of batches
+    num_batches = num_points // max_evals_grouped
+    if num_points % max_evals_grouped != 0:
+        num_batches += 1
+
+    # split the points
+    batched_points = np.array_split(np.asarray(points), num_batches)
+
+    results = []
+    for batch in batched_points:
+        if unpack_points:
+            batch = _repack_points(batch)
+            results += _as_list(function(*batch))
+        else:
+            results += _as_list(function(batch))
+
+    return results
+
+
+def _as_list(obj):
+    """Convert a list or numpy array into a list."""
+    return obj.tolist() if isinstance(obj, np.ndarray) else obj
+
+
+def _repack_points(points):
+    """Turn a list of tuples of points into a tuple of lists of points.
+    E.g. turns
+        [(a1, a2, a3), (b1, b2, b3)]
+    into
+        ([a1, b1], [a2, b2], [a3, b3])
+    where all elements are np.ndarray.
+    """
+    num_sets = len(points[0])  # length of (a1, a2, a3)
+    return ([x[i] for x in points] for i in range(num_sets))
+
+
+def _make_spd(matrix, bias=0.01):
+    identity = np.identity(matrix.shape[0])
+    psd = scipy.linalg.sqrtm(matrix.dot(matrix))
+    return psd + bias * identity
+
+
+def _validate_pert_and_learningrate(perturbation, learning_rate):
+    if learning_rate is None or perturbation is None:
+        raise ValueError("If one of learning rate or perturbation is set, both must be set.")
+
+    if isinstance(perturbation, float):
+
+        def get_eps():
+            return constant(perturbation)
+
+    elif isinstance(perturbation, (list, np.ndarray)):
+
+        def get_eps():
+            return iter(perturbation)
+
+    else:
+        get_eps = perturbation
+
+    if isinstance(learning_rate, float):
+
+        def get_eta():
+            return constant(learning_rate)
+
+    elif isinstance(learning_rate, (list, np.ndarray)):
+
+        def get_eta():
+            return iter(learning_rate)
+
+    else:
+        get_eta = learning_rate
+
+    return get_eta, get_eps
diff --git a/qiskit_machine_learning/optimizers/steppable_optimizer.py b/qiskit_machine_learning/optimizers/steppable_optimizer.py
new file mode 100644
index 000000000..bab6ec4a4
--- /dev/null
+++ b/qiskit_machine_learning/optimizers/steppable_optimizer.py
@@ -0,0 +1,303 @@
+# This code is part of a Qiskit project.
+#
+# (C) Copyright IBM 2022, 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
+# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
+#
+# Any modifications or derivative works of this code must retain this
+# copyright notice, and modified files need to carry a notice indicating
+# that they have been altered from the originals.
+
+"""SteppableOptimizer interface"""
+from __future__ import annotations
+
+from abc import abstractmethod, ABC
+from collections.abc import Callable
+from dataclasses import dataclass
+from .optimizer import Optimizer, POINT, OptimizerResult
+
+
+@dataclass
+class AskData(ABC):
+    """Base class for return type of :meth:`~.SteppableOptimizer.ask`.
+
+    Args:
+        x_fun: Point or list of points where the function needs to be evaluated to compute the next
+        state of the optimizer.
+        x_jac: Point or list of points where the gradient/jacobian needs to be evaluated to compute
+        the next state of the optimizer.
+
+    """
+
+    x_fun: POINT | list[POINT] | None = None
+    x_jac: POINT | list[POINT] | None = None
+
+
+@dataclass
+class TellData(ABC):
+    """Base class for argument type of :meth:`~.SteppableOptimizer.tell`.
+
+    Args:
+        eval_fun: Image of the function at :attr:`~.ask_data.x_fun`.
+        eval_jac: Image of the gradient-jacobian at :attr:`~.ask_data.x_jac`.
+
+    """
+
+    eval_fun: float | list[float] | None = None
+    eval_jac: POINT | list[POINT] | None = None
+
+
+@dataclass
+class OptimizerState:
+    """Base class representing the state of the optimizer.
+
+    This class stores the current state of the optimizer, given by the current point and
+    (optionally) information like the function value, the gradient or the number of
+    function evaluations. This dataclass can also store any other individual variables that
+    change during the optimization.
+
+    """
+
+    x: POINT
+    """Current optimization parameters."""
+    fun: Callable[[POINT], float] | None
+    """Function being  optimized."""
+    jac: Callable[[POINT], POINT] | None
+    """Jacobian of the function being optimized."""
+    nfev: int | None
+    """Number of function evaluations so far in the optimization."""
+    njev: int | None
+    """Number of jacobian evaluations so far in the optimization."""
+    nit: int | None
+    """Number of optimization steps performed so far in the optimization."""
+
+
+class SteppableOptimizer(Optimizer):
+    """
+    Base class for a steppable optimizer.
+
+    This family of optimizers uses the `ask and tell interface
+    <https://optuna.readthedocs.io/en/stable/tutorial/20_recipes/009_ask_and_tell.html>`_.
+    When using this interface the user has to call :meth:`~.ask` to get information about
+    how to evaluate the function (we are asking the optimizer about how to do the evaluation).
+    This information is typically the next points at which the function is evaluated, but depending
+    on the optimizer it can also determine whether to evaluate the function or its gradient.
+    Once the function has been evaluated, the user calls the method :meth:`~..tell`
+    to tell the optimizer what the result of the function evaluation(s) is. The optimizer then
+    updates its state accordingly and the user can decide whether to stop the optimization process
+    or to repeat a step.
+
+    This interface is more customizable, and allows the user to have full control over the evaluation
+    of the function.
+
+    Examples:
+
+        An example where the evaluation of the function has a chance of failing. The user, with
+        specific knowledge about his function can catch this errors and handle them before passing
+        the result to the optimizer.
+
+        .. code-block:: python
+
+            import random
+            import numpy as np
+            from qiskit_machine_learning.optimizers import GradientDescent
+
+            def objective(x):
+                if random.choice([True, False]):
+                    return None
+                else:
+                    return (np.linalg.norm(x) - 1) ** 2
+
+            def grad(x):
+                if random.choice([True, False]):
+                    return None
+                else:
+                    return 2 * (np.linalg.norm(x) - 1) * x / np.linalg.norm(x)
+
+
+            initial_point = np.random.normal(0, 1, size=(100,))
+
+            optimizer = GradientDescent(maxiter=20)
+            optimizer.start(x0=initial_point, fun=objective, jac=grad)
+
+            while optimizer.continue_condition():
+                ask_data = optimizer.ask()
+                evaluated_gradient = None
+
+                while evaluated_gradient is None:
+                    evaluated_gradient = grad(ask_data.x_center)
+                    optimizer.state.njev += 1
+
+                optimizer.state.nit += 1
+
+                 cf  = TellData(eval_jac=evaluated_gradient)
+                optimizer.tell(ask_data=ask_data, tell_data=tell_data)
+
+            result = optimizer.create_result()
+
+
+    Users that aren't dealing with complicated functions and who are more familiar with step by step
+    optimization algorithms can use the :meth:`~.step` method which wraps the :meth:`~.ask`
+    and :meth:`~.tell` methods. In the same spirit the method :meth:`~.minimize` will optimize the
+    function and return the result.
+
+    To see other libraries that use this interface one can visit:
+    https://optuna.readthedocs.io/en/stable/tutorial/20_recipes/009_ask_and_tell.html
+
+
+    """
+
+    def __init__(
+        self,
+        maxiter: int = 100,
+    ):
+        """
+        Args:
+            maxiter: Number of steps in the optimization process before ending the loop.
+        """
+        super().__init__()
+        self._state: OptimizerState | None = None
+        self.maxiter = maxiter
+
+    @property
+    def state(self) -> OptimizerState:
+        """Return the current state of the optimizer."""
+        return self._state
+
+    @state.setter
+    def state(self, state: OptimizerState) -> None:
+        """Set the current state of the optimizer."""
+        self._state = state
+
+    def ask(self) -> AskData:
+        """Ask the optimizer for a set of points to evaluate.
+
+        This method asks the optimizer which are the next points to evaluate.
+        These points can, e.g., correspond to function values and/or its derivative.
+        It may also correspond to variables that let the user infer which points to evaluate.
+        It is the first method inside of a :meth:`~.step` in the optimization process.
+
+        Returns:
+            An object containing the data needed to make the function evaluation to advance the
+            optimization process.
+
+        """
+        raise NotImplementedError
+
+    def tell(self, ask_data: AskData, tell_data: TellData) -> None:
+        """Updates the optimization state using the results of the function evaluation.
+
+        A canonical optimization example using :meth:`~.ask` and :meth:`~.tell` can be seen
+        in :meth:`~.step`.
+
+        Args:
+            ask_data: Contains the information on how the evaluation was done.
+            tell_data: Contains all relevant information about the evaluation of the objective
+                function.
+        """
+        raise NotImplementedError
+
+    @abstractmethod
+    def evaluate(self, ask_data: AskData) -> TellData:
+        """Evaluates the function according to the instructions contained in :attr:`~.ask_data`.
+
+        If the user decides to use :meth:`~.step` instead of :meth:`~.ask` and :meth:`~.tell`
+        this function will contain the logic on how to evaluate the function.
+
+        Args:
+            ask_data: Contains the information on how to do the evaluation.
+
+        Returns:
+            Data of all relevant information about the function evaluation.
+
+        """
+        raise NotImplementedError
+
+    def _callback_wrapper(self) -> None:
+        """
+        Wraps the callback function to accommodate each optimizer.
+        """
+        pass
+
+    def step(self) -> None:
+        """Performs one step in the optimization process.
+
+        This method composes :meth:`~.ask`, :meth:`~.evaluate`, and :meth:`~.tell` to make a "step"
+        in the optimization process.
+        """
+        ask_data = self.ask()
+        tell_data = self.evaluate(ask_data=ask_data)
+        self.tell(ask_data=ask_data, tell_data=tell_data)
+
+    # pylint: disable=invalid-name
+    @abstractmethod
+    def start(
+        self,
+        fun: Callable[[POINT], float],
+        x0: POINT,
+        jac: Callable[[POINT], POINT] | None = None,
+        bounds: list[tuple[float, float]] | None = None,
+    ) -> None:
+        """Populates the state of the optimizer with the data provided and sets all the counters to 0.
+
+        Args:
+            fun: Function to minimize.
+            x0: Initial point.
+            jac: Function to compute the gradient.
+            bounds: Bounds of the search space.
+
+        """
+        raise NotImplementedError
+
+    def minimize(
+        self,
+        fun: Callable[[POINT], float],
+        x0: POINT,
+        jac: Callable[[POINT], POINT] | None = None,
+        bounds: list[tuple[float, float]] | None = None,
+    ) -> OptimizerResult:
+        """Minimizes the function.
+
+        For well behaved functions the user can call this method to minimize a function.
+        If the user wants more control on how to evaluate the function a custom loop can be
+        created using :meth:`~.ask` and :meth:`~.tell` and evaluating the function manually.
+
+        Args:
+            fun: Function to minimize.
+            x0: Initial point.
+            jac: Function to compute the gradient.
+            bounds: Bounds of the search space.
+
+        Returns:
+            Object containing the result of the optimization.
+
+        """
+        self.start(x0=x0, fun=fun, jac=jac, bounds=bounds)
+        while self.continue_condition():
+            self.step()
+            self._callback_wrapper()
+        return self.create_result()
+
+    @abstractmethod
+    def create_result(self) -> OptimizerResult:
+        """Returns the result of the optimization.
+
+        All the information needed to create such a result should be stored in the optimizer state
+        and will typically contain the best point found, the function value and gradient at that point,
+        the number of function and gradient evaluation and the number of iterations in the optimization.
+
+        Returns:
+            The result of the optimization process.
+
+        """
+        raise NotImplementedError
+
+    def continue_condition(self) -> bool:
+        """Condition that indicates the optimization process should continue.
+
+        Returns:
+            ``True`` if the optimization process should continue, ``False`` otherwise.
+        """
+        return self.state.nit < self.maxiter  # pylint: disable=no-member
diff --git a/qiskit_machine_learning/optimizers/tnc.py b/qiskit_machine_learning/optimizers/tnc.py
new file mode 100644
index 000000000..13d50d29b
--- /dev/null
+++ b/qiskit_machine_learning/optimizers/tnc.py
@@ -0,0 +1,83 @@
+# This code is part of a Qiskit project.
+#
+# (C) Copyright IBM 2018, 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
+# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
+#
+# Any modifications or derivative works of this code must retain this
+# copyright notice, and modified files need to carry a notice indicating
+# that they have been altered from the originals.
+
+"""Truncated Newton (TNC) optimizer."""
+from __future__ import annotations
+
+
+from .scipy_optimizer import SciPyOptimizer
+
+
+class TNC(SciPyOptimizer):
+    """
+    Truncated Newton (TNC) optimizer.
+
+    TNC uses a truncated Newton algorithm to minimize a function with variables subject to bounds.
+    This algorithm uses gradient information; it is also called Newton Conjugate-Gradient.
+    It differs from the :class:`CG` method as it wraps a C implementation and allows each variable
+    to be given upper and lower bounds.
+
+    Uses scipy.optimize.minimize TNC
+    For further detail, please refer to
+    See https://docs.scipy.org/doc/scipy/reference/generated/scipy.optimize.minimize.html
+    """
+
+    _OPTIONS = ["maxiter", "disp", "accuracy", "ftol", "xtol", "gtol", "eps"]
+
+    # pylint: disable=unused-argument
+    def __init__(
+        self,
+        maxiter: int = 100,
+        disp: bool = False,
+        accuracy: float = 0,
+        ftol: float = -1,
+        xtol: float = -1,
+        gtol: float = -1,
+        tol: float | None = None,
+        eps: float = 1e-08,
+        options: dict | None = None,
+        max_evals_grouped: int = 1,
+        **kwargs,
+    ) -> None:
+        """
+        Args:
+            maxiter: Maximum number of function evaluation.
+            disp: Set to True to print convergence messages.
+            accuracy: Relative precision for finite difference calculations.
+                If <= machine_precision, set to sqrt(machine_precision). Defaults to 0.
+            ftol: Precision goal for the value of f in the stopping criterion.
+                If ftol < 0.0, ftol is set to 0.0 defaults to -1.
+            xtol: Precision goal for the value of x in the stopping criterion
+                (after applying x scaling factors).
+                If xtol < 0.0, xtol is set to sqrt(machine_precision). Defaults to -1.
+            gtol: Precision goal for the value of the projected gradient in
+                the stopping criterion (after applying x scaling factors).
+                If gtol < 0.0, gtol is set to 1e-2 * sqrt(accuracy).
+                Setting it to 0.0 is not recommended. Defaults to -1.
+            tol: Tolerance for termination.
+            eps: Step size used for numerical approximation of the Jacobian.
+            options: A dictionary of solver options.
+            max_evals_grouped: Max number of default gradient evaluations performed simultaneously.
+            kwargs: additional kwargs for scipy.optimize.minimize.
+        """
+        if options is None:
+            options = {}
+        for k, v in list(locals().items()):
+            if k in self._OPTIONS:
+                options[k] = v
+        super().__init__(
+            "TNC",
+            options=options,
+            tol=tol,
+            max_evals_grouped=max_evals_grouped,
+            **kwargs,
+        )
diff --git a/qiskit_machine_learning/optimizers/umda.py b/qiskit_machine_learning/optimizers/umda.py
new file mode 100644
index 000000000..55af590cb
--- /dev/null
+++ b/qiskit_machine_learning/optimizers/umda.py
@@ -0,0 +1,347 @@
+# This code is part of a Qiskit project.
+#
+# (C) Copyright IBM 2018, 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
+# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
+#
+# Any modifications or derivative works of this code must retain this
+# copyright notice, and modified files need to carry a notice indicating
+# that they have been altered from the originals.
+
+"""Univariate Marginal Distribution Algorithm (Estimation-of-Distribution-Algorithm)."""
+
+from __future__ import annotations
+
+from collections.abc import Callable
+from typing import Any
+
+import numpy as np
+from scipy.stats import norm
+
+from ..utils import algorithm_globals
+from .optimizer import OptimizerResult, POINT
+from .scipy_optimizer import Optimizer, OptimizerSupportLevel
+
+
+class UMDA(Optimizer):
+    """Continuous Univariate Marginal Distribution Algorithm (UMDA).
+
+    UMDA [1] is a specific type of Estimation of Distribution Algorithm (EDA) where new individuals
+    are sampled from univariate normal distributions and are updated in each iteration of the
+    algorithm by the best individuals found in the previous iteration.
+
+    .. seealso::
+
+        This original implementation of the UDMA optimizer for Qiskit was inspired by my
+        (Vicente P. Soloviev) work on the EDAspy Python package [2].
+
+    EDAs are stochastic search algorithms and belong to the family of the evolutionary algorithms.
+    The main difference is that EDAs have a probabilistic model which is updated in each iteration
+    from the best individuals of previous generations (elite selection). Depending on the complexity
+    of the probabilistic model, EDAs can be classified in different ways. In this case, UMDA is a
+    univariate EDA as the embedded probabilistic model is univariate.
+
+    UMDA has been compared to some of the already implemented algorithms in Qiskit library to
+    optimize the parameters of variational algorithms such as QAOA or VQE and competitive results
+    have been obtained [1]. UMDA seems to provide very good solutions for those circuits in which
+    the number of layers is not big.
+
+    The optimization process can be personalized depending on the parameters chosen in the
+    initialization. The main parameter is the population size. The bigger it is, the final result
+    will be better. However, this increases the complexity of the algorithm and the runtime will
+    be much heavier. In the work [1] different experiments have been performed where population
+    size has been set to 20 - 30.
+
+    .. note::
+
+        The UMDA implementation has more parameters but these have default values for the
+        initialization for better understanding of the user. For example, ``\alpha`` parameter has
+        been set to 0.5 and is the percentage of the population which is selected in each iteration
+        to update the probabilistic model.
+
+
+    Example:
+
+        This short example runs UMDA to optimize the parameters of a variational algorithm. Here we
+        will use the same operator as used in the algorithms introduction, which was originally
+        computed by Qiskit Nature for an H2 molecule. The minimum energy of the H2 Hamiltonian can
+        be found quite easily so we are able to set maxiters to a small value.
+
+        .. code-block:: python
+
+            from qiskit_machine_learning.optimizers import UMDA
+            from qiskit_machine_learning import QAOA
+            from qiskit.quantum_info import Pauli
+            from qiskit.primitives import Sampler
+
+            X = Pauli("X")
+            I = Pauli("I")
+            Z = Pauli("Z")
+
+            H2_op = (-1.052373245772859 * I ^ I) + \
+            (0.39793742484318045 * I ^ Z) + \
+            (-0.39793742484318045 * Z ^ I) + \
+            (-0.01128010425623538 * Z ^ Z) + \
+            (0.18093119978423156 * X ^ X)
+
+            p = 2  # Toy example: 2 layers with 2 parameters in each layer: 4 variables
+
+            opt = UMDA(maxiter=100, size_gen=20)
+            qaoa = QAOA(Sampler(), opt,reps=p)
+            result = qaoa.compute_minimum_eigenvalue(operator=H2_op)
+
+        If it is desired to modify the percentage of individuals considered to update the
+        probabilistic model, then this code can be used. Here for example we set the 60% instead
+        of the 50% predefined.
+
+        .. code-block:: python
+
+            opt = UMDA(maxiter=100, size_gen=20, alpha = 0.6)
+            qaoa = QAOA(Sampler(), opt,reps=p)
+            result = qaoa.compute_minimum_eigenvalue(operator=H2_op)
+
+    .. note::
+
+        This component has some function that is normally random. If you want to reproduce behavior
+        then you should set the random number generator seed in the algorithm_globals
+        (``qiskit_machine_learning.utils.algorithm_globals.random_seed = seed``).
+
+    References:
+
+        [1]: Vicente P. Soloviev, Pedro Larrañaga and Concha Bielza (2022, July). Quantum Parametric
+        Circuit Optimization with Estimation of Distribution Algorithms. In 2022 The Genetic and
+        Evolutionary Computation Conference (GECCO). DOI: https://doi.org/10.1145/3520304.3533963
+
+        [2]: Vicente P. Soloviev. Python package EDAspy.
+        https://github.com/VicentePerezSoloviev/EDAspy.
+    """
+
+    ELITE_FACTOR = 0.4
+    STD_BOUND = 0.3
+
+    def __init__(
+        self,
+        maxiter: int = 100,
+        size_gen: int = 20,
+        alpha: float = 0.5,
+        callback: Callable[[int, np.ndarray, float], None] | None = None,
+    ) -> None:
+        r"""
+        Args:
+            maxiter: Maximum number of iterations.
+            size_gen: Population size of each generation.
+            alpha: Percentage (0, 1] of the population to be selected as elite selection.
+            callback: A callback function passed information in each iteration step. The
+                information is, in this order: the number of function evaluations, the parameters,
+                the best function value in this iteration.
+        """
+
+        self.size_gen = size_gen
+        self.maxiter = maxiter
+        self.alpha = alpha
+        self._vector: np.ndarray | None = None
+        # initialization of generation
+        self._generation: np.ndarray | None = None
+        self._dead_iter = int(self._maxiter / 5)
+
+        self._truncation_length = int(size_gen * alpha)
+
+        super().__init__()
+
+        self._best_cost_global: float | None = None
+        self._best_ind_global: np.ndarray | None = None
+        self._evaluations: np.ndarray | None = None
+
+        self._n_variables: int | None = None
+
+        self.callback = callback
+
+    def _initialization(self) -> np.ndarray:
+        vector = np.zeros((4, self._n_variables))
+
+        vector[0, :] = np.pi  # mu
+        vector[1, :] = 0.5  # std
+
+        return vector
+
+    # build a generation of size SIZE_GEN from prob vector
+    def _new_generation(self):
+        """Build a new generation sampled from the vector of probabilities.
+        Updates the generation pandas dataframe
+        """
+
+        gen = algorithm_globals.random.normal(
+            self._vector[0, :], self._vector[1, :], [self._size_gen, self._n_variables]
+        )
+
+        self._generation = self._generation[: int(self.ELITE_FACTOR * len(self._generation))]
+        self._generation = np.vstack((self._generation, gen))
+
+    # truncate the generation at alpha percent
+    def _truncation(self):
+        """Selection of the best individuals of the actual generation.
+        Updates the generation by selecting the best individuals.
+        """
+        best_indices = self._evaluations.argsort()[: self._truncation_length]
+        self._generation = self._generation[best_indices, :]
+        self._evaluations = np.take(self._evaluations, best_indices)
+
+    # check each individual of the generation
+    def _check_generation(self, objective_function):
+        """Check the cost of each individual in the cost function implemented by the user."""
+        self._evaluations = np.apply_along_axis(objective_function, 1, self._generation)
+
+    # update the probability vector
+    def _update_vector(self):
+        """From the best individuals update the vector of normal distributions in order to the next
+        generation can sample from it. Update the vector of normal distributions
+        """
+
+        for i in range(self._n_variables):
+            self._vector[0, i], self._vector[1, i] = norm.fit(self._generation[:, i])
+            if self._vector[1, i] < self.STD_BOUND:
+                self._vector[1, i] = self.STD_BOUND
+
+    def minimize(
+        self,
+        fun: Callable[[POINT], float],
+        x0: POINT,
+        jac: Callable[[POINT], POINT] | None = None,
+        bounds: list[tuple[float, float]] | None = None,
+    ) -> OptimizerResult:
+        not_better_count = 0
+        result = OptimizerResult()
+
+        if isinstance(x0, float):
+            x0 = np.asarray([x0])
+        self._n_variables = len(x0)
+
+        self._best_cost_global = 999999999999
+        self._best_ind_global = None
+        history = []
+        self._evaluations = np.array(0)
+
+        self._vector = self._initialization()
+
+        # initialization of generation
+        self._generation = algorithm_globals.random.normal(
+            self._vector[0, :], self._vector[1, :], [self._size_gen, self._n_variables]
+        )
+
+        for _ in range(self._maxiter):
+            self._check_generation(fun)
+            self._truncation()
+            self._update_vector()
+
+            best_mae_local: float = min(self._evaluations)
+
+            history.append(best_mae_local)
+            best_ind_local = np.where(self._evaluations == best_mae_local)[0][0]
+            best_ind_local = self._generation[best_ind_local]
+
+            # update the best values ever
+            if best_mae_local < self._best_cost_global:
+                self._best_cost_global = best_mae_local
+                self._best_ind_global = best_ind_local
+                not_better_count = 0
+
+            else:
+                not_better_count += 1
+                if not_better_count >= self._dead_iter:
+                    break
+
+            if self.callback is not None:
+                self.callback(
+                    len(history) * self._size_gen, self._best_ind_global, self._best_cost_global
+                )
+
+            self._new_generation()
+
+        result.x = self._best_ind_global
+        result.fun = self._best_cost_global
+        result.nfev = len(history) * self._size_gen
+
+        return result
+
+    @property
+    def size_gen(self) -> int:
+        """Returns the size of the generations (number of individuals per generation)"""
+        return self._size_gen
+
+    @size_gen.setter
+    def size_gen(self, value: int):
+        """
+        Sets the size of the generations of the algorithm.
+
+        Args:
+            value: Size of the generations (number of individuals per generation).
+
+        Raises:
+            ValueError: If `value` is lower than 1.
+        """
+        if value <= 0:
+            raise ValueError("The size of the generation should be greater than 0.")
+        self._size_gen = value
+
+    @property
+    def maxiter(self) -> int:
+        """Returns the maximum number of iterations"""
+        return self._maxiter
+
+    @maxiter.setter
+    def maxiter(self, value: int):
+        """
+        Sets the maximum number of iterations of the algorithm.
+
+        Args:
+            value: Maximum number of iterations of the algorithm.
+
+        Raises:
+            ValueError: If `value` is lower than 1.
+        """
+        if value <= 0:
+            raise ValueError("The maximum number of iterations should be greater than 0.")
+
+        self._maxiter = value
+
+    @property
+    def alpha(self) -> float:
+        """Returns the alpha parameter value (percentage of population selected to update
+        probabilistic model)"""
+        return self._alpha
+
+    @alpha.setter
+    def alpha(self, value: float):
+        """
+        Sets the alpha parameter (percentage of individuals selected to update the probabilistic
+        model)
+
+        Args:
+            value: Percentage (0,1] of generation selected to update the probabilistic model.
+
+        Raises:
+            ValueError: If `value` is lower than 0 or greater than 1.
+        """
+        if (value <= 0) or (value > 1):
+            raise ValueError(f"alpha must be in the range (0, 1], value given was {value}")
+
+        self._alpha = value
+
+    @property
+    def settings(self) -> dict[str, Any]:
+        return {
+            "maxiter": self.maxiter,
+            "alpha": self.alpha,
+            "size_gen": self.size_gen,
+            "callback": self.callback,
+        }
+
+    def get_support_level(self):
+        """Get the support level dictionary."""
+        return {
+            "gradient": OptimizerSupportLevel.ignored,
+            "bounds": OptimizerSupportLevel.ignored,
+            "initial_point": OptimizerSupportLevel.required,
+        }
diff --git a/qiskit_machine_learning/state_fidelities/__init__.py b/qiskit_machine_learning/state_fidelities/__init__.py
new file mode 100644
index 000000000..19a49ba5a
--- /dev/null
+++ b/qiskit_machine_learning/state_fidelities/__init__.py
@@ -0,0 +1,44 @@
+# This code is part of a Qiskit project.
+#
+# (C) Copyright IBM 2022, 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
+# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
+#
+# Any modifications or derivative works of this code must retain this
+# copyright notice, and modified files need to carry a notice indicating
+# that they have been altered from the originals.
+"""
+State Fidelities (:mod:`qiskit_machine_learning.state_fidelities`)
+============================================================
+Algorithms that compute the fidelity of pairs of quantum states.
+
+.. currentmodule:: qiskit_machine_learning.state_fidelities
+
+State Fidelities
+----------------
+
+.. autosummary::
+   :toctree: ../stubs/
+   :nosignatures:
+
+   BaseStateFidelity
+   ComputeUncompute
+
+Results
+-------
+
+.. autosummary::
+   :toctree: ../stubs/
+   :nosignatures:
+
+    StateFidelityResult
+
+"""
+
+from .base_state_fidelity import BaseStateFidelity
+from .compute_uncompute import ComputeUncompute
+from .state_fidelity_result import StateFidelityResult
+
+__all__ = ["BaseStateFidelity", "ComputeUncompute", "StateFidelityResult"]
diff --git a/qiskit_machine_learning/state_fidelities/base_state_fidelity.py b/qiskit_machine_learning/state_fidelities/base_state_fidelity.py
new file mode 100644
index 000000000..7355ce2b8
--- /dev/null
+++ b/qiskit_machine_learning/state_fidelities/base_state_fidelity.py
@@ -0,0 +1,312 @@
+# This code is part of a Qiskit project.
+#
+# (C) Copyright IBM 2022, 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
+# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
+#
+# Any modifications or derivative works of this code must retain this
+# copyright notice, and modified files need to carry a notice indicating
+# that they have been altered from the originals.
+"""
+Base state fidelity interface
+"""
+
+from __future__ import annotations
+from abc import ABC, abstractmethod
+from collections.abc import MutableMapping
+from typing import cast, Sequence, List
+import numpy as np
+
+from qiskit import QuantumCircuit
+from qiskit.circuit import ParameterVector
+from qiskit.primitives.utils import _circuit_key
+
+from ..algorithm_job import AlgorithmJob
+
+
+class BaseStateFidelity(ABC):
+    r"""
+    An interface to calculate state fidelities (state overlaps) for pairs of
+    (parametrized) quantum circuits. The calculation depends on the particular
+    fidelity method implementation, but can be always defined as the state overlap:
+
+    .. math::
+
+            |\langle\psi(x)|\phi(y)\rangle|^2
+
+    where :math:`x` and :math:`y` are optional parametrizations of the
+    states :math:`\psi` and :math:`\phi` prepared by the circuits
+    ``circuit_1`` and ``circuit_2``, respectively.
+
+    """
+
+    def __init__(self) -> None:
+        # use cache for preventing unnecessary circuit compositions
+        self._circuit_cache: MutableMapping[tuple[int, int], QuantumCircuit] = {}
+
+    @staticmethod
+    def _preprocess_values(
+        circuits: QuantumCircuit | Sequence[QuantumCircuit],
+        values: Sequence[float] | Sequence[Sequence[float]] | None = None,
+    ) -> Sequence[list[float]]:
+        """
+        Checks whether the passed values match the shape of the parameters
+        of the corresponding circuits and formats values to 2D list.
+
+        Args:
+            circuits: List of circuits to be checked.
+            values: Parameter values corresponding to the circuits to be checked.
+
+        Returns:
+            A 2D value list if the values match the circuits, or an empty 2D list
+            if values is None.
+
+        Raises:
+            ValueError: if the number of parameter values doesn't match the number of
+                        circuit parameters
+            TypeError: if the input values are not a sequence.
+        """
+
+        if isinstance(circuits, QuantumCircuit):
+            circuits = [circuits]
+
+        if values is None:
+            for circuit in circuits:
+                if circuit.num_parameters != 0:
+                    raise ValueError(
+                        f"`values` cannot be `None` because circuit <{circuit.name}> has "
+                        f"{circuit.num_parameters} free parameters."
+                    )
+            return [[]]
+        else:
+            # Support ndarray
+            if isinstance(values, np.ndarray):
+                values = values.tolist()
+            if len(values) > 0 and isinstance(values[0], np.ndarray):
+                values = [v.tolist() for v in values]
+
+            if not isinstance(values, Sequence):
+                raise TypeError(
+                    f"Expected a sequence of numerical parameter values, "
+                    f"but got input type {type(values)} instead."
+                )
+
+            # ensure 2d
+            if len(values) > 0 and not isinstance(values[0], Sequence) or len(values) == 0:
+                values = [cast(List[float], values)]
+
+            # we explicitly cast the type here because mypy appears to be unable to understand the
+            # above few lines where we ensure that values are 2d
+            return cast(Sequence[List[float]], values)
+
+    def _check_qubits_match(self, circuit_1: QuantumCircuit, circuit_2: QuantumCircuit) -> None:
+        """
+        Checks that the number of qubits of 2 circuits matches.
+        Args:
+            circuit_1: (Parametrized) quantum circuit.
+            circuit_2: (Parametrized) quantum circuit.
+
+        Raises:
+            ValueError: when ``circuit_1`` and ``circuit_2`` don't have the
+            same number of qubits.
+        """
+
+        if circuit_1.num_qubits != circuit_2.num_qubits:
+            raise ValueError(
+                f"The number of qubits for the first circuit ({circuit_1.num_qubits}) "
+                f"and second circuit ({circuit_2.num_qubits}) are not the same."
+            )
+
+    @abstractmethod
+    def create_fidelity_circuit(
+        self, circuit_1: QuantumCircuit, circuit_2: QuantumCircuit
+    ) -> QuantumCircuit:
+        """
+        Implementation-dependent method to create a fidelity circuit
+        from 2 circuit inputs.
+
+        Args:
+            circuit_1: (Parametrized) quantum circuit.
+            circuit_2: (Parametrized) quantum circuit.
+
+        Returns:
+            The fidelity quantum circuit corresponding to ``circuit_1`` and ``circuit_2``.
+        """
+        raise NotImplementedError
+
+    def _construct_circuits(
+        self,
+        circuits_1: QuantumCircuit | Sequence[QuantumCircuit],
+        circuits_2: QuantumCircuit | Sequence[QuantumCircuit],
+    ) -> Sequence[QuantumCircuit]:
+        """
+        Constructs the list of fidelity circuits to be evaluated.
+        These circuits represent the state overlap between pairs of input circuits,
+        and their construction depends on the fidelity method implementations.
+
+        Args:
+            circuits_1: (Parametrized) quantum circuits.
+            circuits_2: (Parametrized) quantum circuits.
+
+        Returns:
+            List of constructed fidelity circuits.
+
+        Raises:
+            ValueError: if the length of the input circuit lists doesn't match.
+        """
+
+        if isinstance(circuits_1, QuantumCircuit):
+            circuits_1 = [circuits_1]
+        if isinstance(circuits_2, QuantumCircuit):
+            circuits_2 = [circuits_2]
+
+        if len(circuits_1) != len(circuits_2):
+            raise ValueError(
+                f"The length of the first circuit list({len(circuits_1)}) "
+                f"and second circuit list ({len(circuits_2)}) is not the same."
+            )
+
+        circuits = []
+        for circuit_1, circuit_2 in zip(circuits_1, circuits_2):
+            # Use the same key for circuits as qiskit.primitives use.
+            circuit = self._circuit_cache.get((_circuit_key(circuit_1), _circuit_key(circuit_2)))
+
+            if circuit is not None:
+                circuits.append(circuit)
+            else:
+                self._check_qubits_match(circuit_1, circuit_2)
+
+                # re-parametrize input circuits
+                # TODO: make smarter checks to avoid unnecessary re-parametrizations
+                parameters_1 = ParameterVector("x", circuit_1.num_parameters)
+                parametrized_circuit_1 = circuit_1.assign_parameters(parameters_1)
+                parameters_2 = ParameterVector("y", circuit_2.num_parameters)
+                parametrized_circuit_2 = circuit_2.assign_parameters(parameters_2)
+
+                circuit = self.create_fidelity_circuit(
+                    parametrized_circuit_1, parametrized_circuit_2
+                )
+                circuits.append(circuit)
+                # update cache
+                self._circuit_cache[_circuit_key(circuit_1), _circuit_key(circuit_2)] = circuit
+
+        return circuits
+
+    def _construct_value_list(
+        self,
+        circuits_1: Sequence[QuantumCircuit],
+        circuits_2: Sequence[QuantumCircuit],
+        values_1: Sequence[float] | Sequence[Sequence[float]] | None = None,
+        values_2: Sequence[float] | Sequence[Sequence[float]] | None = None,
+    ) -> list[list[float]]:
+        """
+        Preprocesses input parameter values to match the fidelity
+        circuit parametrization, and return in list format.
+
+        Args:
+           circuits_1: (Parametrized) quantum circuits preparing the
+                        first list of quantum states.
+           circuits_2: (Parametrized) quantum circuits preparing the
+                        second list of quantum states.
+           values_1: Numerical parameters to be bound to the first circuits.
+           values_2: Numerical parameters to be bound to the second circuits.
+
+        Returns:
+             List of lists of parameter values for fidelity circuit.
+
+        """
+        values_1 = self._preprocess_values(circuits_1, values_1)
+        values_2 = self._preprocess_values(circuits_2, values_2)
+        # now, values_1 and values_2 are explicitly made 2d lists
+
+        values = []
+        if len(values_2[0]) == 0:
+            values = list(values_1)
+        elif len(values_1[0]) == 0:
+            values = list(values_2)
+        else:
+            for val_1, val_2 in zip(values_1, values_2):
+                # the `+` operation concatenates the lists
+                # and then this new list gets appended to the values list
+                values.append(val_1 + val_2)
+
+        # values is guaranteed to be 2d
+        return values
+
+    @abstractmethod
+    def _run(
+        self,
+        circuits_1: QuantumCircuit | Sequence[QuantumCircuit],
+        circuits_2: QuantumCircuit | Sequence[QuantumCircuit],
+        values_1: Sequence[float] | Sequence[Sequence[float]] | None = None,
+        values_2: Sequence[float] | Sequence[Sequence[float]] | None = None,
+        **options,
+    ) -> AlgorithmJob:
+        r"""
+        Computes the state overlap (fidelity) calculation between two
+        (parametrized) circuits (first and second) for a specific set of parameter
+        values (first and second).
+
+        Args:
+            circuits_1: (Parametrized) quantum circuits preparing :math:`|\psi\rangle`.
+            circuits_2: (Parametrized) quantum circuits preparing :math:`|\phi\rangle`.
+            values_1: Numerical parameters to be bound to the first set of circuits
+            values_2: Numerical parameters to be bound to the second set of circuits.
+            options: Primitive backend runtime options used for circuit execution. The order
+                of priority is\: options in ``run`` method > fidelity's default
+                options > primitive's default setting.
+                Higher priority setting overrides lower priority setting.
+
+        Returns:
+            A newly constructed algorithm job instance to get the fidelity result.
+        """
+        raise NotImplementedError
+
+    def run(
+        self,
+        circuits_1: QuantumCircuit | Sequence[QuantumCircuit],
+        circuits_2: QuantumCircuit | Sequence[QuantumCircuit],
+        values_1: Sequence[float] | Sequence[Sequence[float]] | None = None,
+        values_2: Sequence[float] | Sequence[Sequence[float]] | None = None,
+        **options,
+    ) -> AlgorithmJob:
+        r"""
+        Runs asynchronously the state overlap (fidelity) calculation between two
+        (parametrized) circuits (first and second) for a specific set of parameter
+        values (first and second). This calculation depends on the particular
+        fidelity method implementation.
+
+        Args:
+            circuits_1: (Parametrized) quantum circuits preparing :math:`|\psi\rangle`.
+            circuits_2: (Parametrized) quantum circuits preparing :math:`|\phi\rangle`.
+            values_1: Numerical parameters to be bound to the first set of circuits.
+            values_2: Numerical parameters to be bound to the second set of circuits.
+            options: Primitive backend runtime options used for circuit execution. The order
+                of priority is\: options in ``run`` method > fidelity's default
+                options > primitive's default setting.
+                Higher priority setting overrides lower priority setting.
+
+        Returns:
+            Primitive job for the fidelity calculation.
+            The job's result is an instance of :class:`.StateFidelityResult`.
+        """
+        job = self._run(circuits_1, circuits_2, values_1, values_2, **options)
+
+        job.submit()
+        return job
+
+    @staticmethod
+    def _truncate_fidelities(fidelities: Sequence[float]) -> Sequence[float]:
+        """
+        Ensures fidelity result in [0,1].
+
+        Args:
+           fidelities: Sequence of raw fidelity results.
+
+        Returns:
+             List of truncated fidelities.
+
+        """
+        return np.clip(fidelities, 0, 1).tolist()
diff --git a/qiskit_machine_learning/state_fidelities/compute_uncompute.py b/qiskit_machine_learning/state_fidelities/compute_uncompute.py
new file mode 100644
index 000000000..3453b2081
--- /dev/null
+++ b/qiskit_machine_learning/state_fidelities/compute_uncompute.py
@@ -0,0 +1,258 @@
+# This code is part of a Qiskit project.
+#
+# (C) Copyright IBM 2022, 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
+# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
+#
+# Any modifications or derivative works of this code must retain this
+# copyright notice, and modified files need to carry a notice indicating
+# that they have been altered from the originals.
+"""
+Compute-uncompute fidelity interface using primitives
+"""
+
+from __future__ import annotations
+from collections.abc import Sequence
+from copy import copy
+
+from qiskit import QuantumCircuit
+from qiskit.primitives import BaseSampler
+from qiskit.primitives.primitive_job import PrimitiveJob
+from qiskit.providers import Options
+
+from ..exceptions import AlgorithmError
+from .base_state_fidelity import BaseStateFidelity
+from .state_fidelity_result import StateFidelityResult
+from ..algorithm_job import AlgorithmJob
+
+
+class ComputeUncompute(BaseStateFidelity):
+    r"""
+    This class leverages the sampler primitive to calculate the state
+    fidelity of two quantum circuits following the compute-uncompute
+    method (see [1] for further reference).
+    The fidelity can be defined as the state overlap.
+
+    .. math::
+
+            |\langle\psi(x)|\phi(y)\rangle|^2
+
+    where :math:`x` and :math:`y` are optional parametrizations of the
+    states :math:`\psi` and :math:`\phi` prepared by the circuits
+    ``circuit_1`` and ``circuit_2``, respectively.
+
+    **Reference:**
+    [1] Havlíček, V., Córcoles, A. D., Temme, K., Harrow, A. W., Kandala,
+    A., Chow, J. M., & Gambetta, J. M. (2019). Supervised learning
+    with quantum-enhanced feature spaces. Nature, 567(7747), 209-212.
+    `arXiv:1804.11326v2 [quant-ph] <https://arxiv.org/pdf/1804.11326.pdf>`_
+
+    """
+
+    def __init__(
+        self,
+        sampler: BaseSampler,
+        options: Options | None = None,
+        local: bool = False,
+    ) -> None:
+        r"""
+        Args:
+            sampler: Sampler primitive instance.
+            options: Primitive backend runtime options used for circuit execution.
+                The order of priority is: options in ``run`` method > fidelity's
+                default options > primitive's default setting.
+                Higher priority setting overrides lower priority setting.
+            local: If set to ``True``, the fidelity is averaged over
+                single-qubit projectors
+
+                .. math::
+
+                    \hat{O} = \frac{1}{N}\sum_{i=1}^N|0_i\rangle\langle 0_i|,
+
+                instead of the global projector :math:`|0\rangle\langle 0|^{\otimes n}`.
+                This coincides with the standard (global) fidelity in the limit of
+                the fidelity approaching 1. Might be used to increase the variance
+                to improve trainability in algorithms such as :class:`~.time_evolvers.PVQD`.
+
+        Raises:
+            ValueError: If the sampler is not an instance of ``BaseSampler``.
+        """
+        if not isinstance(sampler, BaseSampler):
+            raise ValueError(
+                f"The sampler should be an instance of BaseSampler, " f"but got {type(sampler)}"
+            )
+        self._sampler: BaseSampler = sampler
+        self._local = local
+        self._default_options = Options()
+        if options is not None:
+            self._default_options.update_options(**options)
+        super().__init__()
+
+    def create_fidelity_circuit(
+        self, circuit_1: QuantumCircuit, circuit_2: QuantumCircuit
+    ) -> QuantumCircuit:
+        """
+        Combines ``circuit_1`` and ``circuit_2`` to create the
+        fidelity circuit following the compute-uncompute method.
+
+        Args:
+            circuit_1: (Parametrized) quantum circuit.
+            circuit_2: (Parametrized) quantum circuit.
+
+        Returns:
+            The fidelity quantum circuit corresponding to circuit_1 and circuit_2.
+        """
+        if len(circuit_1.clbits) > 0:
+            circuit_1.remove_final_measurements()
+        if len(circuit_2.clbits) > 0:
+            circuit_2.remove_final_measurements()
+
+        circuit = circuit_1.compose(circuit_2.inverse())
+        circuit.measure_all()
+        return circuit
+
+    def _run(
+        self,
+        circuits_1: QuantumCircuit | Sequence[QuantumCircuit],
+        circuits_2: QuantumCircuit | Sequence[QuantumCircuit],
+        values_1: Sequence[float] | Sequence[Sequence[float]] | None = None,
+        values_2: Sequence[float] | Sequence[Sequence[float]] | None = None,
+        **options,
+    ) -> AlgorithmJob:
+        r"""
+        Computes the state overlap (fidelity) calculation between two
+        (parametrized) circuits (first and second) for a specific set of parameter
+        values (first and second) following the compute-uncompute method.
+
+        Args:
+            circuits_1: (Parametrized) quantum circuits preparing :math:`|\psi\rangle`.
+            circuits_2: (Parametrized) quantum circuits preparing :math:`|\phi\rangle`.
+            values_1: Numerical parameters to be bound to the first circuits.
+            values_2: Numerical parameters to be bound to the second circuits.
+            options: Primitive backend runtime options used for circuit execution.
+                    The order of priority is: options in ``run`` method > fidelity's
+                    default options > primitive's default setting.
+                    Higher priority setting overrides lower priority setting.
+
+        Returns:
+            An AlgorithmJob for the fidelity calculation.
+
+        Raises:
+            ValueError: At least one pair of circuits must be defined.
+            AlgorithmError: If the sampler job is not completed successfully.
+        """
+
+        circuits = self._construct_circuits(circuits_1, circuits_2)
+        if len(circuits) == 0:
+            raise ValueError(
+                "At least one pair of circuits must be defined to calculate the state overlap."
+            )
+        values = self._construct_value_list(circuits_1, circuits_2, values_1, values_2)
+
+        # The priority of run options is as follows:
+        # options in `evaluate` method > fidelity's default options >
+        # primitive's default options.
+        opts = copy(self._default_options)
+        opts.update_options(**options)
+
+        sampler_job = self._sampler.run(circuits=circuits, parameter_values=values, **opts.__dict__)
+
+        local_opts = self._get_local_options(opts.__dict__)
+        return AlgorithmJob(ComputeUncompute._call, sampler_job, circuits, self._local, local_opts)
+
+    @staticmethod
+    def _call(
+        job: PrimitiveJob, circuits: Sequence[QuantumCircuit], local: bool, local_opts: Options
+    ) -> StateFidelityResult:
+        try:
+            result = job.result()
+        except Exception as exc:
+            raise AlgorithmError("Sampler job failed!") from exc
+
+        if local:
+            raw_fidelities = [
+                ComputeUncompute._get_local_fidelity(prob_dist, circuit.num_qubits)
+                for prob_dist, circuit in zip(result.quasi_dists, circuits)
+            ]
+        else:
+            raw_fidelities = [
+                ComputeUncompute._get_global_fidelity(prob_dist) for prob_dist in result.quasi_dists
+            ]
+        fidelities = ComputeUncompute._truncate_fidelities(raw_fidelities)
+
+        return StateFidelityResult(
+            fidelities=fidelities,
+            raw_fidelities=raw_fidelities,
+            metadata=result.metadata,
+            options=local_opts,
+        )
+
+    @property
+    def options(self) -> Options:
+        """Return the union of estimator options setting and fidelity default options,
+        where, if the same field is set in both, the fidelity's default options override
+        the primitive's default setting.
+
+        Returns:
+            The fidelity default + estimator options.
+        """
+        return self._get_local_options(self._default_options.__dict__)
+
+    def update_default_options(self, **options):
+        """Update the fidelity's default options setting.
+
+        Args:
+            **options: The fields to update the default options.
+        """
+
+        self._default_options.update_options(**options)
+
+    def _get_local_options(self, options: Options) -> Options:
+        """Return the union of the primitive's default setting,
+        the fidelity default options, and the options in the ``run`` method.
+        The order of priority is: options in ``run`` method > fidelity's
+                default options > primitive's default setting.
+
+        Args:
+            options: The fields to update the options
+
+        Returns:
+            The fidelity default + estimator + run options.
+        """
+        opts = copy(self._sampler.options)
+        opts.update_options(**options)
+        return opts
+
+    @staticmethod
+    def _get_global_fidelity(probability_distribution: dict[int, float]) -> float:
+        """Process the probability distribution of a measurement to determine the
+        global fidelity.
+
+        Args:
+            probability_distribution: Obtained from the measurement result
+
+        Returns:
+            The global fidelity.
+        """
+        return probability_distribution.get(0, 0)
+
+    @staticmethod
+    def _get_local_fidelity(probability_distribution: dict[int, float], num_qubits: int) -> float:
+        """Process the probability distribution of a measurement to determine the
+        local fidelity by averaging over single-qubit projectors.
+
+        Args:
+            probability_distribution: Obtained from the measurement result
+
+        Returns:
+            The local fidelity.
+        """
+        fidelity = 0.0
+        for qubit in range(num_qubits):
+            for bitstring, prob in probability_distribution.items():
+                # Check whether the bit representing the current qubit is 0
+                if not bitstring >> qubit & 1:
+                    fidelity += prob / num_qubits
+        return fidelity
diff --git a/qiskit_machine_learning/state_fidelities/state_fidelity_result.py b/qiskit_machine_learning/state_fidelities/state_fidelity_result.py
new file mode 100644
index 000000000..683167ee7
--- /dev/null
+++ b/qiskit_machine_learning/state_fidelities/state_fidelity_result.py
@@ -0,0 +1,37 @@
+# This code is part of a Qiskit project.
+#
+# (C) Copyright IBM 2022, 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
+# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
+#
+# Any modifications or derivative works of this code must retain this
+# copyright notice, and modified files need to carry a notice indicating
+# that they have been altered from the originals.
+"""
+Fidelity result class
+"""
+
+from __future__ import annotations
+
+from collections.abc import Sequence, Mapping
+from typing import Any
+from dataclasses import dataclass
+
+from qiskit.providers import Options
+
+
+@dataclass(frozen=True)
+class StateFidelityResult:
+    """This class stores the result of StateFidelity computations."""
+
+    fidelities: Sequence[float]
+    """List of truncated fidelity values for each pair of input circuits, ensured to be in [0,1]."""
+    raw_fidelities: Sequence[float]
+    """List of raw fidelity values for each pair of input circuits, which might not be in [0,1]
+    depending on the error mitigation method used."""
+    metadata: Sequence[Mapping[str, Any]]
+    """Additional information about the fidelity calculation."""
+    options: Options
+    """Primitive runtime options for the execution of the fidelity job."""
diff --git a/qiskit_machine_learning/utils/__init__.py b/qiskit_machine_learning/utils/__init__.py
index bba7f030a..70873e3e7 100644
--- a/qiskit_machine_learning/utils/__init__.py
+++ b/qiskit_machine_learning/utils/__init__.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
@@ -28,5 +28,13 @@
 
 """
 from .adjust_num_qubits import derive_num_qubits_feature_map_ansatz
-
-__all__ = ["derive_num_qubits_feature_map_ansatz"]
+from .algorithm_globals import algorithm_globals
+from .validate_initial_point import validate_initial_point
+from .validate_bounds import validate_bounds
+
+__all__ = [
+    "derive_num_qubits_feature_map_ansatz",
+    "algorithm_globals",
+    "validate_initial_point",
+    "validate_bounds",
+]
diff --git a/qiskit_machine_learning/utils/adjust_num_qubits.py b/qiskit_machine_learning/utils/adjust_num_qubits.py
index f146d1513..84ae8eb85 100644
--- a/qiskit_machine_learning/utils/adjust_num_qubits.py
+++ b/qiskit_machine_learning/utils/adjust_num_qubits.py
@@ -1,6 +1,6 @@
 # This code is part of a Qiskit project.
 #
-# (C) Copyright IBM 2022, 2023.
+# (C) Copyright IBM 2022, 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
@@ -17,7 +17,7 @@
 from qiskit import QuantumCircuit
 from qiskit.circuit.library import RealAmplitudes, ZFeatureMap, ZZFeatureMap
 
-from qiskit_machine_learning import QiskitMachineLearningError
+from ..exceptions import QiskitMachineLearningError
 
 
 # pylint: disable=invalid-name
diff --git a/qiskit_machine_learning/utils/algorithm_globals.py b/qiskit_machine_learning/utils/algorithm_globals.py
new file mode 100644
index 000000000..5d85af3c1
--- /dev/null
+++ b/qiskit_machine_learning/utils/algorithm_globals.py
@@ -0,0 +1,131 @@
+# This code is part of a Qiskit project.
+#
+# (C) Copyright IBM 2019, 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
+# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
+#
+# Any modifications or derivative works of this code must retain this
+# copyright notice, and modified files need to carry a notice indicating
+# that they have been altered from the originals.
+
+"""
+utils.algorithm_globals
+=======================
+Common (global) properties used across qiskit_machine_learning.
+
+.. currentmodule:: qiskit_machine_learning.utils.algorithm_globals
+
+Includes:
+
+  * Random number generator and random seed.
+
+    Algorithms can use the generator for random values, as needed, and it
+    can be seeded here for reproducible results when using such an algorithm.
+    This is often important, for example in unit tests, where the same
+    outcome is desired each time (reproducible) and not have it be variable
+    due to randomness.
+
+Attributes:
+    random_seed (int | None): Random generator seed (read/write).
+    random (np.random.Generator): Random generator (read-only)
+"""
+
+from __future__ import annotations
+
+import warnings
+
+import numpy as np
+
+
+class QiskitAlgorithmGlobals:
+    """Global properties for algorithms."""
+
+    # The code is done to work even after some future removal of algorithm_globals
+    # from Qiskit (qiskit.utils). All that is needed in the future, after that, if
+    # this is updated, is just the logic in the except blocks.
+    #
+    # If the Qiskit version exists this acts a redirect to that (it delegates the
+    # calls off to it). In the future when that does not exist this has similar code
+    # in the except blocks here, as noted above, that will take over. By delegating
+    # to the Qiskit instance it means that any existing code that uses that continues
+    # to work. Logic here in qiskit_machine_learning though uses this instance and the
+    # random check here has logic to warn if the seed here is not the same as the Qiskit
+    # version so we can detect direct usage of the Qiskit version and alert the user to
+    # change their code to use this. So simply changing from:
+    #     from qiskit.utils import algorithm_globals
+    # to
+    #     from qiskit_algorithm.utils import algorithm_globals
+
+    def __init__(self) -> None:
+        self._random_seed: int | None = None
+        self._random: np.random.Generator | None = None
+
+    @property
+    def random_seed(self) -> int | None:
+        """Random seed property (getter/setter)."""
+        try:
+            with warnings.catch_warnings():
+                warnings.simplefilter("ignore", category=DeprecationWarning)
+
+                from qiskit.utils import algorithm_globals as qiskit_globals
+
+                return qiskit_globals.random_seed
+
+        except ImportError:
+            return self._random_seed
+
+    @random_seed.setter
+    def random_seed(self, seed: int | None) -> None:
+        """Set the random generator seed.
+
+        Args:
+            seed: If ``None`` then internally a random value is used as a seed
+        """
+        try:
+            with warnings.catch_warnings():
+                warnings.simplefilter("ignore", category=DeprecationWarning)
+
+                from qiskit.utils import algorithm_globals as qiskit_globals
+
+                qiskit_globals.random_seed = seed
+                # Mirror the seed here when set via this random_seed. If the seed is
+                # set on the qiskit.utils instance then we can detect it's different
+                self._random_seed = seed
+
+        except ImportError:
+            self._random_seed = seed
+            self._random = None
+
+    @property
+    def random(self) -> np.random.Generator:
+        """Return a numpy np.random.Generator (default_rng) using random_seed."""
+        try:
+            with warnings.catch_warnings():
+                warnings.simplefilter("ignore", category=DeprecationWarning)
+
+                from qiskit.utils import algorithm_globals as qiskit_globals
+
+                if self._random_seed != qiskit_globals.random_seed:
+                    # If the seeds are different - likely this local is None and the qiskit.utils
+                    # algorithms global was seeded directly then we will warn to use this here as
+                    # the Qiskit version is planned to be removed in a future version of Qiskit.
+                    warnings.warn(
+                        "Using random that is seeded via qiskit.utils algorithm_globals is deprecated "
+                        "since version 0.2.0. Instead set random_seed directly to "
+                        "qiskit_machine_learning.utils algorithm_globals.",
+                        category=DeprecationWarning,
+                        stacklevel=2,
+                    )
+
+                return qiskit_globals.random
+
+        except ImportError:
+            if self._random is None:
+                self._random = np.random.default_rng(self._random_seed)
+            return self._random
+
+
+# Global instance to be used as the entry point for globals.
+algorithm_globals = QiskitAlgorithmGlobals()
diff --git a/qiskit_machine_learning/utils/optionals.py b/qiskit_machine_learning/utils/optionals.py
new file mode 100644
index 000000000..30e9b517e
--- /dev/null
+++ b/qiskit_machine_learning/utils/optionals.py
@@ -0,0 +1,27 @@
+# This code is part of a Qiskit project.
+#
+# (C) Copyright IBM 2022, 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
+# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
+#
+# Any modifications or derivative works of this code must retain this
+# copyright notice, and modified files need to carry a notice indicating
+# that they have been altered from the originals.
+
+"""
+Additional optional constants.
+"""
+
+from qiskit.utils import LazyImportTester
+
+
+HAS_NLOPT = LazyImportTester("nlopt", name="NLopt Optimizer", install="pip install nlopt")
+HAS_SKQUANT = LazyImportTester(
+    "skquant.opt",
+    name="scikit-quant",
+    install="pip install scikit-quant",
+)
+HAS_SQSNOBFIT = LazyImportTester("SQSnobFit", install="pip install SQSnobFit")
+HAS_TWEEDLEDUM = LazyImportTester("tweedledum", install="pip install tweedledum")
diff --git a/qiskit_machine_learning/utils/set_batching.py b/qiskit_machine_learning/utils/set_batching.py
new file mode 100644
index 000000000..94d2624d0
--- /dev/null
+++ b/qiskit_machine_learning/utils/set_batching.py
@@ -0,0 +1,27 @@
+# This code is part of a Qiskit project.
+#
+# (C) Copyright IBM 2022, 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
+# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
+#
+# Any modifications or derivative works of this code must retain this
+# copyright notice, and modified files need to carry a notice indicating
+# that they have been altered from the originals.
+
+"""Set default batch sizes for the optimizers."""
+
+from ..optimizers import Optimizer, SPSA
+
+
+def _set_default_batchsize(optimizer: Optimizer) -> bool:
+    """Set the default batchsize, if None is set and return whether it was updated or not."""
+    if isinstance(optimizer, SPSA):
+        updated = optimizer._max_evals_grouped is None
+        if updated:
+            optimizer.set_max_evals_grouped(50)
+    else:  # we only set a batchsize for SPSA
+        updated = False
+
+    return updated
diff --git a/qiskit_machine_learning/utils/validate_bounds.py b/qiskit_machine_learning/utils/validate_bounds.py
new file mode 100644
index 000000000..2affd7b8c
--- /dev/null
+++ b/qiskit_machine_learning/utils/validate_bounds.py
@@ -0,0 +1,44 @@
+# This code is part of a Qiskit project.
+#
+# (C) Copyright IBM 2022, 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
+# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
+#
+# Any modifications or derivative works of this code must retain this
+# copyright notice, and modified files need to carry a notice indicating
+# that they have been altered from the originals.
+
+"""Validate parameter bounds."""
+
+from __future__ import annotations
+
+from qiskit.circuit import QuantumCircuit
+
+
+def validate_bounds(circuit: QuantumCircuit) -> list[tuple[float | None, float | None]]:
+    """
+    Validate the bounds provided by a quantum circuit against its number of parameters.
+    If no bounds are obtained, return ``None`` for all lower and upper bounds.
+
+    Args:
+        circuit: A parameterized quantum circuit.
+
+    Returns:
+        A list of tuples (lower_bound, upper_bound)).
+
+    Raises:
+        ValueError: If the number of bounds does not the match the number of circuit parameters.
+    """
+    if hasattr(circuit, "parameter_bounds") and circuit.parameter_bounds is not None:
+        bounds = circuit.parameter_bounds
+        if len(bounds) != circuit.num_parameters:
+            raise ValueError(
+                f"The number of bounds ({len(bounds)}) does not match the number of "
+                f"parameters in the circuit ({circuit.num_parameters})."
+            )
+    else:
+        bounds = [(None, None)] * circuit.num_parameters
+
+    return bounds
diff --git a/qiskit_machine_learning/utils/validate_initial_point.py b/qiskit_machine_learning/utils/validate_initial_point.py
new file mode 100644
index 000000000..ad7f8ba5b
--- /dev/null
+++ b/qiskit_machine_learning/utils/validate_initial_point.py
@@ -0,0 +1,65 @@
+# This code is part of a Qiskit project.
+#
+# (C) Copyright IBM 2022, 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
+# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
+#
+# Any modifications or derivative works of this code must retain this
+# copyright notice, and modified files need to carry a notice indicating
+# that they have been altered from the originals.
+
+"""Validate an initial point."""
+
+from __future__ import annotations
+
+import numpy as np
+
+from qiskit.circuit import QuantumCircuit
+from .algorithm_globals import algorithm_globals
+
+
+def validate_initial_point(point: np.ndarray | None | None, circuit: QuantumCircuit) -> np.ndarray:
+    r"""
+    Validate a choice of initial point against a choice of circuit. If no point is provided, a
+    random point will be generated within certain parameter bounds. It will first look to the
+    circuit for these bounds. If the circuit does not specify bounds, bounds of :math:`-2\pi`,
+    :math:`2\pi` will be used.
+
+    Args:
+        point: An initial point.
+        circuit: A parameterized quantum circuit.
+
+    Returns:
+        A validated initial point.
+
+    Raises:
+        ValueError: If the dimension of the initial point does not match the number of circuit
+        parameters.
+    """
+    expected_size = circuit.num_parameters
+
+    if point is None:
+        # get bounds if circuit has them set, otherwise use [-2pi, 2pi] for each parameter
+        bounds = getattr(circuit, "parameter_bounds", None)
+        if bounds is None:
+            bounds = [(-2 * np.pi, 2 * np.pi)] * expected_size
+
+        # replace all Nones by [-2pi, 2pi]
+        lower_bounds = []
+        upper_bounds = []
+        for lower, upper in bounds:
+            lower_bounds.append(lower if lower is not None else -2 * np.pi)
+            upper_bounds.append(upper if upper is not None else 2 * np.pi)
+
+        # sample from within bounds
+        point = algorithm_globals.random.uniform(lower_bounds, upper_bounds)
+
+    elif len(point) != expected_size:
+        raise ValueError(
+            f"The dimension of the initial point ({len(point)}) does not match the "
+            f"number of parameters in the circuit ({expected_size})."
+        )
+
+    return point
diff --git a/qiskit_machine_learning/utils/validation.py b/qiskit_machine_learning/utils/validation.py
new file mode 100644
index 000000000..edd8e06e1
--- /dev/null
+++ b/qiskit_machine_learning/utils/validation.py
@@ -0,0 +1,138 @@
+# This code is part of a Qiskit project.
+#
+# (C) Copyright IBM 2019, 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
+# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
+#
+# Any modifications or derivative works of this code must retain this
+# copyright notice, and modified files need to carry a notice indicating
+# that they have been altered from the originals.
+
+"""
+Validation module
+"""
+
+from typing import Set
+
+
+def validate_in_set(name: str, value: object, values: Set[object]) -> None:
+    """
+    Args:
+        name: value name.
+        value: value to check.
+        values: set that should contain value.
+    Raises:
+        ValueError: invalid value
+    """
+    if value not in values:
+        raise ValueError(f"{name} must be one of '{values}', was '{value}'.")
+
+
+def validate_min(name: str, value: float, minimum: float) -> None:
+    """
+    Args:
+        name: value name.
+        value: value to check.
+        minimum: minimum value allowed.
+    Raises:
+        ValueError: invalid value
+    """
+    if value < minimum:
+        raise ValueError(f"{name} must have value >= {minimum}, was {value}")
+
+
+def validate_min_exclusive(name: str, value: float, minimum: float) -> None:
+    """
+    Args:
+        name: value name.
+        value: value to check.
+        minimum: minimum value allowed.
+    Raises:
+        ValueError: invalid value
+    """
+    if value <= minimum:
+        raise ValueError(f"{name} must have value > {minimum}, was {value}")
+
+
+def validate_max(name: str, value: float, maximum: float) -> None:
+    """
+    Args:
+        name: value name.
+        value: value to check.
+        maximum: maximum value allowed.
+    Raises:
+        ValueError: invalid value
+    """
+    if value > maximum:
+        raise ValueError(f"{name} must have value <= {maximum}, was {value}")
+
+
+def validate_max_exclusive(name: str, value: float, maximum: float) -> None:
+    """
+    Args:
+        name: value name.
+        value: value to check.
+        maximum: maximum value allowed.
+    Raises:
+        ValueError: invalid value
+    """
+    if value >= maximum:
+        raise ValueError(f"{name} must have value < {maximum}, was {value}")
+
+
+def validate_range(name: str, value: float, minimum: float, maximum: float) -> None:
+    """
+    Args:
+        name: value name.
+        value: value to check.
+        minimum: minimum value allowed.
+        maximum: maximum value allowed.
+    Raises:
+        ValueError: invalid value
+    """
+    if value < minimum or value > maximum:
+        raise ValueError(f"{name} must have value >= {minimum} and <= {maximum}, was {value}")
+
+
+def validate_range_exclusive(name: str, value: float, minimum: float, maximum: float) -> None:
+    """
+    Args:
+        name: value name.
+        value: value to check.
+        minimum: minimum value allowed.
+        maximum: maximum value allowed.
+    Raises:
+        ValueError: invalid value
+    """
+    if value <= minimum or value >= maximum:
+        raise ValueError(f"{name} must have value > {minimum} and < {maximum}, was {value}")
+
+
+def validate_range_exclusive_min(name: str, value: float, minimum: float, maximum: float) -> None:
+    """
+    Args:
+        name: value name.
+        value: value to check.
+        minimum: minimum value allowed.
+        maximum: maximum value allowed.
+    Raises:
+        ValueError: invalid value
+    """
+    if value <= minimum or value > maximum:
+        raise ValueError(f"{name} must have value > {minimum} and <= {maximum}, was {value}")
+
+
+def validate_range_exclusive_max(name: str, value: float, minimum: float, maximum: float) -> None:
+    """
+    Args:
+        name: value name.
+        value: value to check.
+        minimum: minimum value allowed.
+        maximum: maximum value allowed.
+    Raises:
+        ValueError: invalid value
+    """
+    if value < minimum or value >= maximum:
+        raise ValueError(f"{name} must have value >= {minimum} and < {maximum}, was {value}")
diff --git a/qiskit_machine_learning/variational_algorithm.py b/qiskit_machine_learning/variational_algorithm.py
new file mode 100644
index 000000000..2b863a8ca
--- /dev/null
+++ b/qiskit_machine_learning/variational_algorithm.py
@@ -0,0 +1,137 @@
+# This code is part of a Qiskit project.
+#
+# (C) Copyright IBM 2019, 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
+# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
+#
+# Any modifications or derivative works of this code must retain this
+# copyright notice, and modified files need to carry a notice indicating
+# that they have been altered from the originals.
+
+"""The Variational Algorithm Base Class.
+
+This class can be used an interface for working with Variation Algorithms, such as VQE,
+QAOA, or QSVM, and also provides helper utilities for implementing new variational algorithms.
+Writing a new variational algorithm is a simple as extending this class, implementing a cost
+function for the new algorithm to pass to the optimizer, and running :meth:`find_minimum` method
+of this class to carry out the optimization. Alternatively, all of the functions below can be
+overridden to opt-out of this infrastructure but still meet the interface requirements.
+
+.. note::
+
+    This component has some function that is normally random. If you want to reproduce behavior
+    then you should set the random number generator seed in the algorithm_globals
+    (``qiskit_machine_learning.utils.algorithm_globals.random_seed = seed``).
+"""
+
+from __future__ import annotations
+from abc import ABC, abstractmethod
+import numpy as np
+
+from qiskit.circuit import QuantumCircuit
+
+from .algorithm_result import AlgorithmResult
+from .optimizers import OptimizerResult
+
+
+class VariationalAlgorithm(ABC):
+    """The Variational Algorithm Base Class."""
+
+    @property
+    @abstractmethod
+    def initial_point(self) -> np.ndarray | None:
+        """Returns initial point."""
+        pass
+
+    @initial_point.setter
+    @abstractmethod
+    def initial_point(self, initial_point: np.ndarray | None) -> None:
+        """Sets initial point."""
+        pass
+
+
+class VariationalResult(AlgorithmResult):
+    """Variation Algorithm Result."""
+
+    def __init__(self) -> None:
+        super().__init__()
+        self._optimizer_evals: int | None = None
+        self._optimizer_time: float | None = None
+        self._optimal_value: float | None = None
+        self._optimal_point: np.ndarray | None = None
+        self._optimal_parameters: dict | None = None
+        self._optimizer_result: OptimizerResult | None = None
+        self._optimal_circuit: QuantumCircuit | None = None
+
+    @property
+    def optimizer_evals(self) -> int | None:
+        """Returns number of optimizer evaluations"""
+        return self._optimizer_evals
+
+    @optimizer_evals.setter
+    def optimizer_evals(self, value: int) -> None:
+        """Sets number of optimizer evaluations"""
+        self._optimizer_evals = value
+
+    @property
+    def optimizer_time(self) -> float | None:
+        """Returns time taken for optimization"""
+        return self._optimizer_time
+
+    @optimizer_time.setter
+    def optimizer_time(self, value: float) -> None:
+        """Sets time taken for optimization"""
+        self._optimizer_time = value
+
+    @property
+    def optimal_value(self) -> float | None:
+        """Returns optimal value"""
+        return self._optimal_value
+
+    @optimal_value.setter
+    def optimal_value(self, value: int) -> None:
+        """Sets optimal value"""
+        self._optimal_value = value
+
+    @property
+    def optimal_point(self) -> np.ndarray | None:
+        """Returns optimal point"""
+        return self._optimal_point
+
+    @optimal_point.setter
+    def optimal_point(self, value: np.ndarray) -> None:
+        """Sets optimal point"""
+        self._optimal_point = value
+
+    @property
+    def optimal_parameters(self) -> dict | None:
+        """Returns the optimal parameters in a dictionary"""
+        return self._optimal_parameters
+
+    @optimal_parameters.setter
+    def optimal_parameters(self, value: dict) -> None:
+        """Sets optimal parameters"""
+        self._optimal_parameters = value
+
+    @property
+    def optimizer_result(self) -> OptimizerResult | None:
+        """Returns the optimizer result"""
+        return self._optimizer_result
+
+    @optimizer_result.setter
+    def optimizer_result(self, value: OptimizerResult) -> None:
+        """Sets optimizer result"""
+        self._optimizer_result = value
+
+    @property
+    def optimal_circuit(self) -> QuantumCircuit:
+        """The optimal circuits. Along with the optimal parameters,
+        these can be used to retrieve the minimum eigenstate.
+        """
+        return self._optimal_circuit
+
+    @optimal_circuit.setter
+    def optimal_circuit(self, optimal_circuit: QuantumCircuit) -> None:
+        self._optimal_circuit = optimal_circuit
diff --git a/releasenotes/notes/qiskit-algorithms-incorporated-421554a4ff547d0d.yaml b/releasenotes/notes/qiskit-algorithms-incorporated-421554a4ff547d0d.yaml
new file mode 100644
index 000000000..3f284d6db
--- /dev/null
+++ b/releasenotes/notes/qiskit-algorithms-incorporated-421554a4ff547d0d.yaml
@@ -0,0 +1,105 @@
+---
+prelude: >
+    This release includes the migration of a subset of Qiskit Algorithms features to Qiskit Machine
+    Learning. This ensures continued enhancement of essential features for Qiskit Machine
+    Learning following the end of official support for Qiskit Algorithms. Therefore, Qiskit
+    Machine Learning will no longer depend on Qiskit Algorithms, possibly introducing breaking
+    changes in import structures. Some quick-fixes are described below.
+
+features:
+  - |
+    Migrated essential Qiskit Algorithms features to Qiskit Machine Learning:
+
+    - `qiskit_algorithms/gradients` -> `qiskit_machine_learning/gradients`.
+      Note:
+      only the SPSA, parameter-shift and linear-combination-of-unitaries gradients are retained.
+      Other gradient strategies, such as reverse and finite-diff are not incorporated.
+
+    - `qiskit_algorithms/optimizers` -> `qiskit_machine_learning/optimizers`.
+      Note:
+      optimizers from `scikit-quant <https://scikit-quant.readthedocs.io/en/latest/>`_ are not
+      incorporated.
+
+    - `qiskit_algorithms/state_fidelities` -> `qiskit_machine_learning/state_fidelities`
+
+    - Partial merge of `qiskit_algorithms/utils` with `qiskit_machine_learning/utils`
+
+    - From the next release, Qiskit Machine Learning will require Qiskit 1.0 or higher.
+      You may be required to upgrade Qiskit Aer accordingly, depending on your set-up.
+
+upgrade:
+  - |
+    The merge of some of the features of Qiskit Algorithms into Qiskit Machine Learning might lead
+    to breaking changes. For this reason, caution is advised when updating to version 0.8 during
+    critical production stages in a project.
+
+  - |
+    Users must update their imports and code references in code that uses Qiskit Machine Leaning
+    and Algorithms:
+
+    - Change `qiskit_algorithms.gradients` to `qiskit_machine_learning.gradients`
+
+    - Change `qiskit_algorithms.optimizers` to `qiskit_machine_learning.optimizers`
+
+    - Change `qiskit_algorithms.state_fidelities` to `qiskit_machine_learning.state_fidelities`
+
+    - Update utilities as needed due to partial merge.
+
+  - |
+    To continue using sub-modules and functionalities of Qiskit Algorithms that **have not been
+    transferred**, you may continue using them as before by importing from Qiskit Algorithms.
+    However, be aware that Qiskit Algorithms is no longer officially supported and some of its
+    functionalities may not work in your use case. For any problems directly related to Qiskit
+    Algorithms, please open a GitHub issue at https://github
+    .com/qiskit-community/qiskit-algorithms. Should you want to include a Qiskit Algorithms
+    functionality that has not been incorporated in Qiskit Machine Learning, please open a
+    feature-request issue at https://github.com/qiskit-community/qiskit-machine-learning,
+    explaining why this change would be useful for you and other users.
+
+  - |
+    Four examples of upgrading the code can be found below.
+
+    Gradients:
+
+    .. code:: python
+
+      # Before:
+      from qiskit_algorithms.gradients import SPSA, ParameterShift
+      # After:
+      from qiskit_machine_learning.gradients import SPSA, ParameterShift
+      # Usage
+      spsa = SPSA()
+      param_shift = ParameterShift()
+
+    Optimizers
+
+    .. code:: python
+
+      # Before:
+      from qiskit_algorithms.optimizers import COBYLA, ADAM
+      # After:
+      from qiskit_machine_learning.optimizers import COBYLA, ADAM
+      # Usage
+      cobyla = COBYLA()
+      adam = ADAM()
+
+    Quantum state fidelities
+
+    .. code:: python
+
+      # Before:
+      from qiskit_algorithms.state_fidelities import ComputeFidelity
+      # After:
+      from qiskit_machine_learning.state_fidelities import ComputeFidelity
+      # Usage
+      fidelity = ComputeFidelity()
+
+    Algorithm globals (used to fix the random seed)
+
+    .. code:: python
+
+      # Before:
+      from qiskit_algorithms.utils import algorithm_globals
+      # After:
+      from qiskit_machine_learning.utils import algorithm_globals
+      algorithm_globals.random_seed = 1234
diff --git a/requirements.txt b/requirements.txt
index bf8f50e3b..6436d0e69 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,5 +1,4 @@
-qiskit>=0.44
-qiskit-algorithms>=0.2.0
+qiskit>=1.0
 scipy>=1.4
 numpy>=1.17
 psutil>=5


Test patch:
diff --git a/test/__init__.py b/test/__init__.py
index f3216fbbf..3d89bce89 100644
--- a/test/__init__.py
+++ b/test/__init__.py
@@ -1,6 +1,6 @@
 # This code is part of a Qiskit project.
 #
-# (C) Copyright IBM 2020, 2023.
+# (C) Copyright IBM 2020, 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
@@ -13,5 +13,7 @@
 """ ML test packages """
 
 from .machine_learning_test_case import QiskitMachineLearningTestCase, gpu
+from .algorithms_test_case import QiskitAlgorithmsTestCase
+from .decorators import slow_test
 
-__all__ = ["QiskitMachineLearningTestCase", "gpu"]
+__all__ = ["QiskitMachineLearningTestCase", "gpu", "QiskitAlgorithmsTestCase", "slow_test"]
diff --git a/test/algorithms/classifiers/test_fidelity_quantum_kernel_pegasos_qsvc.py b/test/algorithms/classifiers/test_fidelity_quantum_kernel_pegasos_qsvc.py
index 9f4ef4d8d..123bf3aa6 100644
--- a/test/algorithms/classifiers/test_fidelity_quantum_kernel_pegasos_qsvc.py
+++ b/test/algorithms/classifiers/test_fidelity_quantum_kernel_pegasos_qsvc.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
@@ -19,10 +19,10 @@
 
 import numpy as np
 from qiskit.circuit.library import ZFeatureMap
-from qiskit_algorithms.utils import algorithm_globals
 from sklearn.datasets import make_blobs
 from sklearn.preprocessing import MinMaxScaler
 
+from qiskit_machine_learning.utils import algorithm_globals
 from qiskit_machine_learning.algorithms import PegasosQSVC, SerializableModelMixin
 from qiskit_machine_learning.kernels import FidelityQuantumKernel
 
diff --git a/test/algorithms/classifiers/test_fidelity_quantum_kernel_qsvc.py b/test/algorithms/classifiers/test_fidelity_quantum_kernel_qsvc.py
index 7d4003799..c56ad53e5 100644
--- a/test/algorithms/classifiers/test_fidelity_quantum_kernel_qsvc.py
+++ b/test/algorithms/classifiers/test_fidelity_quantum_kernel_qsvc.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
@@ -19,8 +19,8 @@
 
 import numpy as np
 from qiskit.circuit.library import ZZFeatureMap
-from qiskit_algorithms.utils import algorithm_globals
 
+from qiskit_machine_learning.utils import algorithm_globals
 from qiskit_machine_learning.algorithms import QSVC, SerializableModelMixin
 from qiskit_machine_learning.exceptions import QiskitMachineLearningWarning
 from qiskit_machine_learning.kernels import FidelityQuantumKernel
diff --git a/test/algorithms/classifiers/test_neural_network_classifier.py b/test/algorithms/classifiers/test_neural_network_classifier.py
index 532d35421..30930a9fe 100644
--- a/test/algorithms/classifiers/test_neural_network_classifier.py
+++ b/test/algorithms/classifiers/test_neural_network_classifier.py
@@ -1,6 +1,6 @@
 # This code is part of a Qiskit project.
 #
-# (C) Copyright IBM 2022, 2023.
+# (C) Copyright IBM 2022, 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
@@ -26,10 +26,10 @@
 from ddt import ddt, data, idata, unpack
 from qiskit.circuit import QuantumCircuit
 from qiskit.circuit.library import RealAmplitudes, ZZFeatureMap
-from qiskit_algorithms.optimizers import COBYLA, L_BFGS_B, SPSA, Optimizer
-from qiskit_algorithms.utils import algorithm_globals
 from scipy.optimize import minimize
 
+from qiskit_machine_learning.optimizers import COBYLA, L_BFGS_B, SPSA, Optimizer
+from qiskit_machine_learning.utils import algorithm_globals
 from qiskit_machine_learning.algorithms import SerializableModelMixin
 from qiskit_machine_learning.algorithms.classifiers import NeuralNetworkClassifier
 from qiskit_machine_learning.exceptions import QiskitMachineLearningError
@@ -61,9 +61,11 @@ def _create_optimizer(self, opt: str) -> Optimizer | None:
         if opt == "bfgs":
             optimizer = L_BFGS_B(maxiter=5)
         elif opt == "cobyla":
-            optimizer = COBYLA(maxiter=25)
+            optimizer = COBYLA(maxiter=25)  # type: ignore[assignment]
         elif opt == "callable":
-            optimizer = partial(minimize, method="COBYLA", options={"maxiter": 25})
+            optimizer = partial(
+                minimize, method="COBYLA", options={"maxiter": 25}  # type: ignore[assignment]
+            )
         else:
             optimizer = None
 
diff --git a/test/algorithms/classifiers/test_pegasos_qsvc.py b/test/algorithms/classifiers/test_pegasos_qsvc.py
index 622095b73..a6f888f4d 100644
--- a/test/algorithms/classifiers/test_pegasos_qsvc.py
+++ b/test/algorithms/classifiers/test_pegasos_qsvc.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
@@ -18,11 +18,12 @@
 from test import QiskitMachineLearningTestCase
 
 import numpy as np
-from qiskit.circuit.library import ZFeatureMap
-from qiskit_algorithms.utils import algorithm_globals
 from sklearn.datasets import make_blobs
 from sklearn.preprocessing import MinMaxScaler
 
+from qiskit.circuit.library import ZFeatureMap
+
+from qiskit_machine_learning.utils import algorithm_globals
 from qiskit_machine_learning.algorithms import PegasosQSVC, SerializableModelMixin
 from qiskit_machine_learning.kernels import FidelityQuantumKernel
 
diff --git a/test/algorithms/classifiers/test_qsvc.py b/test/algorithms/classifiers/test_qsvc.py
index 73fb83195..5b543c783 100644
--- a/test/algorithms/classifiers/test_qsvc.py
+++ b/test/algorithms/classifiers/test_qsvc.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
@@ -20,7 +20,7 @@
 import numpy as np
 
 from qiskit.circuit.library import ZZFeatureMap
-from qiskit_algorithms.utils import algorithm_globals
+from qiskit_machine_learning.utils import algorithm_globals
 from qiskit_machine_learning.algorithms import QSVC, SerializableModelMixin
 from qiskit_machine_learning.kernels import FidelityQuantumKernel
 from qiskit_machine_learning.exceptions import (
diff --git a/test/algorithms/classifiers/test_vqc.py b/test/algorithms/classifiers/test_vqc.py
index 4c80bd216..9d252e8c8 100644
--- a/test/algorithms/classifiers/test_vqc.py
+++ b/test/algorithms/classifiers/test_vqc.py
@@ -1,6 +1,6 @@
 # This code is part of a Qiskit project.
 #
-# (C) Copyright IBM 2018, 2023.
+# (C) Copyright IBM 2018, 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
@@ -29,9 +29,8 @@
 
 from qiskit import QuantumCircuit
 from qiskit.circuit.library import RealAmplitudes, ZZFeatureMap, ZFeatureMap
-from qiskit_algorithms.optimizers import COBYLA, L_BFGS_B
-from qiskit_algorithms.utils import algorithm_globals
-
+from qiskit_machine_learning.optimizers import COBYLA, L_BFGS_B
+from qiskit_machine_learning.utils import algorithm_globals
 from qiskit_machine_learning.algorithms import VQC
 from qiskit_machine_learning.exceptions import QiskitMachineLearningError
 
diff --git a/test/algorithms/inference/test_qbayesian.py b/test/algorithms/inference/test_qbayesian.py
index a4f5a2693..d0b114b8d 100644
--- a/test/algorithms/inference/test_qbayesian.py
+++ b/test/algorithms/inference/test_qbayesian.py
@@ -16,10 +16,12 @@
 from test import QiskitMachineLearningTestCase
 
 import numpy as np
-from qiskit_algorithms.utils import algorithm_globals
+
 from qiskit import QuantumCircuit
 from qiskit.circuit import QuantumRegister
 from qiskit.primitives import Sampler
+
+from qiskit_machine_learning.utils import algorithm_globals
 from qiskit_machine_learning.algorithms import QBayesian
 
 
diff --git a/test/algorithms/regressors/test_fidelity_quantum_kernel_qsvr.py b/test/algorithms/regressors/test_fidelity_quantum_kernel_qsvr.py
index 9d56532fd..50130d756 100644
--- a/test/algorithms/regressors/test_fidelity_quantum_kernel_qsvr.py
+++ b/test/algorithms/regressors/test_fidelity_quantum_kernel_qsvr.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
@@ -20,7 +20,7 @@
 import numpy as np
 from qiskit.circuit.library import ZZFeatureMap
 from qiskit.primitives import Sampler
-from qiskit_algorithms.utils import algorithm_globals
+from qiskit_machine_learning.utils import algorithm_globals
 
 from qiskit_machine_learning.algorithms import QSVR, SerializableModelMixin
 from qiskit_machine_learning.exceptions import QiskitMachineLearningWarning
diff --git a/test/algorithms/regressors/test_neural_network_regressor.py b/test/algorithms/regressors/test_neural_network_regressor.py
index 75eb1dfcc..a7c9cb225 100644
--- a/test/algorithms/regressors/test_neural_network_regressor.py
+++ b/test/algorithms/regressors/test_neural_network_regressor.py
@@ -1,6 +1,6 @@
 # This code is part of a Qiskit project.
 #
-# (C) Copyright IBM 2022, 2023.
+# (C) Copyright IBM 2022, 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
@@ -22,12 +22,13 @@
 
 import numpy as np
 from ddt import ddt, unpack, idata
+from scipy.optimize import minimize
+
 from qiskit.circuit import Parameter, QuantumCircuit
 from qiskit.circuit.library import ZZFeatureMap, RealAmplitudes
-from qiskit_algorithms.optimizers import COBYLA, L_BFGS_B, SPSA
-from qiskit_algorithms.utils import algorithm_globals
-from scipy.optimize import minimize
 
+from qiskit_machine_learning.optimizers import COBYLA, L_BFGS_B, SPSA
+from qiskit_machine_learning.utils import algorithm_globals
 from qiskit_machine_learning import QiskitMachineLearningError
 from qiskit_machine_learning.algorithms import SerializableModelMixin
 from qiskit_machine_learning.algorithms.regressors import NeuralNetworkRegressor
@@ -79,9 +80,11 @@ def _create_regressor(
         if opt == "bfgs":
             optimizer = L_BFGS_B(maxiter=5)
         elif opt == "cobyla":
-            optimizer = COBYLA(maxiter=25)
+            optimizer = COBYLA(maxiter=25)  # type: ignore[assignment]
         elif opt == "callable":
-            optimizer = partial(minimize, method="COBYLA", options={"maxiter": 25})
+            optimizer = partial(
+                minimize, method="COBYLA", options={"maxiter": 25}  # type: ignore[assignment]
+            )
         else:
             optimizer = None
 
diff --git a/test/algorithms/regressors/test_qsvr.py b/test/algorithms/regressors/test_qsvr.py
index 2f3a6ef21..5510ede5a 100644
--- a/test/algorithms/regressors/test_qsvr.py
+++ b/test/algorithms/regressors/test_qsvr.py
@@ -20,7 +20,7 @@
 import numpy as np
 
 from qiskit.circuit.library import ZZFeatureMap
-from qiskit_algorithms.utils import algorithm_globals
+from qiskit_machine_learning.utils import algorithm_globals
 from qiskit_machine_learning.algorithms import QSVR, SerializableModelMixin
 from qiskit_machine_learning.exceptions import QiskitMachineLearningWarning
 from qiskit_machine_learning.kernels import FidelityQuantumKernel
diff --git a/test/algorithms/regressors/test_vqr.py b/test/algorithms/regressors/test_vqr.py
index 3fe16683b..67dde8c03 100644
--- a/test/algorithms/regressors/test_vqr.py
+++ b/test/algorithms/regressors/test_vqr.py
@@ -1,6 +1,6 @@
 # This code is part of a Qiskit project.
 #
-# (C) Copyright IBM 2022, 2023.
+# (C) Copyright IBM 2022, 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
@@ -19,8 +19,8 @@
 from qiskit.circuit import Parameter, QuantumCircuit
 from qiskit.circuit.library import ZZFeatureMap, RealAmplitudes
 from qiskit.primitives import Estimator
-from qiskit_algorithms.optimizers import COBYLA, L_BFGS_B
-from qiskit_algorithms.utils import algorithm_globals
+from qiskit_machine_learning.optimizers import COBYLA, L_BFGS_B
+from qiskit_machine_learning.utils import algorithm_globals
 
 from qiskit_machine_learning.algorithms import VQR
 
diff --git a/test/algorithms_test_case.py b/test/algorithms_test_case.py
new file mode 100644
index 000000000..c8e5abe07
--- /dev/null
+++ b/test/algorithms_test_case.py
@@ -0,0 +1,88 @@
+# This code is part of a Qiskit project.
+#
+# (C) Copyright IBM 2018, 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
+# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
+#
+# Any modifications or derivative works of this code must retain this
+# copyright notice, and modified files need to carry a notice indicating
+# that they have been altered from the originals.
+
+"""Algorithms Test Case"""
+
+from typing import Optional
+from abc import ABC
+import warnings
+import inspect
+import logging
+import os
+import unittest
+import time
+
+from qiskit_machine_learning.utils import algorithm_globals
+
+# disable deprecation warnings that can cause log output overflow
+# pylint: disable=unused-argument
+
+
+def _noop(*args, **kargs):
+    pass
+
+
+# disable warning messages
+# warnings.warn = _noop
+
+
+class QiskitAlgorithmsTestCase(unittest.TestCase, ABC):
+    """Optimization Test Case"""
+
+    moduleName = None
+    log = None
+
+    def setUp(self) -> None:
+        warnings.filterwarnings("default", category=DeprecationWarning)
+        self._started_at = time.time()
+        self._class_location = __file__
+
+    def tearDown(self) -> None:
+        algorithm_globals.random_seed = None
+        elapsed = time.time() - self._started_at
+        if elapsed > 5.0:
+            print(f"({round(elapsed, 2):.2f}s)", flush=True)
+
+    @classmethod
+    def setUpClass(cls) -> None:
+        cls.moduleName = os.path.splitext(inspect.getfile(cls))[0]
+        cls.log = logging.getLogger(cls.__name__)
+
+        # Set logging to file and stdout if the LOG_LEVEL environment variable
+        # is set.
+        if os.getenv("LOG_LEVEL"):
+            # Set up formatter.
+            log_fmt = f"{cls.__name__}.%(funcName)s:%(levelname)s:%(asctime)s:" " %(message)s"
+            formatter = logging.Formatter(log_fmt)
+
+            # Set up the file handler.
+            log_file_name = f"{cls.moduleName}.log"
+            file_handler = logging.FileHandler(log_file_name)
+            file_handler.setFormatter(formatter)
+            cls.log.addHandler(file_handler)
+
+            # Set the logging level from the environment variable, defaulting
+            # to INFO if it is not a valid level.
+            level = logging._nameToLevel.get(os.getenv("LOG_LEVEL"), logging.INFO)
+            cls.log.setLevel(level)
+
+    def get_resource_path(self, filename: str, path: Optional[str] = None) -> str:
+        """Get the absolute path to a resource.
+        Args:
+            filename: filename or relative path to the resource.
+            path: path used as relative to the filename.
+        Returns:
+            str: the absolute path to the resource.
+        """
+        root = os.path.dirname(self._class_location)
+        path = root if path is None else os.path.join(root, path)
+        return os.path.normpath(os.path.join(path, filename))
diff --git a/test/circuit/library/test_raw_feature_vector.py b/test/circuit/library/test_raw_feature_vector.py
index 6763067e0..5b8ed9363 100644
--- a/test/circuit/library/test_raw_feature_vector.py
+++ b/test/circuit/library/test_raw_feature_vector.py
@@ -1,6 +1,6 @@
 # This code is part of a Qiskit project.
 #
-# (C) Copyright IBM 2020, 2023.
+# (C) Copyright IBM 2020, 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
@@ -22,8 +22,8 @@
 from qiskit.circuit.library import RealAmplitudes
 from qiskit.exceptions import QiskitError
 from qiskit.quantum_info import Statevector
-from qiskit_algorithms.optimizers import COBYLA
-from qiskit_algorithms.utils import algorithm_globals
+from qiskit_machine_learning.optimizers import COBYLA
+from qiskit_machine_learning.utils import algorithm_globals
 
 from qiskit_machine_learning.algorithms import VQC
 from qiskit_machine_learning.circuit.library import RawFeatureVector
diff --git a/test/connectors/test_torch.py b/test/connectors/test_torch.py
index f82558445..a9d4dde22 100644
--- a/test/connectors/test_torch.py
+++ b/test/connectors/test_torch.py
@@ -1,6 +1,6 @@
 # This code is part of a Qiskit project.
 #
-# (C) Copyright IBM 2022, 2023.
+# (C) Copyright IBM 2022, 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,7 +15,7 @@
 import unittest
 import builtins
 from abc import ABC, abstractmethod
-from qiskit_algorithms.utils import algorithm_globals
+from qiskit_machine_learning.utils import algorithm_globals
 import qiskit_machine_learning.optionals as _optionals
 
 
diff --git a/test/connectors/test_torch_connector.py b/test/connectors/test_torch_connector.py
index aeb63fb1c..385af7803 100644
--- a/test/connectors/test_torch_connector.py
+++ b/test/connectors/test_torch_connector.py
@@ -272,7 +272,6 @@ def __init__(
                 kernel_size: int = 3,
                 stride: int = 1,
             ):
-
                 super().__init__()
                 self.kernel_size = kernel_size
                 self.stride = stride
diff --git a/test/datasets/test_ad_hoc_data.py b/test/datasets/test_ad_hoc_data.py
index cb6392039..5f5376925 100644
--- a/test/datasets/test_ad_hoc_data.py
+++ b/test/datasets/test_ad_hoc_data.py
@@ -1,6 +1,6 @@
 # This code is part of a Qiskit project.
 #
-# (C) Copyright IBM 2020, 2023.
+# (C) Copyright IBM 2020, 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
@@ -20,7 +20,7 @@
 import numpy as np
 from ddt import ddt, unpack, idata
 
-from qiskit_algorithms.utils import algorithm_globals
+from qiskit_machine_learning.utils import algorithm_globals
 from qiskit_machine_learning.datasets import ad_hoc_data
 
 
diff --git a/test/decorators.py b/test/decorators.py
new file mode 100644
index 000000000..e14a3ba23
--- /dev/null
+++ b/test/decorators.py
@@ -0,0 +1,37 @@
+# This code is part of a Qiskit project.
+#
+# (C) Copyright IBM 2017, 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
+# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
+#
+# Any modifications or derivative works of this code must retain this
+# copyright notice, and modified files need to carry a notice indicating
+# that they have been altered from the originals.
+
+
+"""Decorator for using with unit tests."""
+
+import functools
+import os
+import unittest
+
+
+def slow_test(func):
+    """Decorator that signals that the test takes minutes to run.
+
+    Args:
+        func (callable): test function to be decorated.
+
+    Returns:
+        callable: the decorated function.
+    """
+
+    @functools.wraps(func)
+    def _wrapper(*args, **kwargs):
+        if "run_slow" in os.environ.get("QISKIT_TESTS", ""):
+            raise unittest.SkipTest("Skipping slow tests")
+        return func(*args, **kwargs)
+
+    return _wrapper
diff --git a/test/gradients/__init__.py b/test/gradients/__init__.py
new file mode 100644
index 000000000..e448b5e28
--- /dev/null
+++ b/test/gradients/__init__.py
@@ -0,0 +1,13 @@
+# This code is part of a Qiskit project.
+#
+# (C) Copyright IBM 2022, 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
+# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
+#
+# Any modifications or derivative works of this code must retain this
+# copyright notice, and modified files need to carry a notice indicating
+# that they have been altered from the originals.
+
+"""Tests for the primitive-based gradients"""
diff --git a/test/gradients/logging_primitives.py b/test/gradients/logging_primitives.py
new file mode 100644
index 000000000..f5ffeae0e
--- /dev/null
+++ b/test/gradients/logging_primitives.py
@@ -0,0 +1,42 @@
+# This code is part of a Qiskit project.
+#
+# (C) Copyright IBM 2022, 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
+# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
+#
+# Any modifications or derivative works of this code must retain this
+# copyright notice, and modified files need to carry a notice indicating
+# that they have been altered from the originals.
+
+"""Test primitives that check what kind of operations are in the circuits they execute."""
+
+from qiskit.primitives import Estimator, Sampler
+
+
+class LoggingEstimator(Estimator):
+    """An estimator checking what operations were in the circuits it executed."""
+
+    def __init__(self, options=None, operations_callback=None):
+        super().__init__(options=options)
+        self.operations_callback = operations_callback
+
+    def _run(self, circuits, observables, parameter_values, **run_options):
+        if self.operations_callback is not None:
+            ops = [circuit.count_ops() for circuit in circuits]
+            self.operations_callback(ops)
+        return super()._run(circuits, observables, parameter_values, **run_options)
+
+
+class LoggingSampler(Sampler):
+    """A sampler checking what operations were in the circuits it executed."""
+
+    def __init__(self, operations_callback):
+        super().__init__()
+        self.operations_callback = operations_callback
+
+    def _run(self, circuits, parameter_values, **run_options):
+        ops = [circuit.count_ops() for circuit in circuits]
+        self.operations_callback(ops)
+        return super()._run(circuits, parameter_values, **run_options)
diff --git a/test/gradients/test_estimator_gradient.py b/test/gradients/test_estimator_gradient.py
new file mode 100644
index 000000000..ab2a97fac
--- /dev/null
+++ b/test/gradients/test_estimator_gradient.py
@@ -0,0 +1,451 @@
+# This code is part of a Qiskit project.
+#
+# (C) Copyright IBM 2019, 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
+# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
+#
+# Any modifications or derivative works of this code must retain this
+# copyright notice, and modified files need to carry a notice indicating
+# that they have been altered from the originals.
+# =============================================================================
+
+"""Test Estimator Gradients"""
+
+import unittest
+from test import QiskitAlgorithmsTestCase
+
+import numpy as np
+from ddt import ddt, data
+
+from qiskit import QuantumCircuit
+from qiskit.circuit import Parameter
+from qiskit.circuit.library import EfficientSU2, RealAmplitudes
+from qiskit.circuit.library.standard_gates import RXXGate, RYYGate, RZXGate, RZZGate
+from qiskit.primitives import Estimator
+from qiskit.quantum_info import SparsePauliOp
+
+from qiskit_machine_learning.gradients import (
+    LinCombEstimatorGradient,
+    ParamShiftEstimatorGradient,
+    SPSAEstimatorGradient,
+)
+
+from .logging_primitives import LoggingEstimator
+
+gradient_factories = [
+    ParamShiftEstimatorGradient,
+    LinCombEstimatorGradient,
+]
+
+
+@ddt
+class TestEstimatorGradient(QiskitAlgorithmsTestCase):
+    """Test Estimator Gradient"""
+
+    @data(*gradient_factories)
+    def test_gradient_operators(self, grad):
+        """Test the estimator gradient for different operators"""
+        estimator = Estimator()
+        a = Parameter("a")
+        qc = QuantumCircuit(1)
+        qc.h(0)
+        qc.p(a, 0)
+        qc.h(0)
+        gradient = grad(estimator)
+        op = SparsePauliOp.from_list([("Z", 1)])
+        correct_result = -1 / np.sqrt(2)
+        param = [np.pi / 4]
+        value = gradient.run([qc], [op], [param]).result().gradients[0]
+        self.assertAlmostEqual(value[0], correct_result, 3)
+        op = SparsePauliOp.from_list([("Z", 1)])
+        value = gradient.run([qc], [op], [param]).result().gradients[0]
+        self.assertAlmostEqual(value[0], correct_result, 3)
+
+    @data(*gradient_factories)
+    def test_single_circuit_observable(self, grad):
+        """Test the estimator gradient for a single circuit and observable"""
+        estimator = Estimator()
+        a = Parameter("a")
+        qc = QuantumCircuit(1)
+        qc.h(0)
+        qc.p(a, 0)
+        qc.h(0)
+        gradient = grad(estimator)
+        op = SparsePauliOp.from_list([("Z", 1)])
+        correct_result = -1 / np.sqrt(2)
+        param = [np.pi / 4]
+        value = gradient.run(qc, op, [param]).result().gradients[0]
+        self.assertAlmostEqual(value[0], correct_result, 3)
+
+    @data(*gradient_factories)
+    def test_gradient_p(self, grad):
+        """Test the estimator gradient for p"""
+        estimator = Estimator()
+        a = Parameter("a")
+        qc = QuantumCircuit(1)
+        qc.h(0)
+        qc.p(a, 0)
+        qc.h(0)
+        gradient = grad(estimator)
+        op = SparsePauliOp.from_list([("Z", 1)])
+        param_list = [[np.pi / 4], [0], [np.pi / 2]]
+        correct_results = [[-1 / np.sqrt(2)], [0], [-1]]
+        for i, param in enumerate(param_list):
+            gradients = gradient.run([qc], [op], [param]).result().gradients[0]
+            for j, value in enumerate(gradients):
+                self.assertAlmostEqual(value, correct_results[i][j], 3)
+
+    @data(*gradient_factories)
+    def test_gradient_u(self, grad):
+        """Test the estimator gradient for u"""
+        estimator = Estimator()
+        a = Parameter("a")
+        b = Parameter("b")
+        c = Parameter("c")
+        qc = QuantumCircuit(1)
+        qc.h(0)
+        qc.u(a, b, c, 0)
+        qc.h(0)
+        gradient = grad(estimator)
+        op = SparsePauliOp.from_list([("Z", 1)])
+
+        param_list = [[np.pi / 4, 0, 0], [np.pi / 4, np.pi / 4, np.pi / 4]]
+        correct_results = [[-0.70710678, 0.0, 0.0], [-0.35355339, -0.85355339, -0.85355339]]
+        for i, param in enumerate(param_list):
+            gradients = gradient.run([qc], [op], [param]).result().gradients[0]
+            for j, value in enumerate(gradients):
+                self.assertAlmostEqual(value, correct_results[i][j], 3)
+
+    @data(*gradient_factories)
+    def test_gradient_efficient_su2(self, grad):
+        """Test the estimator gradient for EfficientSU2"""
+        estimator = Estimator()
+        qc = EfficientSU2(2, reps=1)
+        op = SparsePauliOp.from_list([("ZI", 1)])
+        gradient = grad(estimator)
+        param_list = [
+            [np.pi / 4 for param in qc.parameters],
+            [np.pi / 2 for param in qc.parameters],
+        ]
+        correct_results = [
+            [
+                -0.35355339,
+                -0.70710678,
+                0,
+                0.35355339,
+                0,
+                -0.70710678,
+                0,
+                0,
+            ],
+            [0, 0, 0, 1, 0, 0, 0, 0],
+        ]
+        for i, param in enumerate(param_list):
+            gradients = gradient.run([qc], [op], [param]).result().gradients[0]
+            np.testing.assert_allclose(gradients, correct_results[i], atol=1e-3)
+
+    @data(*gradient_factories)
+    def test_gradient_2qubit_gate(self, grad):
+        """Test the estimator gradient for 2 qubit gates"""
+        estimator = Estimator()
+        for gate in [RXXGate, RYYGate, RZZGate, RZXGate]:
+            param_list = [[np.pi / 4], [np.pi / 2]]
+            correct_results = [
+                [-0.70710678],
+                [-1],
+            ]
+            op = SparsePauliOp.from_list([("ZI", 1)])
+            for i, param in enumerate(param_list):
+                a = Parameter("a")
+                qc = QuantumCircuit(2)
+                gradient = grad(estimator)
+
+                if gate is RZZGate:
+                    qc.h([0, 1])
+                    qc.append(gate(a), [qc.qubits[0], qc.qubits[1]], [])
+                    qc.h([0, 1])
+                else:
+                    qc.append(gate(a), [qc.qubits[0], qc.qubits[1]], [])
+                gradients = gradient.run([qc], [op], [param]).result().gradients[0]
+                np.testing.assert_allclose(gradients, correct_results[i], atol=1e-3)
+
+    @data(*gradient_factories)
+    def test_gradient_parameter_coefficient(self, grad):
+        """Test the estimator gradient for parameter variables with coefficients"""
+        estimator = Estimator()
+        qc = RealAmplitudes(num_qubits=2, reps=1)
+        qc.rz(qc.parameters[0].exp() + 2 * qc.parameters[1], 0)
+        qc.rx(3.0 * qc.parameters[0] + qc.parameters[1].sin(), 1)
+        qc.u(qc.parameters[0], qc.parameters[1], qc.parameters[3], 1)
+        qc.p(2 * qc.parameters[0] + 1, 0)
+        qc.rxx(qc.parameters[0] + 2, 0, 1)
+        gradient = grad(estimator)
+        param_list = [[np.pi / 4 for _ in qc.parameters], [np.pi / 2 for _ in qc.parameters]]
+        correct_results = [
+            [-0.7266653, -0.4905135, -0.0068606, -0.9228880],
+            [-3.5972095, 0.10237173, -0.3117748, 0],
+        ]
+        op = SparsePauliOp.from_list([("ZI", 1)])
+        for i, param in enumerate(param_list):
+            gradients = gradient.run([qc], [op], [param]).result().gradients[0]
+            np.testing.assert_allclose(gradients, correct_results[i], atol=1e-3)
+
+    @data(*gradient_factories)
+    def test_gradient_parameters(self, grad):
+        """Test the estimator gradient for parameters"""
+        estimator = Estimator()
+        a = Parameter("a")
+        b = Parameter("b")
+        qc = QuantumCircuit(1)
+        qc.rx(a, 0)
+        qc.rx(b, 0)
+        gradient = grad(estimator)
+        param_list = [[np.pi / 4, np.pi / 2]]
+        correct_results = [
+            [-0.70710678],
+        ]
+        op = SparsePauliOp.from_list([("Z", 1)])
+        for i, param in enumerate(param_list):
+            gradients = gradient.run([qc], [op], [param], parameters=[[a]]).result().gradients[0]
+            np.testing.assert_allclose(gradients, correct_results[i], atol=1e-3)
+
+        # parameter order
+        with self.subTest(msg="The order of gradients"):
+            c = Parameter("c")
+            qc = QuantumCircuit(1)
+            qc.rx(a, 0)
+            qc.rz(b, 0)
+            qc.rx(c, 0)
+
+            param_list = [[np.pi / 4, np.pi / 2, np.pi / 3]]
+            correct_results = [
+                [-0.35355339, 0.61237244, -0.61237244],
+                [-0.61237244, 0.61237244, -0.35355339],
+                [-0.35355339, -0.61237244],
+                [-0.61237244, -0.35355339],
+            ]
+            param = [[a, b, c], [c, b, a], [a, c], [c, a]]
+            op = SparsePauliOp.from_list([("Z", 1)])
+            for i, p in enumerate(param):  # pylint: disable=invalid-name
+                gradient = grad(estimator)
+                gradients = (
+                    gradient.run([qc], [op], param_list, parameters=[p]).result().gradients[0]
+                )
+                np.testing.assert_allclose(gradients, correct_results[i], atol=1e-3)
+
+    @data(*gradient_factories)
+    def test_gradient_multi_arguments(self, grad):
+        """Test the estimator gradient for multiple arguments"""
+        estimator = Estimator()
+        a = Parameter("a")
+        b = Parameter("b")
+        qc = QuantumCircuit(1)
+        qc.rx(a, 0)
+        qc2 = QuantumCircuit(1)
+        qc2.rx(b, 0)
+        gradient = grad(estimator)
+        param_list = [[np.pi / 4], [np.pi / 2]]
+        correct_results = [
+            [-0.70710678],
+            [-1],
+        ]
+        op = SparsePauliOp.from_list([("Z", 1)])
+        gradients = gradient.run([qc, qc2], [op] * 2, param_list).result().gradients
+        np.testing.assert_allclose(gradients, correct_results, atol=1e-3)
+
+        c = Parameter("c")
+        qc3 = QuantumCircuit(1)
+        qc3.rx(c, 0)
+        qc3.ry(a, 0)
+        param_list2 = [[np.pi / 4], [np.pi / 4, np.pi / 4], [np.pi / 4, np.pi / 4]]
+        correct_results2 = [
+            [-0.70710678],
+            [-0.5],
+            [-0.5, -0.5],
+        ]
+        gradients2 = (
+            gradient.run([qc, qc3, qc3], [op] * 3, param_list2, parameters=[[a], [c], None])
+            .result()
+            .gradients
+        )
+        np.testing.assert_allclose(gradients2[0], correct_results2[0], atol=1e-3)
+        np.testing.assert_allclose(gradients2[1], correct_results2[1], atol=1e-3)
+        np.testing.assert_allclose(gradients2[2], correct_results2[2], atol=1e-3)
+
+    @data(*gradient_factories)
+    def test_gradient_validation(self, grad):
+        """Test estimator gradient's validation"""
+        estimator = Estimator()
+        a = Parameter("a")
+        qc = QuantumCircuit(1)
+        qc.rx(a, 0)
+        gradient = grad(estimator)
+        param_list = [[np.pi / 4], [np.pi / 2]]
+        op = SparsePauliOp.from_list([("Z", 1)])
+        with self.assertRaises(ValueError):
+            gradient.run([qc], [op], param_list)
+        with self.assertRaises(ValueError):
+            gradient.run([qc, qc], [op, op], param_list, parameters=[[a]])
+        with self.assertRaises(ValueError):
+            gradient.run([qc, qc], [op], param_list, parameters=[[a]])
+        with self.assertRaises(ValueError):
+            gradient.run([qc], [op], [[np.pi / 4, np.pi / 4]])
+
+    def test_spsa_gradient(self):
+        """Test the SPSA estimator gradient"""
+        estimator = Estimator()
+        with self.assertRaises(ValueError):
+            _ = SPSAEstimatorGradient(estimator, epsilon=-0.1)
+        a = Parameter("a")
+        b = Parameter("b")
+        qc = QuantumCircuit(2)
+        qc.rx(b, 0)
+        qc.rx(a, 1)
+        param_list = [[1, 1]]
+        correct_results = [[-0.84147098, 0.84147098]]
+        op = SparsePauliOp.from_list([("ZI", 1)])
+        gradient = SPSAEstimatorGradient(estimator, epsilon=1e-6, seed=123)
+        gradients = gradient.run([qc], [op], param_list).result().gradients
+        np.testing.assert_allclose(gradients, correct_results, atol=1e-3)
+
+        # multi parameters
+        with self.subTest(msg="Multiple parameters"):
+            gradient = SPSAEstimatorGradient(estimator, epsilon=1e-6, seed=123)
+            param_list2 = [[1, 1], [1, 1], [3, 3]]
+            gradients2 = (
+                gradient.run([qc] * 3, [op] * 3, param_list2, parameters=[None, [b], None])
+                .result()
+                .gradients
+            )
+            correct_results2 = [[-0.84147098, 0.84147098], [0.84147098], [-0.14112001, 0.14112001]]
+            for grad, correct in zip(gradients2, correct_results2):
+                np.testing.assert_allclose(grad, correct, atol=1e-3)
+
+        # batch size
+        with self.subTest(msg="Batch size"):
+            correct_results = [[-0.84147098, 0.1682942]]
+            gradient = SPSAEstimatorGradient(estimator, epsilon=1e-6, batch_size=5, seed=123)
+            gradients = gradient.run([qc], [op], param_list).result().gradients
+            np.testing.assert_allclose(gradients, correct_results, atol=1e-3)
+
+        # parameter order
+        with self.subTest(msg="The order of gradients"):
+            gradient = SPSAEstimatorGradient(estimator, epsilon=1e-6, seed=123)
+            c = Parameter("c")
+            qc = QuantumCircuit(1)
+            qc.rx(a, 0)
+            qc.rz(b, 0)
+            qc.rx(c, 0)
+            op = SparsePauliOp.from_list([("Z", 1)])
+            param_list3 = [[np.pi / 4, np.pi / 2, np.pi / 3]]
+            param = [[a, b, c], [c, b, a], [a, c], [c, a]]
+            expected = [
+                [-0.3535525, 0.3535525, 0.3535525],
+                [0.3535525, 0.3535525, -0.3535525],
+                [-0.3535525, 0.3535525],
+                [0.3535525, -0.3535525],
+            ]
+            for i, p in enumerate(param):  # pylint: disable=invalid-name
+                gradient = SPSAEstimatorGradient(estimator, epsilon=1e-6, seed=123)
+                gradients = (
+                    gradient.run([qc], [op], param_list3, parameters=[p]).result().gradients[0]
+                )
+                np.testing.assert_allclose(gradients, expected[i], atol=1e-3)
+
+    @data(
+        ParamShiftEstimatorGradient,
+        LinCombEstimatorGradient,
+        SPSAEstimatorGradient,
+    )
+    def test_options(self, grad):
+        """Test estimator gradient's run options"""
+        a = Parameter("a")
+        qc = QuantumCircuit(1)
+        qc.rx(a, 0)
+        op = SparsePauliOp.from_list([("Z", 1)])
+        estimator = Estimator(options={"shots": 100})
+        with self.subTest("estimator"):
+            if grad is SPSAEstimatorGradient:
+                gradient = grad(estimator, epsilon=1e-6)
+            else:
+                gradient = grad(estimator)
+            options = gradient.options
+            result = gradient.run([qc], [op], [[1]]).result()
+            self.assertEqual(result.options.get("shots"), 100)
+            self.assertEqual(options.get("shots"), 100)
+
+        with self.subTest("gradient init"):
+            if grad is SPSAEstimatorGradient:
+                gradient = grad(estimator, epsilon=1e-6, options={"shots": 200})
+            else:
+                gradient = grad(estimator, options={"shots": 200})
+            options = gradient.options
+            result = gradient.run([qc], [op], [[1]]).result()
+            self.assertEqual(result.options.get("shots"), 200)
+            self.assertEqual(options.get("shots"), 200)
+
+        with self.subTest("gradient update"):
+            if grad is SPSAEstimatorGradient:
+                gradient = grad(estimator, epsilon=1e-6, options={"shots": 200})
+            else:
+                gradient = grad(estimator, options={"shots": 200})
+            gradient.update_default_options(shots=100)
+            options = gradient.options
+            result = gradient.run([qc], [op], [[1]]).result()
+            self.assertEqual(result.options.get("shots"), 100)
+            self.assertEqual(options.get("shots"), 100)
+
+        with self.subTest("gradient run"):
+            if grad is SPSAEstimatorGradient:
+                gradient = grad(estimator, epsilon=1e-6, options={"shots": 200})
+            else:
+                gradient = grad(estimator, options={"shots": 200})
+            options = gradient.options
+            result = gradient.run([qc], [op], [[1]], shots=300).result()
+            self.assertEqual(result.options.get("shots"), 300)
+            # Only default + estimator options. Not run.
+            self.assertEqual(options.get("shots"), 200)
+
+    @data(
+        ParamShiftEstimatorGradient,
+        LinCombEstimatorGradient,
+        SPSAEstimatorGradient,
+    )
+    def test_operations_preserved(self, gradient_cls):
+        """Test non-parameterized instructions are preserved and not unrolled."""
+        x = Parameter("x")
+        circuit = QuantumCircuit(2)
+        circuit.initialize([0.5, 0.5, 0.5, 0.5])  # this should remain as initialize
+        circuit.crx(x, 0, 1)  # this should get unrolled
+
+        values = [np.pi / 2]
+        expect = -1 / (2 * np.sqrt(2))
+
+        observable = SparsePauliOp(["XX"])
+
+        ops = []
+
+        def operations_callback(op):
+            ops.append(op)
+
+        estimator = LoggingEstimator(operations_callback=operations_callback)
+
+        if gradient_cls in [SPSAEstimatorGradient]:
+            gradient = gradient_cls(estimator, epsilon=0.01)
+        else:
+            gradient = gradient_cls(estimator)
+
+        job = gradient.run([circuit], [observable], [values])
+        result = job.result()
+
+        with self.subTest(msg="assert initialize is preserved"):
+            self.assertTrue(all("initialize" in ops_i[0].keys() for ops_i in ops))
+
+        with self.subTest(msg="assert result is correct"):
+            self.assertAlmostEqual(result.gradients[0].item(), expect, places=5)
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/test/gradients/test_qfi.py b/test/gradients/test_qfi.py
new file mode 100644
index 000000000..8acbcaf09
--- /dev/null
+++ b/test/gradients/test_qfi.py
@@ -0,0 +1,151 @@
+# This code is part of a Qiskit project.
+#
+# (C) Copyright IBM 2022, 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
+# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
+#
+# Any modifications or derivative works of this code must retain this
+# copyright notice, and modified files need to carry a notice indicating
+# that they have been altered from the originals.
+# =============================================================================
+
+"""Test QFI."""
+
+import unittest
+from test import QiskitAlgorithmsTestCase
+
+from ddt import ddt, data
+import numpy as np
+
+from qiskit import QuantumCircuit
+from qiskit.circuit import Parameter
+from qiskit.circuit.parametervector import ParameterVector
+from qiskit.primitives import Estimator
+
+from qiskit_machine_learning.gradients import LinCombQGT, QFI, DerivativeType
+
+
+@ddt
+class TestQFI(QiskitAlgorithmsTestCase):
+    """Test QFI"""
+
+    def setUp(self):
+        super().setUp()
+        self.estimator = Estimator()
+        self.lcu_qgt = LinCombQGT(self.estimator, derivative_type=DerivativeType.REAL)
+
+    def test_qfi(self):
+        """Test if the quantum fisher information calculation is correct for a simple test case.
+        QFI = [[1, 0], [0, 1]] - [[0, 0], [0, cos^2(a)]]
+        """
+        # create the circuit
+        a, b = Parameter("a"), Parameter("b")
+        qc = QuantumCircuit(1)
+        qc.h(0)
+        qc.rz(a, 0)
+        qc.rx(b, 0)
+
+        param_list = [[np.pi / 4, 0.1], [np.pi, 0.1], [np.pi / 2, 0.1]]
+        correct_values = [[[1, 0], [0, 0.5]], [[1, 0], [0, 0]], [[1, 0], [0, 1]]]
+
+        qfi = QFI(self.lcu_qgt)
+        for i, param in enumerate(param_list):
+            qfis = qfi.run([qc], [param]).result().qfis
+            np.testing.assert_allclose(qfis[0], correct_values[i], atol=1e-3)
+
+    def test_qfi_phase_fix(self):
+        """Test the phase-fix argument in the QFI calculation"""
+        # create the circuit
+        a, b = Parameter("a"), Parameter("b")
+        qc = QuantumCircuit(1)
+        qc.h(0)
+        qc.rz(a, 0)
+        qc.rx(b, 0)
+
+        param = [np.pi / 4, 0.1]
+        # test for different values
+        correct_values = [[1, 0], [0, 1]]
+        qgt = LinCombQGT(self.estimator, phase_fix=False)
+        qfi = QFI(qgt)
+        qfis = qfi.run([qc], [param]).result().qfis
+        np.testing.assert_allclose(qfis[0], correct_values, atol=1e-3)
+
+    @data("lcu")
+    def test_qfi_maxcut(self, qgt_kind):  # pylint: disable=unused-argument
+        """Test the QFI for a simple MaxCut problem.
+
+        This is interesting because it contains the same parameters in different gates.
+        """
+        # create maxcut circuit for the hamiltonian
+        # H = (I ^ I ^ Z ^ Z) + (I ^ Z ^ I ^ Z) + (Z ^ I ^ I ^ Z) + (I ^ Z ^ Z ^ I)
+
+        x = ParameterVector("x", 2)
+        ansatz = QuantumCircuit(4)
+
+        # initial hadamard layer
+        ansatz.h(ansatz.qubits)
+
+        # e^{iZZ} layers
+        def expiz(qubit0, qubit1):
+            ansatz.cx(qubit0, qubit1)
+            ansatz.rz(2 * x[0], qubit1)
+            ansatz.cx(qubit0, qubit1)
+
+        expiz(2, 1)
+        expiz(3, 0)
+        expiz(2, 0)
+        expiz(1, 0)
+
+        # mixer layer with RX gates
+        for i in range(ansatz.num_qubits):
+            ansatz.rx(2 * x[1], i)
+
+        reference = np.array([[16.0, -5.551], [-5.551, 18.497]])
+        param = [0.4, 0.69]
+
+        qgt = self.lcu_qgt
+        qfi = QFI(qgt)
+        qfi_result = qfi.run([ansatz], [param]).result().qfis
+        np.testing.assert_array_almost_equal(qfi_result[0], reference, decimal=3)
+
+    def test_options(self):
+        """Test QFI's options"""
+        a = Parameter("a")
+        qc = QuantumCircuit(1)
+        qc.rx(a, 0)
+        qgt = LinCombQGT(estimator=self.estimator, options={"shots": 100})
+
+        with self.subTest("QGT"):
+            qfi = QFI(qgt=qgt)
+            options = qfi.options
+            result = qfi.run([qc], [[1]]).result()
+            self.assertEqual(result.options.get("shots"), 100)
+            self.assertEqual(options.get("shots"), 100)
+
+        with self.subTest("QFI init"):
+            qfi = QFI(qgt=qgt, options={"shots": 200})
+            result = qfi.run([qc], [[1]]).result()
+            options = qfi.options
+            self.assertEqual(result.options.get("shots"), 200)
+            self.assertEqual(options.get("shots"), 200)
+
+        with self.subTest("QFI update"):
+            qfi = QFI(qgt, options={"shots": 200})
+            qfi.update_default_options(shots=100)
+            options = qfi.options
+            result = qfi.run([qc], [[1]]).result()
+            self.assertEqual(result.options.get("shots"), 100)
+            self.assertEqual(options.get("shots"), 100)
+
+        with self.subTest("QFI run"):
+            qfi = QFI(qgt=qgt, options={"shots": 200})
+            result = qfi.run([qc], [[0]], shots=300).result()
+            options = qfi.options
+            self.assertEqual(result.options.get("shots"), 300)
+            self.assertEqual(options.get("shots"), 200)
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/test/gradients/test_qgt.py b/test/gradients/test_qgt.py
new file mode 100644
index 000000000..cdab18353
--- /dev/null
+++ b/test/gradients/test_qgt.py
@@ -0,0 +1,310 @@
+# This code is part of a Qiskit project.
+#
+# (C) Copyright IBM 2022, 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
+# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
+#
+# Any modifications or derivative works of this code must retain this
+# copyright notice, and modified files need to carry a notice indicating
+# that they have been altered from the originals.
+# =============================================================================
+
+"""Test QGT."""
+
+import unittest
+from test import QiskitAlgorithmsTestCase
+
+from ddt import ddt, data
+import numpy as np
+
+from qiskit import QuantumCircuit
+from qiskit.circuit import Parameter
+from qiskit.circuit.library import RealAmplitudes
+from qiskit.primitives import Estimator
+
+from qiskit_machine_learning.gradients import DerivativeType, LinCombQGT
+
+from .logging_primitives import LoggingEstimator
+
+
+@ddt
+class TestQGT(QiskitAlgorithmsTestCase):
+    """Test QGT"""
+
+    def setUp(self):
+        super().setUp()
+        self.estimator = Estimator()
+
+    @data(LinCombQGT)
+    def test_qgt_derivative_type(self, qgt_type):
+        """Test QGT derivative_type"""
+        args = (self.estimator,)
+        qgt = qgt_type(*args, derivative_type=DerivativeType.REAL)
+
+        a, b = Parameter("a"), Parameter("b")
+        qc = QuantumCircuit(1)
+        qc.h(0)
+        qc.rz(a, 0)
+        qc.rx(b, 0)
+
+        param_list = [[np.pi / 4, 0], [np.pi / 2, np.pi / 4]]
+        correct_values = [
+            np.array([[1, 0.707106781j], [-0.707106781j, 0.5]]) / 4,
+            np.array([[1, 1j], [-1j, 1]]) / 4,
+        ]
+
+        # test real derivative
+        with self.subTest("Test with DerivativeType.REAL"):
+            qgt.derivative_type = DerivativeType.REAL
+            for i, param in enumerate(param_list):
+                qgt_result = qgt.run([qc], [param]).result().qgts
+                np.testing.assert_allclose(qgt_result[0], correct_values[i].real, atol=1e-3)
+
+        # test imaginary derivative
+        with self.subTest("Test with DerivativeType.IMAG"):
+            qgt.derivative_type = DerivativeType.IMAG
+            for i, param in enumerate(param_list):
+                qgt_result = qgt.run([qc], [param]).result().qgts
+                np.testing.assert_allclose(qgt_result[0], correct_values[i].imag, atol=1e-3)
+
+        # test real + imaginary derivative
+        with self.subTest("Test with DerivativeType.COMPLEX"):
+            qgt.derivative_type = DerivativeType.COMPLEX
+            for i, param in enumerate(param_list):
+                qgt_result = qgt.run([qc], [param]).result().qgts
+                np.testing.assert_allclose(qgt_result[0], correct_values[i], atol=1e-3)
+
+    @data(LinCombQGT)
+    def test_qgt_phase_fix(self, qgt_type):
+        """Test the phase-fix argument in a QGT calculation"""
+        args = (self.estimator,)
+        qgt = qgt_type(*args, phase_fix=False)
+
+        # create the circuit
+        a, b = Parameter("a"), Parameter("b")
+        qc = QuantumCircuit(1)
+        qc.h(0)
+        qc.rz(a, 0)
+        qc.rx(b, 0)
+
+        param_list = [[np.pi / 4, 0], [np.pi / 2, np.pi / 4]]
+        correct_values = [
+            np.array([[1, 0.707106781j], [-0.707106781j, 1]]) / 4,
+            np.array([[1, 1j], [-1j, 1]]) / 4,
+        ]
+
+        # test real derivative
+        with self.subTest("Test phase fix with DerivativeType.REAL"):
+            qgt.derivative_type = DerivativeType.REAL
+            for i, param in enumerate(param_list):
+                qgt_result = qgt.run([qc], [param]).result().qgts
+                np.testing.assert_allclose(qgt_result[0], correct_values[i].real, atol=1e-3)
+
+        # test imaginary derivative
+        with self.subTest("Test phase fix with DerivativeType.IMAG"):
+            qgt.derivative_type = DerivativeType.IMAG
+            for i, param in enumerate(param_list):
+                qgt_result = qgt.run([qc], [param]).result().qgts
+                np.testing.assert_allclose(qgt_result[0], correct_values[i].imag, atol=1e-3)
+
+        # test real + imaginary derivative
+        with self.subTest("Test phase fix with DerivativeType.COMPLEX"):
+            qgt.derivative_type = DerivativeType.COMPLEX
+            for i, param in enumerate(param_list):
+                qgt_result = qgt.run([qc], [param]).result().qgts
+                np.testing.assert_allclose(qgt_result[0], correct_values[i], atol=1e-3)
+
+    @data(LinCombQGT)
+    def test_qgt_coefficients(self, qgt_type):
+        """Test the derivative option of QGT"""
+        args = (self.estimator,)
+        qgt = qgt_type(*args, derivative_type=DerivativeType.REAL)
+
+        qc = RealAmplitudes(num_qubits=2, reps=1)
+        qc.rz(qc.parameters[0].exp() + 2 * qc.parameters[1], 0)
+        qc.rx(3.0 * qc.parameters[2] + qc.parameters[3].sin(), 1)
+
+        # test imaginary derivative
+        param_list = [
+            [np.pi / 4 for param in qc.parameters],
+            [np.pi / 2 for param in qc.parameters],
+        ]
+        correct_values = (
+            np.array(
+                [
+                    [
+                        [5.707309, 4.2924833, 1.5295868, 0.1938604],
+                        [4.2924833, 4.9142136, 0.75, 0.8838835],
+                        [1.5295868, 0.75, 3.4430195, 0.0758252],
+                        [0.1938604, 0.8838835, 0.0758252, 1.1357233],
+                    ],
+                    [
+                        [1.0, 0.0, 1.0, 0.0],
+                        [0.0, 1.0, 0.0, 0.0],
+                        [1.0, 0.0, 10.0, -0.0],
+                        [0.0, 0.0, -0.0, 1.0],
+                    ],
+                ]
+            )
+            / 4
+        )
+        for i, param in enumerate(param_list):
+            qgt_result = qgt.run([qc], [param]).result().qgts
+            np.testing.assert_allclose(qgt_result[0], correct_values[i], atol=1e-3)
+
+    @data(LinCombQGT)
+    def test_qgt_parameters(self, qgt_type):
+        """Test the QGT with specified parameters"""
+        args = (self.estimator,)
+        qgt = qgt_type(*args, derivative_type=DerivativeType.REAL)
+
+        a = Parameter("a")
+        b = Parameter("b")
+        qc = QuantumCircuit(1)
+        qc.rx(a, 0)
+        qc.ry(b, 0)
+        param_values = [np.pi / 4, np.pi / 4]
+        qgt_result = qgt.run([qc], [param_values], [[a]]).result().qgts
+        np.testing.assert_allclose(qgt_result[0], [[1 / 4]], atol=1e-3)
+
+        with self.subTest("Test with different parameter orders"):
+            c = Parameter("c")
+            qc2 = QuantumCircuit(1)
+            qc2.rx(a, 0)
+            qc2.rz(b, 0)
+            qc2.rx(c, 0)
+            param_values = [np.pi / 4, np.pi / 4, np.pi / 4]
+            params = [[a, b, c], [c, b, a], [a, c], [b, a]]
+            expected = [
+                np.array(
+                    [
+                        [0.25, 0.0, 0.1767767],
+                        [0.0, 0.125, -0.08838835],
+                        [0.1767767, -0.08838835, 0.1875],
+                    ]
+                ),
+                np.array(
+                    [
+                        [0.1875, -0.08838835, 0.1767767],
+                        [-0.08838835, 0.125, 0.0],
+                        [0.1767767, 0.0, 0.25],
+                    ]
+                ),
+                np.array([[0.25, 0.1767767], [0.1767767, 0.1875]]),
+                np.array([[0.125, 0.0], [0.0, 0.25]]),
+            ]
+            for i, param in enumerate(params):
+                qgt_result = qgt.run([qc2], [param_values], [param]).result().qgts
+                np.testing.assert_allclose(qgt_result[0], expected[i], atol=1e-3)
+
+    @data(LinCombQGT)
+    def test_qgt_multi_arguments(self, qgt_type):
+        """Test the QGT for multiple arguments"""
+        args = (self.estimator,)
+        qgt = qgt_type(*args, derivative_type=DerivativeType.REAL)
+
+        a = Parameter("a")
+        b = Parameter("b")
+        qc = QuantumCircuit(1)
+        qc.rx(a, 0)
+        qc.ry(b, 0)
+        qc2 = QuantumCircuit(1)
+        qc2.rx(a, 0)
+        qc2.ry(b, 0)
+
+        param_list = [[np.pi / 4], [np.pi / 2]]
+        correct_values = [[[1 / 4]], [[1 / 4, 0], [0, 0]]]
+        param_list = [[np.pi / 4, np.pi / 4], [np.pi / 2, np.pi / 2]]
+        qgt_results = qgt.run([qc, qc2], param_list, [[a], None]).result().qgts
+        for i, _ in enumerate(param_list):
+            np.testing.assert_allclose(qgt_results[i], correct_values[i], atol=1e-3)
+
+    @data(LinCombQGT)
+    def test_qgt_validation(self, qgt_type):
+        """Test estimator QGT's validation"""
+        args = (self.estimator,)
+        qgt = qgt_type(*args)
+
+        a = Parameter("a")
+        qc = QuantumCircuit(1)
+        qc.rx(a, 0)
+        parameter_values = [[np.pi / 4]]
+        with self.subTest("assert number of circuits does not match"):
+            with self.assertRaises(ValueError):
+                qgt.run([qc, qc], parameter_values)
+        with self.subTest("assert number of parameter values does not match"):
+            with self.assertRaises(ValueError):
+                qgt.run([qc], [[np.pi / 4], [np.pi / 2]])
+        with self.subTest("assert number of parameters does not match"):
+            with self.assertRaises(ValueError):
+                qgt.run([qc], parameter_values, parameters=[[a], [a]])
+
+    def test_options(self):
+        """Test QGT's options"""
+        a = Parameter("a")
+        qc = QuantumCircuit(1)
+        qc.rx(a, 0)
+        estimator = Estimator(options={"shots": 100})
+
+        with self.subTest("estimator"):
+            qgt = LinCombQGT(estimator)
+            options = qgt.options
+            result = qgt.run([qc], [[1]]).result()
+            self.assertEqual(result.options.get("shots"), 100)
+            self.assertEqual(options.get("shots"), 100)
+
+        with self.subTest("QGT init"):
+            qgt = LinCombQGT(estimator, options={"shots": 200})
+            result = qgt.run([qc], [[1]]).result()
+            options = qgt.options
+            self.assertEqual(result.options.get("shots"), 200)
+            self.assertEqual(options.get("shots"), 200)
+
+        with self.subTest("QGT update"):
+            qgt = LinCombQGT(estimator, options={"shots": 200})
+            qgt.update_default_options(shots=100)
+            options = qgt.options
+            result = qgt.run([qc], [[1]]).result()
+            self.assertEqual(result.options.get("shots"), 100)
+            self.assertEqual(options.get("shots"), 100)
+
+        with self.subTest("QGT run"):
+            qgt = LinCombQGT(estimator, options={"shots": 200})
+            result = qgt.run([qc], [[0]], shots=300).result()
+            options = qgt.options
+            self.assertEqual(result.options.get("shots"), 300)
+            self.assertEqual(options.get("shots"), 200)
+
+    def test_operations_preserved(self):
+        """Test non-parameterized instructions are preserved and not unrolled."""
+        x, y = Parameter("x"), Parameter("y")
+        circuit = QuantumCircuit(2)
+        circuit.initialize([0.5, 0.5, 0.5, 0.5])  # this should remain as initialize
+        circuit.crx(x, 0, 1)  # this should get unrolled
+        circuit.ry(y, 0)
+
+        values = [np.pi / 2, np.pi]
+        expect = np.diag([0.25, 0.5]) / 4
+
+        ops = []
+
+        def operations_callback(op):
+            ops.append(op)
+
+        estimator = LoggingEstimator(operations_callback=operations_callback)
+        qgt = LinCombQGT(estimator, derivative_type=DerivativeType.REAL)
+
+        job = qgt.run([circuit], [values])
+        result = job.result()
+
+        with self.subTest(msg="assert initialize is preserved"):
+            self.assertTrue(all("initialize" in ops_i[0].keys() for ops_i in ops))
+
+        with self.subTest(msg="assert result is correct"):
+            np.testing.assert_allclose(result.qgts[0], expect, atol=1e-5)
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/test/gradients/test_sampler_gradient.py b/test/gradients/test_sampler_gradient.py
new file mode 100644
index 000000000..d586dbe4b
--- /dev/null
+++ b/test/gradients/test_sampler_gradient.py
@@ -0,0 +1,644 @@
+# This code is part of a Qiskit project.
+#
+# (C) Copyright IBM 2019, 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
+# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
+#
+# Any modifications or derivative works of this code must retain this
+# copyright notice, and modified files need to carry a notice indicating
+# that they have been altered from the originals.
+# =============================================================================
+
+"""Test Sampler Gradients"""
+
+import unittest
+from test import QiskitAlgorithmsTestCase
+from typing import List
+
+import numpy as np
+from ddt import ddt, data
+
+from qiskit import QuantumCircuit
+from qiskit.circuit import Parameter
+from qiskit.circuit.library import EfficientSU2, RealAmplitudes
+from qiskit.circuit.library.standard_gates import RXXGate
+from qiskit.primitives import Sampler
+from qiskit.result import QuasiDistribution
+
+from qiskit_machine_learning.gradients import (
+    LinCombSamplerGradient,
+    ParamShiftSamplerGradient,
+    SPSASamplerGradient,
+)
+
+from .logging_primitives import LoggingSampler
+
+gradient_factories = [
+    ParamShiftSamplerGradient,
+    LinCombSamplerGradient,
+]
+
+
+@ddt
+class TestSamplerGradient(QiskitAlgorithmsTestCase):
+    """Test Sampler Gradient"""
+
+    @data(*gradient_factories)
+    def test_single_circuit(self, grad):
+        """Test the sampler gradient for a single circuit"""
+        sampler = Sampler()
+        a = Parameter("a")
+        qc = QuantumCircuit(1)
+        qc.h(0)
+        qc.p(a, 0)
+        qc.h(0)
+        qc.measure_all()
+        gradient = grad(sampler)
+        param_list = [[np.pi / 4], [0], [np.pi / 2]]
+        expected = [
+            [{0: -0.5 / np.sqrt(2), 1: 0.5 / np.sqrt(2)}],
+            [{0: 0, 1: 0}],
+            [{0: -0.499999, 1: 0.499999}],
+        ]
+        for i, param in enumerate(param_list):
+            gradients = gradient.run([qc], [param]).result().gradients[0]
+            array1 = _quasi2array(gradients, num_qubits=1)
+            array2 = _quasi2array(expected[i], num_qubits=1)
+            np.testing.assert_allclose(array1, array2, atol=1e-3)
+
+    @data(*gradient_factories)
+    def test_gradient_p(self, grad):
+        """Test the sampler gradient for p"""
+        sampler = Sampler()
+        a = Parameter("a")
+        qc = QuantumCircuit(1)
+        qc.h(0)
+        qc.p(a, 0)
+        qc.h(0)
+        qc.measure_all()
+        gradient = grad(sampler)
+        param_list = [[np.pi / 4], [0], [np.pi / 2]]
+        expected = [
+            [{0: -0.5 / np.sqrt(2), 1: 0.5 / np.sqrt(2)}],
+            [{0: 0, 1: 0}],
+            [{0: -0.499999, 1: 0.499999}],
+        ]
+        for i, param in enumerate(param_list):
+            gradients = gradient.run([qc], [param]).result().gradients[0]
+            array1 = _quasi2array(gradients, num_qubits=1)
+            array2 = _quasi2array(expected[i], num_qubits=1)
+            np.testing.assert_allclose(array1, array2, atol=1e-3)
+
+    @data(*gradient_factories)
+    def test_gradient_u(self, grad):
+        """Test the sampler gradient for u"""
+        sampler = Sampler()
+        a = Parameter("a")
+        b = Parameter("b")
+        c = Parameter("c")
+        qc = QuantumCircuit(1)
+        qc.h(0)
+        qc.u(a, b, c, 0)
+        qc.h(0)
+        qc.measure_all()
+        gradient = grad(sampler)
+        param_list = [[np.pi / 4, 0, 0], [np.pi / 4, np.pi / 4, np.pi / 4]]
+        expected = [
+            [{0: -0.5 / np.sqrt(2), 1: 0.5 / np.sqrt(2)}, {0: 0, 1: 0}, {0: 0, 1: 0}],
+            [{0: -0.176777, 1: 0.176777}, {0: -0.426777, 1: 0.426777}, {0: -0.426777, 1: 0.426777}],
+        ]
+        for i, param in enumerate(param_list):
+            gradients = gradient.run([qc], [param]).result().gradients[0]
+            array1 = _quasi2array(gradients, num_qubits=1)
+            array2 = _quasi2array(expected[i], num_qubits=1)
+            np.testing.assert_allclose(array1, array2, atol=1e-3)
+
+    @data(*gradient_factories)
+    def test_gradient_efficient_su2(self, grad):
+        """Test the sampler gradient for EfficientSU2"""
+        sampler = Sampler()
+        qc = EfficientSU2(2, reps=1)
+        qc.measure_all()
+        gradient = grad(sampler)
+        param_list = [
+            [np.pi / 4 for param in qc.parameters],
+            [np.pi / 2 for param in qc.parameters],
+        ]
+        expected = [
+            [
+                {
+                    0: -0.11963834764831836,
+                    1: -0.05713834764831845,
+                    2: -0.21875000000000003,
+                    3: 0.39552669529663675,
+                },
+                {
+                    0: -0.32230339059327373,
+                    1: -0.031250000000000014,
+                    2: 0.2339150429449554,
+                    3: 0.11963834764831843,
+                },
+                {
+                    0: 0.012944173824159189,
+                    1: -0.01294417382415923,
+                    2: 0.07544417382415919,
+                    3: -0.07544417382415919,
+                },
+                {
+                    0: 0.2080266952966367,
+                    1: -0.03125000000000002,
+                    2: -0.11963834764831842,
+                    3: -0.057138347648318405,
+                },
+                {
+                    0: -0.11963834764831838,
+                    1: 0.11963834764831838,
+                    2: -0.21875000000000003,
+                    3: 0.21875,
+                },
+                {
+                    0: -0.2781092167691146,
+                    1: -0.0754441738241592,
+                    2: 0.27810921676911443,
+                    3: 0.07544417382415924,
+                },
+                {0: 0.0, 1: 0.0, 2: 0.0, 3: 0.0},
+                {0: 0.0, 1: 0.0, 2: 0.0, 3: 0.0},
+            ],
+            [
+                {
+                    0: -4.163336342344337e-17,
+                    1: 2.7755575615628914e-17,
+                    2: -4.163336342344337e-17,
+                    3: 0.0,
+                },
+                {0: 0.0, 1: -1.3877787807814457e-17, 2: 4.163336342344337e-17, 3: 0.0},
+                {
+                    0: -0.24999999999999994,
+                    1: 0.24999999999999994,
+                    2: 0.24999999999999994,
+                    3: -0.24999999999999994,
+                },
+                {
+                    0: 0.24999999999999994,
+                    1: 0.24999999999999994,
+                    2: -0.24999999999999994,
+                    3: -0.24999999999999994,
+                },
+                {
+                    0: -4.163336342344337e-17,
+                    1: 4.163336342344337e-17,
+                    2: -4.163336342344337e-17,
+                    3: 5.551115123125783e-17,
+                },
+                {
+                    0: -0.24999999999999994,
+                    1: 0.24999999999999994,
+                    2: 0.24999999999999994,
+                    3: -0.24999999999999994,
+                },
+                {0: 0.0, 1: 2.7755575615628914e-17, 2: 0.0, 3: 2.7755575615628914e-17},
+                {0: 0.0, 1: 0.0, 2: 0.0, 3: 0.0},
+            ],
+        ]
+        for i, param in enumerate(param_list):
+            gradients = gradient.run([qc], [param]).result().gradients[0]
+            array1 = _quasi2array(gradients, num_qubits=2)
+            array2 = _quasi2array(expected[i], num_qubits=2)
+            np.testing.assert_allclose(array1, array2, atol=1e-3)
+
+    @data(*gradient_factories)
+    def test_gradient_2qubit_gate(self, grad):
+        """Test the sampler gradient for 2 qubit gates"""
+        sampler = Sampler()
+        for gate in [RXXGate]:
+            param_list = [[np.pi / 4], [np.pi / 2]]
+            correct_results = [
+                [{0: -0.5 / np.sqrt(2), 1: 0, 2: 0, 3: 0.5 / np.sqrt(2)}],
+                [{0: -0.5, 1: 0, 2: 0, 3: 0.5}],
+            ]
+            for i, param in enumerate(param_list):
+                a = Parameter("a")
+                qc = QuantumCircuit(2)
+                qc.append(gate(a), [qc.qubits[0], qc.qubits[1]], [])
+                qc.measure_all()
+                gradient = grad(sampler)
+                gradients = gradient.run([qc], [param]).result().gradients[0]
+                array1 = _quasi2array(gradients, num_qubits=2)
+                array2 = _quasi2array(correct_results[i], num_qubits=2)
+                np.testing.assert_allclose(array1, array2, atol=1e-3)
+
+    @data(*gradient_factories)
+    def test_gradient_parameter_coefficient(self, grad):
+        """Test the sampler gradient for parameter variables with coefficients"""
+        sampler = Sampler()
+        qc = RealAmplitudes(num_qubits=2, reps=1)
+        qc.rz(qc.parameters[0].exp() + 2 * qc.parameters[1], 0)
+        qc.rx(3.0 * qc.parameters[0] + qc.parameters[1].sin(), 1)
+        qc.u(qc.parameters[0], qc.parameters[1], qc.parameters[3], 1)
+        qc.p(2 * qc.parameters[0] + 1, 0)
+        qc.rxx(qc.parameters[0] + 2, 0, 1)
+        qc.measure_all()
+        gradient = grad(sampler)
+        param_list = [[np.pi / 4 for _ in qc.parameters], [np.pi / 2 for _ in qc.parameters]]
+        correct_results = [
+            [
+                {
+                    0: 0.30014831912265927,
+                    1: -0.6634809704357856,
+                    2: 0.343589357193753,
+                    3: 0.019743294119373426,
+                },
+                {
+                    0: 0.16470607453981906,
+                    1: -0.40996282450610577,
+                    2: 0.08791803062881773,
+                    3: 0.15733871933746948,
+                },
+                {
+                    0: 0.27036068339663866,
+                    1: -0.273790986018701,
+                    2: 0.12752010079553433,
+                    3: -0.12408979817347202,
+                },
+                {
+                    0: -0.2098616294167757,
+                    1: -0.2515823946449894,
+                    2: 0.21929102305386305,
+                    3: 0.24215300100790207,
+                },
+            ],
+            [
+                {
+                    0: -1.844810060881004,
+                    1: 0.04620532700836027,
+                    2: 1.6367366426074323,
+                    3: 0.16186809126521057,
+                },
+                {
+                    0: 0.07296073407769421,
+                    1: -0.021774869186331716,
+                    2: 0.02177486918633173,
+                    3: -0.07296073407769456,
+                },
+                {
+                    0: -0.07794369186049102,
+                    1: -0.07794369186049122,
+                    2: 0.07794369186049117,
+                    3: 0.07794369186049112,
+                },
+                {
+                    0: 0.0,
+                    1: 0.0,
+                    2: 0.0,
+                    3: 0.0,
+                },
+            ],
+        ]
+
+        for i, param in enumerate(param_list):
+            gradients = gradient.run([qc], [param]).result().gradients[0]
+            array1 = _quasi2array(gradients, num_qubits=2)
+            array2 = _quasi2array(correct_results[i], num_qubits=2)
+            np.testing.assert_allclose(array1, array2, atol=1e-3)
+
+    @data(*gradient_factories)
+    def test_gradient_parameters(self, grad):
+        """Test the sampler gradient for parameters"""
+        sampler = Sampler()
+        a = Parameter("a")
+        b = Parameter("b")
+        qc = QuantumCircuit(1)
+        qc.rx(a, 0)
+        qc.rz(b, 0)
+        qc.measure_all()
+        gradient = grad(sampler)
+        param_list = [[np.pi / 4, np.pi / 2]]
+        expected = [
+            [{0: -0.5 / np.sqrt(2), 1: 0.5 / np.sqrt(2)}],
+        ]
+        for i, param in enumerate(param_list):
+            gradients = gradient.run([qc], [param], parameters=[[a]]).result().gradients[0]
+            array1 = _quasi2array(gradients, num_qubits=1)
+            array2 = _quasi2array(expected[i], num_qubits=1)
+            np.testing.assert_allclose(array1, array2, atol=1e-3)
+
+        # parameter order
+        with self.subTest(msg="The order of gradients"):
+            c = Parameter("c")
+            qc = QuantumCircuit(1)
+            qc.rx(a, 0)
+            qc.rz(b, 0)
+            qc.rx(c, 0)
+            qc.measure_all()
+            param_values = [[np.pi / 4, np.pi / 2, np.pi / 3]]
+            params = [[a, b, c], [c, b, a], [a, c], [c, a]]
+            expected = [
+                [
+                    {0: -0.17677666583387008, 1: 0.17677666583378482},
+                    {0: 0.3061861668168149, 1: -0.3061861668167012},
+                    {0: -0.3061861668168149, 1: 0.30618616681678645},
+                ],
+                [
+                    {0: -0.3061861668168149, 1: 0.30618616681678645},
+                    {0: 0.3061861668168149, 1: -0.3061861668167012},
+                    {0: -0.17677666583387008, 1: 0.17677666583378482},
+                ],
+                [
+                    {0: -0.17677666583387008, 1: 0.17677666583378482},
+                    {0: -0.3061861668168149, 1: 0.30618616681678645},
+                ],
+                [
+                    {0: -0.3061861668168149, 1: 0.30618616681678645},
+                    {0: -0.17677666583387008, 1: 0.17677666583378482},
+                ],
+            ]
+            for i, p in enumerate(params):  # pylint: disable=invalid-name
+                gradients = gradient.run([qc], param_values, parameters=[p]).result().gradients[0]
+                array1 = _quasi2array(gradients, num_qubits=1)
+                array2 = _quasi2array(expected[i], num_qubits=1)
+                np.testing.assert_allclose(array1, array2, atol=1e-3)
+
+    @data(*gradient_factories)
+    def test_gradient_multi_arguments(self, grad):
+        """Test the sampler gradient for multiple arguments"""
+        sampler = Sampler()
+        a = Parameter("a")
+        b = Parameter("b")
+        qc = QuantumCircuit(1)
+        qc.rx(a, 0)
+        qc.measure_all()
+        qc2 = QuantumCircuit(1)
+        qc2.rx(b, 0)
+        qc2.measure_all()
+        gradient = grad(sampler)
+        param_list = [[np.pi / 4], [np.pi / 2]]
+        correct_results = [
+            [{0: -0.5 / np.sqrt(2), 1: 0.5 / np.sqrt(2)}],
+            [{0: -0.499999, 1: 0.499999}],
+        ]
+        gradients = gradient.run([qc, qc2], param_list).result().gradients
+        for i, q_dists in enumerate(gradients):
+            array1 = _quasi2array(q_dists, num_qubits=1)
+            array2 = _quasi2array(correct_results[i], num_qubits=1)
+            np.testing.assert_allclose(array1, array2, atol=1e-3)
+
+        # parameters
+        with self.subTest(msg="Different parameters"):
+            c = Parameter("c")
+            qc3 = QuantumCircuit(1)
+            qc3.rx(c, 0)
+            qc3.ry(a, 0)
+            qc3.measure_all()
+            param_list2 = [[np.pi / 4], [np.pi / 4, np.pi / 4], [np.pi / 4, np.pi / 4]]
+            gradients = (
+                gradient.run([qc, qc3, qc3], param_list2, parameters=[[a], [c], None])
+                .result()
+                .gradients
+            )
+            correct_results = [
+                [{0: -0.5 / np.sqrt(2), 1: 0.5 / np.sqrt(2)}],
+                [{0: -0.25, 1: 0.25}],
+                [{0: -0.25, 1: 0.25}, {0: -0.25, 1: 0.25}],
+            ]
+            for i, q_dists in enumerate(gradients):
+                array1 = _quasi2array(q_dists, num_qubits=1)
+                array2 = _quasi2array(correct_results[i], num_qubits=1)
+                np.testing.assert_allclose(array1, array2, atol=1e-3)
+
+    @data(*gradient_factories)
+    def test_gradient_validation(self, grad):
+        """Test sampler gradient's validation"""
+        sampler = Sampler()
+        a = Parameter("a")
+        qc = QuantumCircuit(1)
+        qc.rx(a, 0)
+        qc.measure_all()
+        gradient = grad(sampler)
+        param_list = [[np.pi / 4], [np.pi / 2]]
+        with self.assertRaises(ValueError):
+            gradient.run([qc], param_list)
+        with self.assertRaises(ValueError):
+            gradient.run([qc, qc], param_list, parameters=[[a]])
+        with self.assertRaises(ValueError):
+            gradient.run([qc], [[np.pi / 4, np.pi / 4]])
+
+    def test_spsa_gradient(self):
+        """Test the SPSA sampler gradient"""
+        sampler = Sampler()
+        with self.assertRaises(ValueError):
+            _ = SPSASamplerGradient(sampler, epsilon=-0.1)
+
+        a = Parameter("a")
+        b = Parameter("b")
+        c = Parameter("c")
+        qc = QuantumCircuit(2)
+        qc.rx(b, 0)
+        qc.rx(a, 1)
+        qc.measure_all()
+        param_list = [[1, 2]]
+        correct_results = [
+            [
+                {0: 0.2273244, 1: -0.6480598, 2: 0.2273244, 3: 0.1934111},
+                {0: -0.2273244, 1: 0.6480598, 2: -0.2273244, 3: -0.1934111},
+            ],
+        ]
+        gradient = SPSASamplerGradient(sampler, epsilon=1e-6, seed=123)
+        for i, param in enumerate(param_list):
+            gradients = gradient.run([qc], [param]).result().gradients[0]
+            array1 = _quasi2array(gradients, num_qubits=2)
+            array2 = _quasi2array(correct_results[i], num_qubits=2)
+            np.testing.assert_allclose(array1, array2, atol=1e-3)
+
+        # multi parameters
+        with self.subTest(msg="Multiple parameters"):
+            param_list2 = [[1, 2], [1, 2], [3, 4]]
+            correct_results2 = [
+                [
+                    {0: 0.2273244, 1: -0.6480598, 2: 0.2273244, 3: 0.1934111},
+                    {0: -0.2273244, 1: 0.6480598, 2: -0.2273244, 3: -0.1934111},
+                ],
+                [
+                    {0: -0.2273244, 1: 0.6480598, 2: -0.2273244, 3: -0.1934111},
+                ],
+                [
+                    {0: -0.0141129, 1: -0.0564471, 2: -0.3642884, 3: 0.4348484},
+                    {0: 0.0141129, 1: 0.0564471, 2: 0.3642884, 3: -0.4348484},
+                ],
+            ]
+            gradient = SPSASamplerGradient(sampler, epsilon=1e-6, seed=123)
+            gradients = (
+                gradient.run([qc] * 3, param_list2, parameters=[None, [b], None]).result().gradients
+            )
+            for i, result in enumerate(gradients):
+                array1 = _quasi2array(result, num_qubits=2)
+                array2 = _quasi2array(correct_results2[i], num_qubits=2)
+                np.testing.assert_allclose(array1, array2, atol=1e-3)
+
+        # batch size
+        with self.subTest(msg="Batch size"):
+            param_list = [[1, 1]]
+            gradient = SPSASamplerGradient(sampler, epsilon=1e-6, batch_size=4, seed=123)
+            gradients = gradient.run([qc], param_list).result().gradients
+            correct_results3 = [
+                [
+                    {
+                        0: -0.1620149622932887,
+                        1: -0.25872053011771756,
+                        2: 0.3723827084675668,
+                        3: 0.04835278392088804,
+                    },
+                    {
+                        0: -0.1620149622932887,
+                        1: 0.3723827084675668,
+                        2: -0.25872053011771756,
+                        3: 0.04835278392088804,
+                    },
+                ]
+            ]
+            for i, q_dists in enumerate(gradients):
+                array1 = _quasi2array(q_dists, num_qubits=2)
+                array2 = _quasi2array(correct_results3[i], num_qubits=2)
+                np.testing.assert_allclose(array1, array2, atol=1e-3)
+
+        # parameter order
+        with self.subTest(msg="The order of gradients"):
+            qc = QuantumCircuit(1)
+            qc.rx(a, 0)
+            qc.rz(b, 0)
+            qc.rx(c, 0)
+            qc.measure_all()
+            param_list = [[np.pi / 4, np.pi / 2, np.pi / 3]]
+            param = [[a, b, c], [c, b, a], [a, c], [c, a]]
+            correct_results = [
+                [
+                    {0: -0.17677624757590138, 1: 0.17677624757590138},
+                    {0: 0.17677624757590138, 1: -0.17677624757590138},
+                    {0: 0.17677624757590138, 1: -0.17677624757590138},
+                ],
+                [
+                    {0: 0.17677624757590138, 1: -0.17677624757590138},
+                    {0: 0.17677624757590138, 1: -0.17677624757590138},
+                    {0: -0.17677624757590138, 1: 0.17677624757590138},
+                ],
+                [
+                    {0: -0.17677624757590138, 1: 0.17677624757590138},
+                    {0: 0.17677624757590138, 1: -0.17677624757590138},
+                ],
+                [
+                    {0: 0.17677624757590138, 1: -0.17677624757590138},
+                    {0: -0.17677624757590138, 1: 0.17677624757590138},
+                ],
+            ]
+            for i, p in enumerate(param):  # pylint: disable=invalid-name
+                gradient = SPSASamplerGradient(sampler, epsilon=1e-6, seed=123)
+                gradients = gradient.run([qc], param_list, parameters=[p]).result().gradients[0]
+                array1 = _quasi2array(gradients, num_qubits=1)
+                array2 = _quasi2array(correct_results[i], num_qubits=1)
+                np.testing.assert_allclose(array1, array2, atol=1e-3)
+
+    @data(
+        ParamShiftSamplerGradient,
+        LinCombSamplerGradient,
+        SPSASamplerGradient,
+    )
+    def test_options(self, grad):
+        """Test sampler gradient's run options"""
+        a = Parameter("a")
+        qc = QuantumCircuit(1)
+        qc.rx(a, 0)
+        qc.measure_all()
+        sampler = Sampler(options={"shots": 100})
+        with self.subTest("sampler"):
+            if grad is SPSASamplerGradient:
+                gradient = grad(sampler, epsilon=1e-6)
+            else:
+                gradient = grad(sampler)
+            options = gradient.options
+            result = gradient.run([qc], [[1]]).result()
+            self.assertEqual(result.options.get("shots"), 100)
+            self.assertEqual(options.get("shots"), 100)
+
+        with self.subTest("gradient init"):
+            if grad is SPSASamplerGradient:
+                gradient = grad(sampler, epsilon=1e-6, options={"shots": 200})
+            else:
+                gradient = grad(sampler, options={"shots": 200})
+            options = gradient.options
+            result = gradient.run([qc], [[1]]).result()
+            self.assertEqual(result.options.get("shots"), 200)
+            self.assertEqual(options.get("shots"), 200)
+
+        with self.subTest("gradient update"):
+            if grad is SPSASamplerGradient:
+                gradient = grad(sampler, epsilon=1e-6, options={"shots": 200})
+            else:
+                gradient = grad(sampler, options={"shots": 200})
+            gradient.update_default_options(shots=100)
+            options = gradient.options
+            result = gradient.run([qc], [[1]]).result()
+            self.assertEqual(result.options.get("shots"), 100)
+            self.assertEqual(options.get("shots"), 100)
+
+        with self.subTest("gradient run"):
+            if grad is SPSASamplerGradient:
+                gradient = grad(sampler, epsilon=1e-6, options={"shots": 200})
+            else:
+                gradient = grad(sampler, options={"shots": 200})
+            options = gradient.options
+            result = gradient.run([qc], [[1]], shots=300).result()
+            self.assertEqual(result.options.get("shots"), 300)
+            # Only default + sampler options. Not run.
+            self.assertEqual(options.get("shots"), 200)
+
+    @data(
+        ParamShiftSamplerGradient,
+        LinCombSamplerGradient,
+        SPSASamplerGradient,
+    )
+    def test_operations_preserved(self, gradient_cls):
+        """Test non-parameterized instructions are preserved and not unrolled."""
+        x = Parameter("x")
+        circuit = QuantumCircuit(2)
+        circuit.initialize(np.array([1, 1, 0, 0]) / np.sqrt(2))  # this should remain as initialize
+        circuit.crx(x, 0, 1)  # this should get unrolled
+        circuit.measure_all()
+
+        values = [np.pi / 2]
+        expect = [{0: 0, 1: -0.25, 2: 0, 3: 0.25}]
+
+        ops = []
+
+        def operations_callback(op):
+            ops.append(op)
+
+        sampler = LoggingSampler(operations_callback=operations_callback)
+
+        if gradient_cls in [SPSASamplerGradient]:
+            gradient = gradient_cls(sampler, epsilon=0.01)
+        else:
+            gradient = gradient_cls(sampler)
+
+        job = gradient.run([circuit], [values])
+        result = job.result()
+
+        with self.subTest(msg="assert initialize is preserved"):
+            self.assertTrue(all("initialize" in ops_i[0].keys() for ops_i in ops))
+
+        with self.subTest(msg="assert result is correct"):
+            array1 = _quasi2array(result.gradients[0], num_qubits=2)
+            array2 = _quasi2array(expect, num_qubits=2)
+            np.testing.assert_allclose(array1, array2, atol=1e-5)
+
+
+def _quasi2array(quasis: List[QuasiDistribution], num_qubits: int) -> np.ndarray:
+    ret = np.zeros((len(quasis), 2**num_qubits))
+    for i, quasi in enumerate(quasis):
+        ret[i, list(quasi.keys())] = list(quasi.values())
+    return ret
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/test/kernels/algorithms/test_fidelity_qkernel_trainer.py b/test/kernels/algorithms/test_fidelity_qkernel_trainer.py
index f324da78c..d6e08ed93 100644
--- a/test/kernels/algorithms/test_fidelity_qkernel_trainer.py
+++ b/test/kernels/algorithms/test_fidelity_qkernel_trainer.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
@@ -20,12 +20,13 @@
 
 from ddt import ddt, data
 import numpy as np
+from scipy.optimize import minimize
+
 from qiskit import QuantumCircuit
 from qiskit.circuit import Parameter, ParameterVector
 from qiskit.circuit.library import ZZFeatureMap
-from qiskit_algorithms.utils import algorithm_globals
-from scipy.optimize import minimize
 
+from qiskit_machine_learning.utils import algorithm_globals
 from qiskit_machine_learning.algorithms.classifiers import QSVC
 from qiskit_machine_learning.kernels import (
     TrainableFidelityQuantumKernel,
diff --git a/test/kernels/test_fidelity_qkernel.py b/test/kernels/test_fidelity_qkernel.py
index 6a132f14e..51b9a5b45 100644
--- a/test/kernels/test_fidelity_qkernel.py
+++ b/test/kernels/test_fidelity_qkernel.py
@@ -22,19 +22,20 @@
 
 import numpy as np
 from ddt import ddt, idata, unpack
+from sklearn.svm import SVC
+
 from qiskit import QuantumCircuit
 from qiskit.circuit import Parameter
 from qiskit.circuit.library import ZFeatureMap
 from qiskit.primitives import Sampler
-from qiskit_algorithms import AlgorithmJob
-from qiskit_algorithms.utils import algorithm_globals
-from qiskit_algorithms.state_fidelities import (
+
+from qiskit_machine_learning.algorithm_job import AlgorithmJob
+from qiskit_machine_learning.utils import algorithm_globals
+from qiskit_machine_learning.state_fidelities import (
     ComputeUncompute,
     BaseStateFidelity,
     StateFidelityResult,
 )
-from sklearn.svm import SVC
-
 from qiskit_machine_learning.kernels import FidelityQuantumKernel
 
 
@@ -278,7 +279,7 @@ def create_fidelity_circuit(
             ) -> QuantumCircuit:
                 raise NotImplementedError()
 
-            def _run(
+            def _run(  # type: ignore[override]
                 self,
                 circuits_1: QuantumCircuit | Sequence[QuantumCircuit],
                 circuits_2: QuantumCircuit | Sequence[QuantumCircuit],
@@ -289,24 +290,11 @@ def _run(
                 values = np.asarray(values_1)
                 fidelities = np.full(values.shape[0], -0.5)
 
-                # Qiskit algorithms changed the internals of the base state fidelity
-                # class and what this method returns to avoid a threading issue. See
-                # https://github.com/qiskit-community/qiskit-algorithms/pull/92 for
-                # more information. That pull request will land in 0.3.0. I made
-                # this test work with the current released version 0.2.2, so tests
-                # pass at present, but in the future this logic can be reduced to
-                # just that needed for 0.3.0 and above if desired when testing against
-                # earlier algorithm versions is no longer needed or wanted.
-                from qiskit_algorithms import __version__ as algs_version
-
-                if algs_version < "0.3.0":
-                    return StateFidelityResult(fidelities, [], {}, options)
-                else:
-                    return AlgorithmJob(MockFidelity._call, fidelities, options)
+                return AlgorithmJob(MockFidelity._call, fidelities, options)
 
             @staticmethod
             def _call(fidelities, options) -> StateFidelityResult:
-                return StateFidelityResult(fidelities, [], {}, options)
+                return StateFidelityResult(fidelities, [], {}, options)  # type: ignore[arg-type]
 
         with self.subTest("No PSD enforcement"):
             kernel = FidelityQuantumKernel(fidelity=MockFidelity(), enforce_psd=False)
diff --git a/test/kernels/test_fidelity_statevector_kernel.py b/test/kernels/test_fidelity_statevector_kernel.py
index 5366aed91..cd92b8c23 100644
--- a/test/kernels/test_fidelity_statevector_kernel.py
+++ b/test/kernels/test_fidelity_statevector_kernel.py
@@ -29,7 +29,7 @@
 from qiskit.circuit import Parameter
 from qiskit.circuit.library import ZFeatureMap
 from qiskit.utils import optionals
-from qiskit_algorithms.utils import algorithm_globals
+from qiskit_machine_learning.utils import algorithm_globals
 
 from qiskit_machine_learning.kernels import FidelityStatevectorKernel
 
diff --git a/test/neural_networks/test_effective_dimension.py b/test/neural_networks/test_effective_dimension.py
index c304df0b3..ce3944548 100644
--- a/test/neural_networks/test_effective_dimension.py
+++ b/test/neural_networks/test_effective_dimension.py
@@ -1,6 +1,6 @@
 # This code is part of a Qiskit project.
 #
-# (C) Copyright IBM 2022, 2023.
+# (C) Copyright IBM 2022, 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
@@ -20,7 +20,7 @@
 
 from qiskit.circuit import QuantumCircuit
 from qiskit.circuit.library import ZFeatureMap, RealAmplitudes
-from qiskit_algorithms.utils import algorithm_globals
+from qiskit_machine_learning.utils import algorithm_globals
 
 from qiskit_machine_learning.neural_networks import (
     EffectiveDimension,
diff --git a/test/neural_networks/test_sampler_qnn.py b/test/neural_networks/test_sampler_qnn.py
index 219b6c567..8084b5109 100644
--- a/test/neural_networks/test_sampler_qnn.py
+++ b/test/neural_networks/test_sampler_qnn.py
@@ -1,6 +1,6 @@
 # This code is part of a Qiskit project.
 #
-# (C) Copyright IBM 2022, 2023.
+# (C) Copyright IBM 2022, 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
@@ -24,7 +24,7 @@
 from qiskit.circuit import Parameter, QuantumCircuit
 from qiskit.primitives import Sampler
 from qiskit.circuit.library import RealAmplitudes, ZZFeatureMap
-from qiskit_algorithms.utils import algorithm_globals
+from qiskit_machine_learning.utils import algorithm_globals
 
 from qiskit_machine_learning.circuit.library import QNNCircuit
 from qiskit_machine_learning.neural_networks.sampler_qnn import SamplerQNN
diff --git a/test/optimizers/__init__.py b/test/optimizers/__init__.py
new file mode 100644
index 000000000..c9d7aa94a
--- /dev/null
+++ b/test/optimizers/__init__.py
@@ -0,0 +1,13 @@
+# This code is part of a Qiskit project.
+#
+# (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
+# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
+#
+# Any modifications or derivative works of this code must retain this
+# copyright notice, and modified files need to carry a notice indicating
+# that they have been altered from the originals.
+
+"""Qiskit's algorithm optimizer tests."""
diff --git a/test/optimizers/test_adam_amsgrad.py b/test/optimizers/test_adam_amsgrad.py
new file mode 100644
index 000000000..060accb34
--- /dev/null
+++ b/test/optimizers/test_adam_amsgrad.py
@@ -0,0 +1,84 @@
+# This code is part of a Qiskit project.
+#
+# (C) Copyright IBM 2019, 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
+# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
+#
+# Any modifications or derivative works of this code must retain this
+# copyright notice, and modified files need to carry a notice indicating
+# that they have been altered from the originals.
+
+"""Test of ADAM optimizer"""
+
+import unittest
+import tempfile
+from test import QiskitAlgorithmsTestCase
+import numpy as np
+from ddt import ddt
+
+from qiskit_machine_learning.optimizers import ADAM
+from qiskit_machine_learning.utils import algorithm_globals
+
+
+@ddt
+class TestOptimizerADAM(QiskitAlgorithmsTestCase):
+    """Test ADAM optimizer"""
+
+    def setUp(self):
+        super().setUp()
+        algorithm_globals.random_seed = 50
+        self.quadratic_objective = lambda x: x[0] ** 2 + x[1] ** 2
+        self.initial_point = np.array([1.0, 1.0])
+
+    def test_optimizer_minimize(self):
+        """Test ADAM optimizer minimize method"""
+        adam = ADAM(maxiter=150, tol=1e-6, lr=1e-1)
+        result = adam.minimize(self.quadratic_objective, self.initial_point)
+        self.assertAlmostEqual(result.fun, 0.0, places=6)
+        self.assertTrue(np.allclose(result.x, np.zeros_like(self.initial_point), atol=1e-2))
+
+    def test_optimizer_with_noise(self):
+        """Test ADAM optimizer with noise factor"""
+        adam = ADAM(maxiter=100, tol=1e-6, lr=1e-1, noise_factor=1e-2)
+        result = adam.minimize(self.quadratic_objective, self.initial_point)
+        self.assertAlmostEqual(result.fun, 0.0, places=4)
+        self.assertTrue(np.allclose(result.x, np.zeros_like(self.initial_point), atol=1e-2))
+
+    def test_amsgrad(self):
+        """Test ADAM optimizer with AMSGRAD variant"""
+        adam = ADAM(maxiter=150, tol=1e-6, lr=1e-1, amsgrad=True)
+        result = adam.minimize(self.quadratic_objective, self.initial_point)
+        self.assertAlmostEqual(result.fun, 0.0, places=6)
+        self.assertTrue(np.allclose(result.x, np.zeros_like(self.initial_point), atol=1e-2))
+
+    def test_save_load_params(self):
+        """Test save and load optimizer parameters"""
+        with tempfile.TemporaryDirectory() as tmpdir:
+            adam = ADAM(maxiter=100, tol=1e-6, lr=1e-1, snapshot_dir=tmpdir)
+            adam.minimize(self.quadratic_objective, self.initial_point)
+            new_adam = ADAM(snapshot_dir=tmpdir)
+            new_adam.load_params(tmpdir)
+
+            self.assertTrue(np.allclose(adam._m, new_adam._m))
+            self.assertTrue(np.allclose(adam._v, new_adam._v))
+            self.assertEqual(adam._t, new_adam._t)
+
+    def test_settings(self):
+        """Test settings property"""
+        adam = ADAM(maxiter=100, tol=1e-6, lr=1e-1)
+        settings = adam.settings
+        self.assertEqual(settings["maxiter"], 100)
+        self.assertEqual(settings["tol"], 1e-6)
+        self.assertEqual(settings["lr"], 1e-1)
+        self.assertEqual(settings["beta_1"], 0.9)
+        self.assertEqual(settings["beta_2"], 0.99)
+        self.assertEqual(settings["noise_factor"], 1e-8)
+        self.assertEqual(settings["eps"], 1e-10)
+        self.assertEqual(settings["amsgrad"], False)
+        self.assertEqual(settings["snapshot_dir"], None)
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/test/optimizers/test_gradient_descent.py b/test/optimizers/test_gradient_descent.py
new file mode 100644
index 000000000..4091ad113
--- /dev/null
+++ b/test/optimizers/test_gradient_descent.py
@@ -0,0 +1,196 @@
+# This code is part of a Qiskit project.
+#
+# (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
+# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
+#
+# Any modifications or derivative works of this code must retain this
+# copyright notice, and modified files need to carry a notice indicating
+# that they have been altered from the originals.
+
+"""Tests for the Gradient Descent optimizer."""
+
+from test import QiskitAlgorithmsTestCase
+import numpy as np
+from qiskit.circuit.library import PauliTwoDesign
+from qiskit.quantum_info import SparsePauliOp, Statevector
+
+from qiskit_machine_learning.optimizers import GradientDescent, GradientDescentState
+from qiskit_machine_learning.optimizers.steppable_optimizer import TellData, AskData
+from qiskit_machine_learning.utils import algorithm_globals
+
+
+class TestGradientDescent(QiskitAlgorithmsTestCase):
+    """Tests for the gradient descent optimizer."""
+
+    def setUp(self):
+        super().setUp()
+        algorithm_globals.random_seed = 50
+        self.initial_point = np.array([1, 1, 1, 1, 0])
+
+    def objective(self, x):
+        """Objective Function for the tests"""
+        return (np.linalg.norm(x) - 1) ** 2
+
+    def grad(self, x):
+        """Gradient of the objective function"""
+        return 2 * (np.linalg.norm(x) - 1) * x / np.linalg.norm(x)
+
+    def test_pauli_two_design(self):
+        """Test standard gradient descent on the Pauli two-design example."""
+        circuit = PauliTwoDesign(3, reps=3, seed=2)
+        parameters = list(circuit.parameters)
+        obs = SparsePauliOp("ZZI")  # Z^Z^I
+
+        initial_point = np.array(
+            [
+                0.1822308,
+                -0.27254251,
+                0.83684425,
+                0.86153976,
+                -0.7111668,
+                0.82766631,
+                0.97867993,
+                0.46136964,
+                2.27079901,
+                0.13382699,
+                0.29589915,
+                0.64883193,
+            ]
+        )
+
+        def objective_pauli(x):
+            bound_circ = circuit.assign_parameters(dict(zip(parameters, x)))
+            return Statevector(bound_circ).expectation_value(obs).real
+
+        optimizer = GradientDescent(maxiter=100, learning_rate=0.1, perturbation=0.1)
+
+        result = optimizer.minimize(objective_pauli, x0=initial_point)
+        self.assertLess(result.fun, -0.95)  # final loss
+        self.assertEqual(result.nfev, 1300)  # function evaluations
+
+    def test_callback(self):
+        """Test the callback."""
+
+        history = []
+
+        def callback(*args):
+            history.append(args)
+
+        optimizer = GradientDescent(maxiter=1, callback=callback)
+
+        _ = optimizer.minimize(self.objective, np.array([1, -1]))
+
+        self.assertEqual(len(history), 1)
+        self.assertIsInstance(history[0][0], int)  # nfevs
+        self.assertIsInstance(history[0][1], np.ndarray)  # parameters
+        self.assertIsInstance(history[0][2], float)  # function value
+        self.assertIsInstance(history[0][3], float)  # norm of the gradient
+
+    def test_minimize(self):
+        """Test setting the learning rate as iterator and minimizing the function."""
+
+        def learning_rate():
+            power = 0.6
+            constant_coeff = 0.1
+
+            def powerlaw():
+                n = 0
+                while True:
+                    yield constant_coeff * (n**power)
+                    n += 1
+
+            return powerlaw()
+
+        optimizer = GradientDescent(maxiter=20, learning_rate=learning_rate)
+        result = optimizer.minimize(self.objective, self.initial_point, self.grad)
+
+        self.assertLess(result.fun, 1e-5)
+
+    def test_no_start(self):
+        """Tests that making a step without having started the optimizer raises an error."""
+        optimizer = GradientDescent()
+        with self.assertRaises(AttributeError):
+            optimizer.step()
+
+    def test_start(self):
+        """Tests if the start method initializes the state properly."""
+        optimizer = GradientDescent()
+        self.assertIsNone(optimizer.state)
+        self.assertIsNone(optimizer.perturbation)
+        optimizer.start(x0=self.initial_point, fun=self.objective)
+
+        test_state = GradientDescentState(
+            x=self.initial_point,
+            fun=self.objective,
+            jac=None,
+            nfev=0,
+            njev=0,
+            nit=0,
+            learning_rate=1,
+            stepsize=None,
+        )
+
+        self.assertEqual(test_state, optimizer.state)
+
+    def test_ask(self):
+        """Test the ask method."""
+        optimizer = GradientDescent()
+        optimizer.start(fun=self.objective, x0=self.initial_point)
+
+        ask_data = optimizer.ask()
+        np.testing.assert_equal(ask_data.x_jac, self.initial_point)
+        self.assertIsNone(ask_data.x_fun)
+
+    def test_evaluate(self):
+        """Test the evaluate method."""
+        optimizer = GradientDescent(perturbation=1e-10)
+        optimizer.start(fun=self.objective, x0=self.initial_point)
+        ask_data = AskData(x_jac=self.initial_point)
+        tell_data = optimizer.evaluate(ask_data=ask_data)
+        np.testing.assert_almost_equal(tell_data.eval_jac, self.grad(self.initial_point), decimal=2)
+
+    def test_tell(self):
+        """Test the tell method."""
+        optimizer = GradientDescent(learning_rate=1.0)
+        optimizer.start(fun=self.objective, x0=self.initial_point)
+        ask_data = AskData(x_jac=self.initial_point)
+        tell_data = TellData(eval_jac=self.initial_point)
+        optimizer.tell(ask_data=ask_data, tell_data=tell_data)
+        np.testing.assert_equal(optimizer.state.x, np.zeros(optimizer.state.x.shape))
+
+    def test_continue_condition(self):
+        """Test if the continue condition is working properly."""
+        optimizer = GradientDescent(tol=1)
+        optimizer.start(fun=self.objective, x0=self.initial_point)
+        self.assertTrue(optimizer.continue_condition())
+        optimizer.state.stepsize = 0.1
+        self.assertFalse(optimizer.continue_condition())
+        optimizer.state.stepsize = 10
+        optimizer.state.nit = 1000
+        self.assertFalse(optimizer.continue_condition())
+
+    def test_step(self):
+        """Tests if performing one step yields the desired result."""
+        optimizer = GradientDescent(learning_rate=1.0)
+        optimizer.start(fun=self.objective, jac=self.grad, x0=self.initial_point)
+        optimizer.step()
+        np.testing.assert_almost_equal(
+            optimizer.state.x, self.initial_point - self.grad(self.initial_point), 6
+        )
+
+    def test_wrong_dimension_gradient(self):
+        """Tests if an error is raised when a gradient of the wrong dimension is passed."""
+
+        optimizer = GradientDescent(learning_rate=1.0)
+        optimizer.start(fun=self.objective, x0=self.initial_point)
+        ask_data = AskData(x_jac=self.initial_point)
+        tell_data = TellData(eval_jac=np.array([1.0, 5]))
+        with self.assertRaises(ValueError):
+            optimizer.tell(ask_data=ask_data, tell_data=tell_data)
+
+        tell_data = TellData(eval_jac=np.array(1))
+        with self.assertRaises(ValueError):
+            optimizer.tell(ask_data=ask_data, tell_data=tell_data)
diff --git a/test/optimizers/test_gsls.py b/test/optimizers/test_gsls.py
new file mode 100644
index 000000000..86206e447
--- /dev/null
+++ b/test/optimizers/test_gsls.py
@@ -0,0 +1,79 @@
+# This code is part of a Qiskit project.
+#
+# (C) Copyright IBM 2018, 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
+# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
+#
+# Any modifications or derivative works of this code must retain this
+# copyright notice, and modified files need to carry a notice indicating
+# that they have been altered from the originals.
+
+"""TestGSLS"""
+
+import unittest
+from test import QiskitAlgorithmsTestCase
+import numpy as np
+
+from qiskit_machine_learning.optimizers.gsls import GSLS
+from qiskit_machine_learning.optimizers.optimizer import OptimizerResult
+from qiskit_machine_learning.utils import algorithm_globals
+
+
+class TestGSLS(QiskitAlgorithmsTestCase):
+    """TestGSLS"""
+
+    def setUp(self):
+        super().setUp()
+        algorithm_globals.random_seed = 50
+        self.optimizer = GSLS(maxiter=200, sampling_radius=0.1, initial_step_size=0.01)
+
+    def test_minimize_rosenbrock(self):
+        """Tests minimize."""
+
+        def rosenbrock(x):
+            """Defines the calculation strategy."""
+            return sum(100.0 * (x[1:] - x[:-1] ** 2.0) ** 2.0 + (1 - x[:-1]) ** 2.0)
+
+        starting_point = np.array([0.0, 0.0])
+        result = self.optimizer.minimize(fun=rosenbrock, x0=starting_point)
+        self.assertIsInstance(result, OptimizerResult)
+        self.assertLess(result.fun, 1.0)
+        self.assertLess(result.nfev, 2000)
+
+    def test_minimize_bounds(self):
+        """Testing the minimize bounds."""
+
+        def objective(x):
+            """Defines the objective."""
+            return np.sum(x**2)
+
+        starting_point = np.array([0.1, 0.1])
+        bounds = [(-0.05, 0.05), (-0.05, 0.05)]
+        result = self.optimizer.minimize(fun=objective, x0=starting_point, bounds=bounds)
+        self.assertIsInstance(result, OptimizerResult)
+        self.assertTrue(np.all(result.x >= -0.05) and np.all(result.x <= 0.05))
+
+    def test_settings(self):
+        """Testing the settings."""
+        settings = self.optimizer.settings
+        self.assertEqual(settings["maxiter"], 200)
+        self.assertEqual(settings["sampling_radius"], 0.1)
+        self.assertEqual(settings["initial_step_size"], 0.01)
+
+    def test_sample_set(self):
+        """Testing the sample set."""
+        n = 2
+        x = np.array([0.0, 0.0])
+        num_points = 10
+        var_lb = np.array([-1.0, -1.0])
+        var_ub = np.array([1.0, 1.0])
+        directions, points = self.optimizer.sample_set(n, x, var_lb, var_ub, num_points)
+        self.assertEqual(directions.shape, (num_points, n))
+        self.assertEqual(points.shape, (num_points, n))
+        self.assertTrue(np.all(points >= var_lb) and np.all(points <= var_ub))
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/test/optimizers/test_nft.py b/test/optimizers/test_nft.py
new file mode 100644
index 000000000..3ddde5d4d
--- /dev/null
+++ b/test/optimizers/test_nft.py
@@ -0,0 +1,58 @@
+# This code is part of a Qiskit project.
+#
+# (C) Copyright IBM 2019, 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
+# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
+#
+# Any modifications or derivative works of this code must retain this
+# copyright notice, and modified files need to carry a notice indicating
+# that they have been altered from the originals.
+
+"""Unit tests for the Nakanishi-Fujii-Todo (NFT) optimizer."""
+
+import unittest
+from test import QiskitAlgorithmsTestCase
+import numpy as np
+
+from qiskit_machine_learning.optimizers.nft import NFT
+from qiskit_machine_learning.utils import algorithm_globals
+
+
+class TestNFTOptimizer(QiskitAlgorithmsTestCase):
+    """Test cases for the NFT optimizer."""
+
+    def setUp(self):
+        """Set up the optimizer for testing."""
+        super().setUp()
+        algorithm_globals.random_seed = 50
+        self.optimizer = NFT(maxiter=400, maxfev=1000)
+
+    def test_optimizer_support(self):
+        """Test the optimizer support levels."""
+        support_levels = self.optimizer.get_support_level()
+        self.assertIn("gradient", support_levels)
+        self.assertIn("bounds", support_levels)
+        self.assertIn("initial_point", support_levels)
+
+    def test_minimize_simple_quadratic(self):
+        """Test the optimizer on a simple quadratic function."""
+
+        def quadratic_function(x):
+            """Test function."""
+            return np.sum((x - 3) ** 2)
+
+        initial_point = np.array([0.0, 0.0])
+        result = self.optimizer.minimize(fun=quadratic_function, x0=initial_point)
+        self.assertTrue(np.allclose(result.x, np.array([3.0, 3.0]), atol=1e-2))
+
+    def test_optimizer_settings(self):
+        """Test the optimizer settings."""
+        settings = self.optimizer.settings
+        self.assertEqual(settings["maxiter"], 400)
+        self.assertEqual(settings["maxfev"], 1000)
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/test/optimizers/test_nlopts.py b/test/optimizers/test_nlopts.py
new file mode 100644
index 000000000..474837e70
--- /dev/null
+++ b/test/optimizers/test_nlopts.py
@@ -0,0 +1,175 @@
+# This code is part of a Qiskit project.
+#
+# (C) Copyright IBM 2018, 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
+# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
+#
+# Any modifications or derivative works of this code must retain this
+# copyright notice, and modified files need to carry a notice indicating
+# that they have been altered from the originals.
+
+"""Unit tests for NLopt optimizers."""
+
+import unittest
+from test import QiskitAlgorithmsTestCase
+import numpy as np
+from qiskit.exceptions import MissingOptionalLibraryError
+from qiskit_machine_learning.optimizers.nlopts import CRS, DIRECT_L, DIRECT_L_RAND, ESCH, ISRES
+from qiskit_machine_learning.utils import algorithm_globals
+
+
+class TestNLoptOptimizer(QiskitAlgorithmsTestCase):
+    """Test cases for NLoptOptimizer and its derived classes."""
+
+    def setUp(self):
+        """Set up optimizers for testing."""
+        super().setUp()
+        algorithm_globals.random_seed = 50
+        self.max_evals = 200
+        self.bounds = [(-5, 5), (-5, 5)]
+
+    def test_optimizer_support(self):
+        """Test the support levels of NLopt optimizers."""
+        try:
+            optimizers = [
+                CRS(max_evals=self.max_evals),
+                DIRECT_L(max_evals=self.max_evals),
+                DIRECT_L_RAND(max_evals=self.max_evals),
+                ESCH(max_evals=self.max_evals),
+                ISRES(max_evals=self.max_evals),
+            ]
+        except MissingOptionalLibraryError as error:
+            self.skipTest(str(error))
+
+        for optimizer in optimizers:
+            support_levels = optimizer.get_support_level()
+            self.assertIn("gradient", support_levels)
+            self.assertIn("bounds", support_levels)
+            self.assertIn("initial_point", support_levels)
+
+    def test_optimizer_settings(self):
+        """Test the optimizer settings."""
+        try:
+            optimizers = [
+                CRS(max_evals=self.max_evals),
+                DIRECT_L(max_evals=self.max_evals),
+                DIRECT_L_RAND(max_evals=self.max_evals),
+                ESCH(max_evals=self.max_evals),
+                ISRES(max_evals=self.max_evals),
+            ]
+        except MissingOptionalLibraryError as error:
+            self.skipTest(str(error))
+
+        for optimizer in optimizers:
+            settings = optimizer.settings
+            self.assertEqual(settings["max_evals"], self.max_evals)
+
+    def test_minimize_simple_quadratic(self):
+        """Test optimizers on a simple quadratic function."""
+
+        def quadratic_function(params):
+            """Test function."""
+            return np.sum((params - 4) ** 2)
+
+        initial_point = np.array([0.0, 0.0])
+
+        try:
+            optimizers = [
+                CRS(max_evals=self.max_evals),
+                DIRECT_L(max_evals=self.max_evals),
+                DIRECT_L_RAND(max_evals=self.max_evals),
+                ESCH(max_evals=self.max_evals),
+                ISRES(max_evals=self.max_evals),
+            ]
+        except MissingOptionalLibraryError as error:
+            self.skipTest(str(error))
+
+        for optimizer in optimizers:
+            result = optimizer.minimize(
+                fun=quadratic_function, x0=initial_point, bounds=self.bounds
+            )
+            self.assertTrue(np.allclose(result.x, np.array([4.0, 4.0]), atol=1e-2))
+
+    def test_minimize_rosenbrock(self):
+        """Test optimizers on the Rosenbrock function."""
+
+        def rosenbrock_function(params):
+            """Test function."""
+            return sum(100.0 * (params[1:] - params[:-1] ** 2.0) ** 2.0 + (1 - params[:-1]) ** 2.0)
+
+        initial_point = np.array([1.5, 1.5])
+
+        try:
+            optimizers = [
+                CRS(max_evals=self.max_evals),
+                DIRECT_L(max_evals=self.max_evals),
+                DIRECT_L_RAND(max_evals=self.max_evals),
+                ESCH(max_evals=self.max_evals),
+                ISRES(max_evals=self.max_evals),
+            ]
+        except MissingOptionalLibraryError as error:
+            self.skipTest(str(error))
+
+        for optimizer in optimizers:
+            result = optimizer.minimize(
+                fun=rosenbrock_function, x0=initial_point, bounds=self.bounds
+            )
+            self.assertLess(result.fun, 1e-4)
+            self.assertLess(result.nfev, self.max_evals)
+
+    def test_minimize_with_invalid_bounds(self):
+        """Test optimizers with invalid bounds."""
+
+        def quadratic_function(params):
+            """Test function."""
+            return np.sum((params - 2) ** 2)
+
+        initial_point = np.array([1.0, 1.0])
+        invalid_bounds = [(None, 5), (None, 5)]  # Invalid bounds with None
+
+        try:
+            optimizers = [
+                CRS(max_evals=self.max_evals),
+                DIRECT_L(max_evals=self.max_evals),
+                DIRECT_L_RAND(max_evals=self.max_evals),
+                ESCH(max_evals=self.max_evals),
+                ISRES(max_evals=self.max_evals),
+            ]
+        except MissingOptionalLibraryError as error:
+            self.skipTest(str(error))
+
+        for optimizer in optimizers:
+            with self.assertRaises(ValueError):
+                optimizer.minimize(fun=quadratic_function, x0=initial_point, bounds=invalid_bounds)
+
+    def test_minimize_with_clamped_initial_point(self):
+        """Test optimizers with an initial point that is clamped to bounds."""
+
+        def quadratic_function(params):
+            """Test function."""
+            return np.sum((params - 2) ** 2)
+
+        initial_point = np.array([10.0, -10.0])
+
+        try:
+            optimizers = [
+                CRS(max_evals=self.max_evals),
+                DIRECT_L(max_evals=self.max_evals),
+                DIRECT_L_RAND(max_evals=self.max_evals),
+                ESCH(max_evals=self.max_evals),
+                ISRES(max_evals=self.max_evals),
+            ]
+        except MissingOptionalLibraryError as error:
+            self.skipTest(str(error))
+
+        for optimizer in optimizers:
+            result = optimizer.minimize(
+                fun=quadratic_function, x0=initial_point, bounds=self.bounds
+            )
+            self.assertTrue(np.allclose(result.x, np.array([5.0, 0.0]), atol=1e-2))
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/test/optimizers/test_optimizer_aqgd.py b/test/optimizers/test_optimizer_aqgd.py
new file mode 100644
index 000000000..00cd67616
--- /dev/null
+++ b/test/optimizers/test_optimizer_aqgd.py
@@ -0,0 +1,75 @@
+# This code is part of a Qiskit project.
+#
+# (C) Copyright IBM 2019, 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
+# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
+#
+# Any modifications or derivative works of this code must retain this
+# copyright notice, and modified files need to carry a notice indicating
+# that they have been altered from the originals.
+
+"""Test of AQGD optimizer"""
+
+import unittest
+from test import QiskitAlgorithmsTestCase
+import numpy as np
+from ddt import ddt
+from qiskit.primitives import Estimator
+from qiskit.quantum_info import SparsePauliOp
+
+from qiskit_machine_learning import AlgorithmError
+from qiskit_machine_learning.gradients import LinCombEstimatorGradient
+from qiskit_machine_learning.optimizers import AQGD
+from qiskit_machine_learning.utils import algorithm_globals
+
+
+@ddt
+class TestOptimizerAQGD(QiskitAlgorithmsTestCase):
+    """Test AQGD optimizer using RY for analytic gradient with VQE"""
+
+    def setUp(self):
+        super().setUp()
+        algorithm_globals.random_seed = 50
+        self.qubit_op = SparsePauliOp.from_list(
+            [
+                ("II", -1.052373245772859),
+                ("IZ", 0.39793742484318045),
+                ("ZI", -0.39793742484318045),
+                ("ZZ", -0.01128010425623538),
+                ("XX", 0.18093119978423156),
+            ]
+        )
+        self.estimator = Estimator()
+        self.gradient = LinCombEstimatorGradient(self.estimator)
+
+    def test_raises_exception(self):
+        """tests that AQGD raises an exception when incorrect values are passed."""
+        self.assertRaises(AlgorithmError, AQGD, maxiter=[1000], eta=[1.0, 0.5], momentum=[0.0, 0.5])
+
+    def test_max_grouped_evals_non_parallelizable(self):
+        """Tests max_grouped_evals for an objective function that cannot be parallelized"""
+
+        # Define the objective function (toy example for functionality)
+        def quadratic_objective(x: np.ndarray) -> float:
+            # Check if only a single point as parameters is passed
+            if np.array(x).ndim != 1:
+                raise ValueError("The function expects a vector.")
+
+            return x[0] ** 2 + x[1] ** 2 - 2 * x[0] * x[1]
+
+        # Define initial point
+        initial_point = np.array([1, 2.23])
+        # Test max_evals_grouped raises no error for max_evals_grouped=1
+        aqgd = AQGD(maxiter=100, max_evals_grouped=1)
+        x_new = aqgd.minimize(quadratic_objective, initial_point).x
+        self.assertAlmostEqual(sum(np.round(x_new / max(x_new), 7)), 0)
+        # Test max_evals_grouped raises an error for max_evals_grouped=2
+        aqgd.set_max_evals_grouped(2)
+        with self.assertRaises(ValueError):
+            aqgd.minimize(quadratic_objective, initial_point)
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/test/optimizers/test_optimizers.py b/test/optimizers/test_optimizers.py
new file mode 100644
index 000000000..0ad5975f4
--- /dev/null
+++ b/test/optimizers/test_optimizers.py
@@ -0,0 +1,420 @@
+# This code is part of a Qiskit project.
+#
+# (C) Copyright IBM 2018, 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
+# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
+#
+# Any modifications or derivative works of this code must retain this
+# copyright notice, and modified files need to carry a notice indicating
+# that they have been altered from the originals.
+
+"""Test Optimizers"""
+
+import unittest
+from test import QiskitAlgorithmsTestCase
+
+from typing import Optional, List, Tuple
+from ddt import ddt, data, unpack
+import numpy as np
+from scipy.optimize import rosen, rosen_der
+
+from qiskit.circuit.library import RealAmplitudes
+from qiskit.exceptions import MissingOptionalLibraryError
+from qiskit.primitives import Sampler
+
+from qiskit_machine_learning.optimizers import (
+    ADAM,
+    AQGD,
+    CG,
+    CRS,
+    COBYLA,
+    DIRECT_L,
+    DIRECT_L_RAND,
+    GSLS,
+    GradientDescent,
+    L_BFGS_B,
+    NELDER_MEAD,
+    Optimizer,
+    P_BFGS,
+    POWELL,
+    SLSQP,
+    SPSA,
+    QNSPSA,
+    TNC,
+    SciPyOptimizer,
+)
+from qiskit_machine_learning.utils import algorithm_globals
+
+
+@ddt
+class TestOptimizers(QiskitAlgorithmsTestCase):
+    """Test Optimizers"""
+
+    def setUp(self):
+        super().setUp()
+        algorithm_globals.random_seed = 52
+
+    def run_optimizer(
+        self,
+        optimizer: Optimizer,
+        max_nfev: int,
+        grad: bool = False,
+        bounds: Optional[List[Tuple[float, float]]] = None,
+    ):
+        """Test the optimizer.
+
+        Args:
+            optimizer: The optimizer instance to test.
+            max_nfev: The maximal allowed number of function evaluations.
+            grad: Whether to pass the gradient function as input.
+            bounds: Optimizer bounds.
+        """
+        x_0 = np.asarray([1.3, 0.7, 0.8, 1.9, 1.2])
+        jac = rosen_der if grad else None
+
+        res = optimizer.minimize(rosen, x_0, jac, bounds)
+        x_opt = res.x
+        nfev = res.nfev
+
+        np.testing.assert_array_almost_equal(x_opt, [1.0] * len(x_0), decimal=2)
+        self.assertLessEqual(nfev, max_nfev)
+
+    def test_adam(self):
+        """adam test"""
+        optimizer = ADAM(maxiter=10000, tol=1e-06)
+        self.run_optimizer(optimizer, max_nfev=10000)
+
+    def test_cg(self):
+        """cg test"""
+        optimizer = CG(maxiter=1000, tol=1e-06)
+        self.run_optimizer(optimizer, max_nfev=10000)
+
+    def test_gradient_descent(self):
+        """cg test"""
+        optimizer = GradientDescent(maxiter=100000, tol=1e-06, learning_rate=1e-3)
+        self.run_optimizer(optimizer, grad=True, max_nfev=100000)
+
+    def test_cobyla(self):
+        """cobyla test"""
+        optimizer = COBYLA(maxiter=100000, tol=1e-06)
+        self.run_optimizer(optimizer, max_nfev=100000)
+
+    def test_l_bfgs_b(self):
+        """l_bfgs_b test"""
+        optimizer = L_BFGS_B(maxfun=1000)
+        self.run_optimizer(optimizer, max_nfev=10000)
+
+    def test_p_bfgs(self):
+        """parallel l_bfgs_b test"""
+        optimizer = P_BFGS(maxfun=1000, max_processes=4)
+        self.run_optimizer(optimizer, max_nfev=10000)
+
+    def test_nelder_mead(self):
+        """nelder mead test"""
+        optimizer = NELDER_MEAD(maxfev=10000, tol=1e-06)
+        self.run_optimizer(optimizer, max_nfev=10000)
+
+    def test_powell(self):
+        """powell test"""
+        optimizer = POWELL(maxfev=10000, tol=1e-06)
+        self.run_optimizer(optimizer, max_nfev=10000)
+
+    def test_slsqp(self):
+        """slsqp test"""
+        optimizer = SLSQP(maxiter=1000, tol=1e-06)
+        self.run_optimizer(optimizer, max_nfev=10000)
+
+    @unittest.skip("Skipping SPSA as it does not do well on non-convex rozen")
+    def test_spsa(self):
+        """spsa test"""
+        optimizer = SPSA(maxiter=10000)
+        self.run_optimizer(optimizer, max_nfev=100000)
+
+    def test_tnc(self):
+        """tnc test"""
+        optimizer = TNC(maxiter=1000, tol=1e-06)
+        self.run_optimizer(optimizer, max_nfev=10000)
+
+    def test_gsls(self):
+        """gsls test"""
+        optimizer = GSLS(
+            sample_size_factor=40,
+            sampling_radius=1.0e-12,
+            maxiter=10000,
+            max_eval=10000,
+            min_step_size=1.0e-12,
+        )
+        x_0 = np.asarray([1.3, 0.7, 0.8, 1.9, 1.2])
+
+        algorithm_globals.random_seed = 1
+        res = optimizer.minimize(rosen, x_0)
+        x_value = res.fun
+        n_evals = res.nfev
+
+        # Ensure value is near-optimal
+        self.assertLessEqual(x_value, 0.01)
+        self.assertLessEqual(n_evals, 10000)
+
+        with self.subTest("Bounds (None, None)"):
+            algorithm_globals.random_seed = 1
+            res = optimizer.minimize(rosen, x_0, bounds=[(None, None)] * len(x_0))
+
+            self.assertLessEqual(res.fun, 0.01)
+            self.assertLessEqual(res.nfev, 10000)
+
+    def test_scipy_optimizer(self):
+        """scipy_optimizer test"""
+        optimizer = SciPyOptimizer("BFGS", options={"maxiter": 1000})
+        self.run_optimizer(optimizer, max_nfev=10000)
+
+    def test_scipy_optimizer_callback(self):
+        """scipy_optimizer callback test"""
+        values = []
+
+        def callback(x):
+            values.append(x)
+
+        optimizer = SciPyOptimizer("BFGS", options={"maxiter": 1000}, callback=callback)
+        self.run_optimizer(optimizer, max_nfev=10000)
+        self.assertTrue(values)  # Check the list is nonempty.
+
+    def test_scipy_optimizer_parse_bounds(self):
+        """
+        Test the parsing of bounds in SciPyOptimizer.minimize method. Verifies that the bounds are
+        correctly parsed and set within the optimizer object.
+
+        Raises:
+            AssertionError: If any of the assertions fail.
+            AssertionError: If a TypeError is raised unexpectedly while parsing bounds.
+
+        """
+        try:
+            # Initialize SciPyOptimizer instance with SLSQP method
+            optimizer = SciPyOptimizer("SLSQP")
+
+            # Call minimize method with a simple lambda function and bounds
+            optimizer.minimize(lambda x: -x, 1.0, bounds=[(0.0, 1.0)])
+
+            # Assert that "bounds" is not present in optimizer options and kwargs
+            self.assertFalse("bounds" in optimizer._options)
+            self.assertFalse("bounds" in optimizer._kwargs)
+
+        except TypeError:
+            # This would give: https://github.com/qiskit-community/qiskit-machine-learning/issues/570
+            self.fail(
+                "TypeError was raised unexpectedly when parsing bounds in SciPyOptimizer.minimize(...)."
+            )
+
+        # Finally, expect exceptions if bounds are parsed incorrectly, i.e. differently than as in Scipy
+        # with self.assertRaises(RuntimeError):
+        #     _ = SciPyOptimizer("SLSQP", bounds=[(0.0, 1.0)])
+        # with self.assertRaises(RuntimeError):
+        #     _ = SciPyOptimizer("SLSQP", options={"bounds": [(0.0, 1.0)]})
+
+    # ESCH and ISRES do not do well with rosen
+    @data(
+        (CRS, True),
+        (DIRECT_L, True),
+        (DIRECT_L_RAND, True),
+        (CRS, False),
+        (DIRECT_L, False),
+        (DIRECT_L_RAND, False),
+    )
+    @unpack
+    def test_nlopt(self, optimizer_cls, use_bound):
+        """NLopt test"""
+        bounds = [(-6, 6)] * 5 if use_bound else None
+        try:
+            optimizer = optimizer_cls()
+            optimizer.set_options(**{"max_evals": 50000})
+            self.run_optimizer(optimizer, max_nfev=50000, bounds=bounds)
+        except MissingOptionalLibraryError as ex:
+            self.skipTest(str(ex))
+
+
+@ddt
+class TestOptimizerSerialization(QiskitAlgorithmsTestCase):
+    """Tests concerning the serialization of optimizers."""
+
+    @data(
+        ("BFGS", {"maxiter": 100, "eps": np.array([0.1])}),
+        ("CG", {"maxiter": 200, "gtol": 1e-8}),
+        ("COBYLA", {"maxiter": 10}),
+        ("L_BFGS_B", {"maxiter": 30}),
+        ("NELDER_MEAD", {"maxiter": 0}),
+        ("NFT", {"maxiter": 100}),
+        ("P_BFGS", {"maxiter": 5}),
+        ("POWELL", {"maxiter": 1}),
+        ("SLSQP", {"maxiter": 400}),
+        ("TNC", {"maxiter": 20}),
+        ("dogleg", {"maxiter": 100}),
+        ("trust-constr", {"maxiter": 10}),
+        ("trust-ncg", {"maxiter": 100}),
+        ("trust-exact", {"maxiter": 120}),
+        ("trust-krylov", {"maxiter": 150}),
+    )
+    @unpack
+    def test_scipy(self, method, options):
+        """Test the SciPyOptimizer is serializable."""
+
+        optimizer = SciPyOptimizer(method, options=options)
+        serialized = optimizer.settings
+        from_dict = SciPyOptimizer(**serialized)
+
+        self.assertEqual(from_dict._method, method.lower())
+        self.assertEqual(from_dict._options, options)
+
+    def test_independent_reconstruction(self):
+        """Test the SciPyOptimizers don't reset all settings upon creating a new instance.
+
+        COBYLA is used as representative example here."""
+
+        kwargs = {"coffee": "without sugar"}
+        options = {"tea": "with milk"}
+        optimizer = COBYLA(maxiter=1, options=options, **kwargs)
+        serialized = optimizer.settings
+        from_dict = COBYLA(**serialized)
+
+        with self.subTest(msg="test attributes"):
+            self.assertEqual(from_dict.settings["maxiter"], 1)
+
+        with self.subTest(msg="test options"):
+            # options should only contain values that are *not* already in the initializer
+            # (e.g. should not contain maxiter)
+            self.assertEqual(from_dict.settings["options"], {"tea": "with milk"})
+
+        with self.subTest(msg="test kwargs"):
+            self.assertEqual(from_dict.settings["coffee"], "without sugar")
+
+        with self.subTest(msg="option ids differ"):
+            self.assertNotEqual(id(serialized["options"]), id(from_dict.settings["options"]))
+
+    def test_adam(self):
+        """Test ADAM is serializable."""
+
+        adam = ADAM(maxiter=100, amsgrad=True)
+        settings = adam.settings
+
+        self.assertEqual(settings["maxiter"], 100)
+        self.assertTrue(settings["amsgrad"])
+
+    def test_aqgd(self):
+        """Test AQGD is serializable."""
+
+        opt = AQGD(maxiter=[200, 100], eta=[0.2, 0.1], momentum=[0.25, 0.1])
+        settings = opt.settings
+
+        self.assertListEqual(settings["maxiter"], [200, 100])
+        self.assertListEqual(settings["eta"], [0.2, 0.1])
+        self.assertListEqual(settings["momentum"], [0.25, 0.1])
+
+    def test_gradient_descent(self):
+        """Test GradientDescent is serializable."""
+
+        opt = GradientDescent(maxiter=10, learning_rate=0.01)
+        settings = opt.settings
+
+        self.assertEqual(settings["maxiter"], 10)
+        self.assertEqual(settings["learning_rate"], 0.01)
+
+    def test_gsls(self):
+        """Test GSLS is serializable."""
+
+        opt = GSLS(maxiter=100, sampling_radius=1e-3)
+        settings = opt.settings
+
+        self.assertEqual(settings["maxiter"], 100)
+        self.assertEqual(settings["sampling_radius"], 1e-3)
+
+    def test_spsa(self):
+        """Test SPSA optimizer is serializable."""
+        options = {
+            "maxiter": 100,
+            "blocking": True,
+            "allowed_increase": 0.1,
+            "second_order": True,
+            "learning_rate": 0.02,
+            "perturbation": 0.05,
+            "regularization": 0.1,
+            "resamplings": 2,
+            "perturbation_dims": 5,
+            "trust_region": False,
+            "initial_hessian": None,
+            "lse_solver": None,
+            "hessian_delay": 0,
+            "callback": None,
+            "termination_checker": None,
+        }
+        spsa = SPSA(**options)
+
+        self.assertDictEqual(spsa.settings, options)
+
+    def test_spsa_custom_iterators(self):
+        """Test serialization works with custom iterators for learning rate and perturbation."""
+        rate = 0.99
+
+        def powerlaw():
+            n = 0
+            while True:
+                yield rate**n
+                n += 1
+
+        def steps():
+            n = 1
+            divide_after = 20
+            epsilon = 0.5
+            while True:
+                yield epsilon
+                n += 1
+                if n % divide_after == 0:
+                    epsilon /= 2
+
+        learning_rate = powerlaw()
+        expected_learning_rate = np.array([next(learning_rate) for _ in range(200)])
+
+        perturbation = steps()
+        expected_perturbation = np.array([next(perturbation) for _ in range(200)])
+
+        spsa = SPSA(maxiter=200, learning_rate=powerlaw, perturbation=steps)
+        settings = spsa.settings
+
+        self.assertTrue(np.allclose(settings["learning_rate"], expected_learning_rate))
+        self.assertTrue(np.allclose(settings["perturbation"], expected_perturbation))
+
+    def test_qnspsa(self):
+        """Test QN-SPSA optimizer is serializable."""
+        ansatz = RealAmplitudes(1)
+        fidelity = QNSPSA.get_fidelity(ansatz, sampler=Sampler())
+        options = {
+            "fidelity": fidelity,
+            "maxiter": 100,
+            "blocking": True,
+            "allowed_increase": 0.1,
+            "learning_rate": 0.02,
+            "perturbation": 0.05,
+            "regularization": 0.1,
+            "resamplings": 2,
+            "perturbation_dims": 5,
+            "lse_solver": None,
+            "initial_hessian": None,
+            "callback": None,
+            "termination_checker": None,
+            "hessian_delay": 0,
+        }
+        spsa = QNSPSA(**options)
+
+        settings = spsa.settings
+        expected = options.copy()
+
+        with self.subTest(msg="check constructed dictionary"):
+            self.assertDictEqual(settings, expected)
+
+        reconstructed = QNSPSA(**settings)  # pylint: disable=unexpected-keyword-arg
+        with self.subTest(msg="test reconstructed optimizer"):
+            self.assertDictEqual(reconstructed.settings, expected)
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/test/optimizers/test_spsa.py b/test/optimizers/test_spsa.py
new file mode 100644
index 000000000..80afce43e
--- /dev/null
+++ b/test/optimizers/test_spsa.py
@@ -0,0 +1,266 @@
+# This code is part of a Qiskit project.
+#
+# (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
+# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
+#
+# Any modifications or derivative works of this code must retain this
+# copyright notice, and modified files need to carry a notice indicating
+# that they have been altered from the originals.
+
+"""Tests for the SPSA optimizer."""
+
+from test import QiskitAlgorithmsTestCase
+from ddt import ddt, data
+
+import numpy as np
+
+from qiskit.circuit.library import PauliTwoDesign
+from qiskit.primitives import Estimator, Sampler
+from qiskit.quantum_info import SparsePauliOp, Statevector
+
+from qiskit_machine_learning.optimizers import SPSA, QNSPSA
+from qiskit_machine_learning.utils import algorithm_globals
+
+
+@ddt
+class TestSPSA(QiskitAlgorithmsTestCase):
+    """Tests for the SPSA optimizer."""
+
+    def setUp(self):
+        super().setUp()
+        np.random.seed(12)
+        algorithm_globals.random_seed = 12
+
+    # @slow_test
+    @data("spsa", "2spsa", "qnspsa")
+    def test_pauli_two_design(self, method):
+        """Test SPSA on the Pauli two-design example."""
+        circuit = PauliTwoDesign(3, reps=1, seed=1)
+        parameters = list(circuit.parameters)
+        obs = SparsePauliOp("ZZI")  # Z^Z^I
+
+        initial_point = np.array(
+            [0.82311034, 0.02611798, 0.21077064, 0.61842177, 0.09828447, 0.62013131]
+        )
+
+        def objective(x):
+            bound_circ = circuit.assign_parameters(dict(zip(parameters, x)))
+            return Statevector(bound_circ).expectation_value(obs).real
+
+        settings = {"maxiter": 100, "blocking": True, "allowed_increase": 0}
+
+        if method == "2spsa":
+            settings["second_order"] = True
+            settings["regularization"] = 0.01
+            expected_nfev = settings["maxiter"] * 5 + 1
+        elif method == "qnspsa":
+            settings["fidelity"] = QNSPSA.get_fidelity(circuit, sampler=Sampler())
+            settings["regularization"] = 0.001
+            settings["learning_rate"] = 0.05
+            settings["perturbation"] = 0.05
+
+            expected_nfev = settings["maxiter"] * 7 + 1
+        else:
+            expected_nfev = settings["maxiter"] * 3 + 1
+
+        if method == "qnspsa":
+            spsa = QNSPSA(**settings)
+        else:
+            spsa = SPSA(**settings)
+
+        result = spsa.minimize(objective, x0=initial_point)
+
+        with self.subTest("check final accuracy"):
+            self.assertLess(result.fun, -0.95)  # final loss
+
+        with self.subTest("check number of function calls"):
+            self.assertEqual(result.nfev, expected_nfev)  # function evaluations
+
+    def test_recalibrate_at_optimize(self):
+        """Test SPSA calibrates anew upon each optimization run, if no auto-calibration is set."""
+
+        def objective(x):
+            return -(x**2)
+
+        spsa = SPSA(maxiter=1)
+        _ = spsa.minimize(objective, x0=np.array([0.5]))
+
+        self.assertIsNone(spsa.learning_rate)
+        self.assertIsNone(spsa.perturbation)
+
+    def test_learning_rate_perturbation_as_iterators(self):
+        """Test the learning rate and perturbation can be callables returning iterators."""
+
+        def get_learning_rate():
+            def learning_rate():
+                x = 0.99
+                while True:
+                    x *= x
+                    yield x
+
+            return learning_rate
+
+        def get_perturbation():
+            def perturbation():
+                x = 0.99
+                while True:
+                    x *= x
+                    yield max(x, 0.01)
+
+            return perturbation
+
+        def objective(x):
+            return (np.linalg.norm(x) - 2) ** 2
+
+        spsa = SPSA(learning_rate=get_learning_rate(), perturbation=get_perturbation())
+        result = spsa.minimize(objective, np.array([0.5, 0.5]))
+
+        self.assertAlmostEqual(np.linalg.norm(result.x), 2, places=2)
+
+    def test_learning_rate_perturbation_as_arrays(self):
+        """Test the learning rate and perturbation can be arrays."""
+
+        learning_rate = np.linspace(1, 0, num=100, endpoint=False) ** 2
+        perturbation = 0.01 * np.ones(100)
+
+        def objective(x):
+            return (np.linalg.norm(x) - 2) ** 2
+
+        spsa = SPSA(learning_rate=learning_rate, perturbation=perturbation)
+        result = spsa.minimize(objective, x0=np.array([0.5, 0.5]))
+
+        self.assertAlmostEqual(np.linalg.norm(result.x), 2, places=2)
+
+    def test_termination_checker(self):
+        """Test the termination_callback"""
+
+        def objective(x):
+            return np.linalg.norm(x) + np.random.rand(1)
+
+        class TerminationChecker:
+            """Example termination checker"""
+
+            def __init__(self):
+                self.values = []
+
+            def __call__(self, nfev, point, fvalue, stepsize, accepted) -> bool:
+                self.values.append(fvalue)
+
+                if len(self.values) > 10:
+                    return True
+                return False
+
+        maxiter = 400
+        spsa = SPSA(maxiter=maxiter, termination_checker=TerminationChecker())
+        result = spsa.minimize(objective, x0=[0.5, 0.5])
+
+        self.assertLess(result.nit, maxiter)
+
+    def test_callback(self):
+        """Test using the callback."""
+
+        def objective(x):
+            return (np.linalg.norm(x) - 2) ** 2
+
+        history = {"nfevs": [], "points": [], "fvals": [], "updates": [], "accepted": []}
+
+        def callback(nfev, point, fval, update, accepted):
+            history["nfevs"].append(nfev)
+            history["points"].append(point)
+            history["fvals"].append(fval)
+            history["updates"].append(update)
+            history["accepted"].append(accepted)
+
+        maxiter = 10
+        spsa = SPSA(maxiter=maxiter, learning_rate=0.01, perturbation=0.01, callback=callback)
+        _ = spsa.minimize(objective, x0=np.array([0.5, 0.5]))
+
+        expected_types = [int, np.ndarray, float, float, bool]
+        for i, (key, values) in enumerate(history.items()):
+            self.assertTrue(all(isinstance(value, expected_types[i]) for value in values))
+            self.assertEqual(len(history[key]), maxiter)
+
+    @data(1, 2, 3, 4)
+    def test_estimate_stddev(self, max_evals_grouped):
+        """Test the estimate_stddev
+        See https://github.com/Qiskit/qiskit-nature/issues/797"""
+
+        def objective(x):
+            if len(x.shape) == 2:
+                return np.array([sum(x_i) for x_i in x])
+            return sum(x)
+
+        point = np.ones(5)
+        result = SPSA.estimate_stddev(objective, point, avg=10, max_evals_grouped=max_evals_grouped)
+        self.assertAlmostEqual(result, 0)
+
+    def test_qnspsa_fidelity_primitives(self):
+        """Test the primitives can be used in get_fidelity."""
+        ansatz = PauliTwoDesign(2, reps=1, seed=2)
+        initial_point = np.random.random(ansatz.num_parameters)
+
+        with self.subTest(msg="pass as kwarg"):
+            fidelity = QNSPSA.get_fidelity(ansatz, sampler=Sampler())
+            result = fidelity(initial_point, initial_point)
+
+            self.assertAlmostEqual(result[0], 1)
+
+    def test_qnspsa_max_evals_grouped(self):
+        """Test using max_evals_grouped with QNSPSA."""
+        circuit = PauliTwoDesign(3, reps=1, seed=1)
+        num_parameters = circuit.num_parameters
+
+        obs = SparsePauliOp("ZZI")  # Z^Z^I
+        estimator = Estimator(options={"seed": 12})
+
+        initial_point = np.array(
+            [0.82311034, 0.02611798, 0.21077064, 0.61842177, 0.09828447, 0.62013131]
+        )
+
+        def objective(x):
+            x = np.reshape(x, (-1, num_parameters)).tolist()
+            n = len(x)
+            return estimator.run(n * [circuit], n * [obs], x).result().values.real
+
+        fidelity = QNSPSA.get_fidelity(circuit, sampler=Sampler())
+        optimizer = QNSPSA(fidelity)
+        optimizer.maxiter = 1
+        optimizer.learning_rate = 0.05
+        optimizer.perturbation = 0.05
+        optimizer.set_max_evals_grouped(50)  # greater than 1
+
+        result = optimizer.minimize(objective, initial_point)
+
+        with self.subTest("check final accuracy"):
+            self.assertAlmostEqual(result.fun[0], 0.473, places=3)
+
+        with self.subTest("check number of function calls"):
+            expected_nfev = 8  # 7 * maxiter + 1
+            self.assertEqual(result.nfev, expected_nfev)
+
+    def test_point_sample(self):
+        """Test point sample function in QNSPSA"""
+
+        def fidelity(x, _y):  # pylint: disable=invalid-name
+            x = np.asarray(x)
+            return np.ones_like(x, dtype=float)  # some float
+
+        def objective(x):
+            return x
+
+        def get_perturbation():
+            def perturbation():
+                while True:
+                    yield 1
+
+            return perturbation
+
+        qnspsa = QNSPSA(fidelity, maxiter=1, learning_rate=0.1, perturbation=get_perturbation())
+        initial_point = 1.0
+        result = qnspsa.minimize(objective, initial_point)
+
+        expected_nfev = 8  # 7 * maxiter + 1
+        self.assertEqual(result.nfev, expected_nfev)
diff --git a/test/optimizers/test_umda.py b/test/optimizers/test_umda.py
new file mode 100644
index 000000000..6a57cd0c5
--- /dev/null
+++ b/test/optimizers/test_umda.py
@@ -0,0 +1,99 @@
+# This code is part of a Qiskit project.
+#
+# (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
+# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
+#
+# Any modifications or derivative works of this code must retain this
+# copyright notice, and modified files need to carry a notice indicating
+# that they have been altered from the originals.
+
+"""Tests for the UMDA optimizer."""
+
+from test import QiskitAlgorithmsTestCase
+
+import numpy as np
+from scipy.optimize import rosen
+
+from qiskit_machine_learning.optimizers.umda import UMDA
+from qiskit_machine_learning.utils import algorithm_globals
+
+
+class TestUMDA(QiskitAlgorithmsTestCase):
+    """Tests for the UMDA optimizer."""
+
+    def setUp(self):
+        """Set the problem."""
+        super().setUp()
+        algorithm_globals.random_seed = 50
+
+    def test_get_set(self):
+        """Test if getters and setters work as expected"""
+        umda = UMDA(maxiter=1, size_gen=20)
+        umda.disp = True
+        umda.size_gen = 30
+        umda.alpha = 0.6
+        umda.maxiter = 100
+
+        self.assertTrue(umda.disp)
+        self.assertEqual(umda.size_gen, 30)
+        self.assertEqual(umda.alpha, 0.6)
+        self.assertEqual(umda.maxiter, 100)
+
+    def test_settings(self):
+        """Test if the settings display works well"""
+        umda = UMDA(maxiter=1, size_gen=20)
+        umda.disp = True
+        umda.size_gen = 30
+        umda.alpha = 0.6
+        umda.maxiter = 100
+
+        set_ = {
+            "maxiter": 100,
+            "alpha": 0.6,
+            "size_gen": 30,
+            "callback": None,
+        }
+
+        self.assertEqual(umda.settings, set_)
+
+    def test_minimize(self):
+        """optimize function test"""
+        # UMDA is volatile so we need to set the seeds for the execution
+        algorithm_globals.random_seed = 52
+
+        optimizer = UMDA(maxiter=1000, size_gen=100)
+        x_0 = [1.3, 0.7, 1.5]
+        res = optimizer.minimize(rosen, x_0)
+
+        self.assertIsNotNone(res.fun)
+        self.assertEqual(len(res.x), len(x_0))
+        np.testing.assert_array_almost_equal(res.x, [1.0] * len(x_0), decimal=2)
+
+    def test_callback(self):
+        """Test the callback."""
+
+        def objective(x):
+            return np.linalg.norm(x) - 1
+
+        nfevs, parameters, fvals = [], [], []
+
+        def store_history(*args):
+            nfevs.append(args[0])
+            parameters.append(args[1])
+            fvals.append(args[2])
+
+        optimizer = UMDA(maxiter=1, callback=store_history)
+        _ = optimizer.minimize(objective, x0=np.arange(5))
+
+        self.assertEqual(len(nfevs), 1)
+        self.assertIsInstance(nfevs[0], int)
+
+        self.assertEqual(len(parameters), 1)
+        self.assertIsInstance(parameters[0], np.ndarray)
+        self.assertEqual(parameters[0].size, 5)
+
+        self.assertEqual(len(fvals), 1)
+        self.assertIsInstance(fvals[0], float)
diff --git a/test/optimizers/utils/__init__.py b/test/optimizers/utils/__init__.py
new file mode 100644
index 000000000..d8993a914
--- /dev/null
+++ b/test/optimizers/utils/__init__.py
@@ -0,0 +1,12 @@
+# This code is part of a Qiskit project.
+#
+# (C) Copyright IBM 2022, 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
+# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
+#
+# Any modifications or derivative works of this code must retain this
+# copyright notice, and modified files need to carry a notice indicating
+# that they have been altered from the originals.
+"""Tests for Optimizer Utils."""
diff --git a/test/optimizers/utils/test_learning_rate.py b/test/optimizers/utils/test_learning_rate.py
new file mode 100644
index 000000000..a14ff3277
--- /dev/null
+++ b/test/optimizers/utils/test_learning_rate.py
@@ -0,0 +1,56 @@
+# This code is part of a Qiskit project.
+#
+# (C) Copyright IBM 2022, 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
+# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
+#
+# Any modifications or derivative works of this code must retain this
+# copyright notice, and modified files need to carry a notice indicating
+# that they have been altered from the originals.
+
+"""Tests for LearningRate."""
+
+from test import QiskitAlgorithmsTestCase
+import numpy as np
+
+from qiskit_machine_learning.optimizers.optimizer_utils import LearningRate
+
+
+class TestLearningRate(QiskitAlgorithmsTestCase):
+    """Tests for the LearningRate class."""
+
+    def setUp(self):
+        super().setUp()
+        np.random.seed(12)
+        self.initial_point = np.array([1, 1, 1, 1, 0])
+
+    def objective(self, x):
+        """Objective Function for the tests"""
+        return (np.linalg.norm(x) - 1) ** 2
+
+    def test_learning_rate(self):
+        """
+        Tests if the learning rate is initialized properly for each kind of input:
+        float, list and iterator.
+        """
+        constant_learning_rate_input = 0.01
+        list_learning_rate_input = [0.01 * n for n in range(10)]
+        # pylint: disable=unnecessary-lambda-assignment
+        generator_learning_rate_input = lambda: (el for el in list_learning_rate_input)
+
+        with self.subTest("Check constant learning rate."):
+            constant_learning_rate = LearningRate(learning_rate=constant_learning_rate_input)
+            for _ in range(5):
+                self.assertEqual(constant_learning_rate_input, next(constant_learning_rate))
+
+        with self.subTest("Check learning rate list."):
+            list_learning_rate = LearningRate(learning_rate=list_learning_rate_input)
+            for i in range(5):
+                self.assertEqual(list_learning_rate_input[i], next(list_learning_rate))
+
+        with self.subTest("Check learning rate generator."):
+            generator_learning_rate = LearningRate(generator_learning_rate_input)
+            for i in range(5):
+                self.assertEqual(list_learning_rate_input[i], next(generator_learning_rate))
diff --git a/test/state_fidelities/__init__.py b/test/state_fidelities/__init__.py
new file mode 100644
index 000000000..ded433ae5
--- /dev/null
+++ b/test/state_fidelities/__init__.py
@@ -0,0 +1,13 @@
+# This code is part of a Qiskit project.
+#
+# (C) Copyright IBM 2022, 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
+# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
+#
+# Any modifications or derivative works of this code must retain this
+# copyright notice, and modified files need to carry a notice indicating
+# that they have been altered from the originals.
+
+"""Tests for the primitive-based fidelity interfaces."""
diff --git a/test/state_fidelities/test_compute_uncompute.py b/test/state_fidelities/test_compute_uncompute.py
new file mode 100644
index 000000000..b498e29fa
--- /dev/null
+++ b/test/state_fidelities/test_compute_uncompute.py
@@ -0,0 +1,265 @@
+# This code is part of a Qiskit project.
+#
+# (C) Copyright IBM 2022, 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
+# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
+#
+# Any modifications or derivative works of this code must retain this
+# copyright notice, and modified files need to carry a notice indicating
+# that they have been altered from the originals.
+
+"""Tests for Fidelity."""
+
+import unittest
+from test import QiskitAlgorithmsTestCase
+
+import numpy as np
+
+from qiskit.circuit import QuantumCircuit, ParameterVector
+from qiskit.circuit.library import RealAmplitudes
+from qiskit.primitives import Sampler
+
+from qiskit_machine_learning.state_fidelities import ComputeUncompute
+
+
+class TestComputeUncompute(QiskitAlgorithmsTestCase):
+    """Test Compute-Uncompute Fidelity class"""
+
+    def setUp(self):
+        super().setUp()
+        parameters = ParameterVector("x", 2)
+
+        rx_rotations = QuantumCircuit(2)
+        rx_rotations.rx(parameters[0], 0)
+        rx_rotations.rx(parameters[1], 1)
+
+        ry_rotations = QuantumCircuit(2)
+        ry_rotations.ry(parameters[0], 0)
+        ry_rotations.ry(parameters[1], 1)
+
+        plus = QuantumCircuit(2)
+        plus.h([0, 1])
+
+        zero = QuantumCircuit(2)
+
+        rx_rotation = QuantumCircuit(2)
+        rx_rotation.rx(parameters[0], 0)
+        rx_rotation.h(1)
+
+        self._circuit = [rx_rotations, ry_rotations, plus, zero, rx_rotation]
+        self._sampler = Sampler()
+        self._left_params = np.array([[0, 0], [np.pi / 2, 0], [0, np.pi / 2], [np.pi, np.pi]])
+        self._right_params = np.array([[0, 0], [0, 0], [np.pi / 2, 0], [0, 0]])
+
+    def test_1param_pair(self):
+        """test for fidelity with one pair of parameters"""
+        fidelity = ComputeUncompute(self._sampler)
+        job = fidelity.run(
+            self._circuit[0], self._circuit[1], self._left_params[0], self._right_params[0]
+        )
+        result = job.result()
+        np.testing.assert_allclose(result.fidelities, np.array([1.0]))
+
+    def test_1param_pair_local(self):
+        """test for fidelity with one pair of parameters"""
+        fidelity = ComputeUncompute(self._sampler, local=True)
+        job = fidelity.run(
+            self._circuit[0], self._circuit[1], self._left_params[0], self._right_params[0]
+        )
+        result = job.result()
+        np.testing.assert_allclose(result.fidelities, np.array([1.0]))
+
+    def test_local(self):
+        """test difference between local and global fidelity"""
+        fidelity_global = ComputeUncompute(self._sampler, local=False)
+        fidelity_local = ComputeUncompute(self._sampler, local=True)
+        fidelities = []
+        for fidelity in [fidelity_global, fidelity_local]:
+            job = fidelity.run(self._circuit[2], self._circuit[3])
+            result = job.result()
+            fidelities.append(result.fidelities[0])
+        np.testing.assert_allclose(fidelities, np.array([0.25, 0.5]), atol=1e-16)
+
+    def test_4param_pairs(self):
+        """test for fidelity with four pairs of parameters"""
+        fidelity = ComputeUncompute(self._sampler)
+        n = len(self._left_params)
+        job = fidelity.run(
+            [self._circuit[0]] * n, [self._circuit[1]] * n, self._left_params, self._right_params
+        )
+        results = job.result()
+        np.testing.assert_allclose(results.fidelities, np.array([1.0, 0.5, 0.25, 0.0]), atol=1e-16)
+
+    def test_symmetry(self):
+        """test for fidelity with the same circuit"""
+        fidelity = ComputeUncompute(self._sampler)
+        n = len(self._left_params)
+        job_1 = fidelity.run(
+            [self._circuit[0]] * n, [self._circuit[0]] * n, self._left_params, self._right_params
+        )
+        job_2 = fidelity.run(
+            [self._circuit[0]] * n, [self._circuit[0]] * n, self._right_params, self._left_params
+        )
+        results_1 = job_1.result()
+        results_2 = job_2.result()
+        np.testing.assert_allclose(results_1.fidelities, results_2.fidelities, atol=1e-16)
+
+    def test_no_params(self):
+        """test for fidelity without parameters"""
+        fidelity = ComputeUncompute(self._sampler)
+        job = fidelity.run([self._circuit[2]], [self._circuit[3]])
+        results = job.result()
+        np.testing.assert_allclose(results.fidelities, np.array([0.25]), atol=1e-16)
+
+        job = fidelity.run([self._circuit[2]], [self._circuit[3]], [], [])
+        results = job.result()
+        np.testing.assert_allclose(results.fidelities, np.array([0.25]), atol=1e-16)
+
+    def test_left_param(self):
+        """test for fidelity with only left parameters"""
+        fidelity = ComputeUncompute(self._sampler)
+        n = len(self._left_params)
+        job = fidelity.run(
+            [self._circuit[1]] * n, [self._circuit[3]] * n, values_1=self._left_params
+        )
+        results = job.result()
+        np.testing.assert_allclose(results.fidelities, np.array([1.0, 0.5, 0.5, 0.0]), atol=1e-16)
+
+    def test_right_param(self):
+        """test for fidelity with only right parameters"""
+        fidelity = ComputeUncompute(self._sampler)
+        n = len(self._left_params)
+        job = fidelity.run(
+            [self._circuit[3]] * n, [self._circuit[1]] * n, values_2=self._left_params
+        )
+        results = job.result()
+        np.testing.assert_allclose(results.fidelities, np.array([1.0, 0.5, 0.5, 0.0]), atol=1e-16)
+
+    def test_not_set_circuits(self):
+        """test for fidelity with no circuits."""
+        fidelity = ComputeUncompute(self._sampler)
+        with self.assertRaises(TypeError):
+            job = fidelity.run(
+                circuits_1=None,
+                circuits_2=None,
+                values_1=self._left_params,
+                values_2=self._right_params,
+            )
+            job.result()
+
+    def test_circuit_mismatch(self):
+        """test for fidelity with different number of left/right circuits."""
+        fidelity = ComputeUncompute(self._sampler)
+        n = len(self._left_params)
+        with self.assertRaises(ValueError):
+            job = fidelity.run(
+                [self._circuit[0]] * n,
+                [self._circuit[1]] * (n + 1),
+                self._left_params,
+                self._right_params,
+            )
+            job.result()
+
+    def test_asymmetric_params(self):
+        """test for fidelity when the 2 circuits have different number of
+        left/right parameters."""
+
+        fidelity = ComputeUncompute(self._sampler)
+        n = len(self._left_params)
+        right_params = [[p] for p in self._right_params[:, 0]]
+        job = fidelity.run(
+            [self._circuit[0]] * n, [self._circuit[4]] * n, self._left_params, right_params
+        )
+        result = job.result()
+        np.testing.assert_allclose(result.fidelities, np.array([0.5, 0.25, 0.25, 0.0]), atol=1e-16)
+
+    def test_input_format(self):
+        """test for different input format variations"""
+
+        fidelity = ComputeUncompute(self._sampler)
+        circuit = RealAmplitudes(2)
+        values = np.random.random(circuit.num_parameters)
+        shift = np.ones_like(values) * 0.01
+
+        # lists of circuits, lists of numpy arrays
+        job = fidelity.run([circuit], [circuit], [values], [values + shift])
+        result_1 = job.result()
+
+        # lists of circuits, lists of lists
+        shift_val = values + shift
+        job = fidelity.run([circuit], [circuit], [values.tolist()], [shift_val.tolist()])
+        result_2 = job.result()
+
+        # circuits, lists
+        shift_val = values + shift
+        job = fidelity.run(circuit, circuit, values.tolist(), shift_val.tolist())
+        result_3 = job.result()
+
+        # circuits, np.arrays
+        job = fidelity.run(circuit, circuit, values, values + shift)
+        result_4 = job.result()
+
+        np.testing.assert_allclose(result_1.fidelities, result_2.fidelities, atol=1e-16)
+        np.testing.assert_allclose(result_1.fidelities, result_3.fidelities, atol=1e-16)
+        np.testing.assert_allclose(result_1.fidelities, result_4.fidelities, atol=1e-16)
+
+    def test_input_measurements(self):
+        """test for fidelity with measurements on input circuits"""
+        fidelity = ComputeUncompute(self._sampler)
+        circuit_1 = self._circuit[0]
+        circuit_1.measure_all()
+        circuit_2 = self._circuit[1]
+        circuit_2.measure_all()
+
+        job = fidelity.run(circuit_1, circuit_2, self._left_params[0], self._right_params[0])
+        result = job.result()
+        np.testing.assert_allclose(result.fidelities, np.array([1.0]))
+
+    def test_options(self):
+        """Test fidelity's run options"""
+        sampler_shots = Sampler(options={"shots": 1024})
+
+        with self.subTest("sampler"):
+            # Only options in sampler
+            fidelity = ComputeUncompute(sampler_shots)
+            options = fidelity.options
+            job = fidelity.run(self._circuit[2], self._circuit[3])
+            result = job.result()
+            self.assertEqual(options.__dict__, {"shots": 1024})
+            self.assertEqual(result.options.__dict__, {"shots": 1024})
+
+        with self.subTest("fidelity init"):
+            # Fidelity default options override sampler
+            # options and add new fields
+            fidelity = ComputeUncompute(sampler_shots, options={"shots": 2048, "dummy": 100})
+            options = fidelity.options
+            job = fidelity.run(self._circuit[2], self._circuit[3])
+            result = job.result()
+            self.assertEqual(options.__dict__, {"shots": 2048, "dummy": 100})
+            self.assertEqual(result.options.__dict__, {"shots": 2048, "dummy": 100})
+
+        with self.subTest("fidelity update"):
+            # Update fidelity options
+            fidelity = ComputeUncompute(sampler_shots, options={"shots": 2048, "dummy": 100})
+            fidelity.update_default_options(shots=100)
+            options = fidelity.options
+            job = fidelity.run(self._circuit[2], self._circuit[3])
+            result = job.result()
+            self.assertEqual(options.__dict__, {"shots": 100, "dummy": 100})
+            self.assertEqual(result.options.__dict__, {"shots": 100, "dummy": 100})
+
+        with self.subTest("fidelity run"):
+            # Run options override fidelity options
+            fidelity = ComputeUncompute(sampler_shots, options={"shots": 2048, "dummy": 100})
+            job = fidelity.run(self._circuit[2], self._circuit[3], shots=50, dummy=None)
+            options = fidelity.options
+            result = job.result()
+            # Only default + sampler options. Not run.
+            self.assertEqual(options.__dict__, {"shots": 2048, "dummy": 100})
+            self.assertEqual(result.options.__dict__, {"shots": 50, "dummy": None})
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/test/utils/test_algorithm_globals.py b/test/utils/test_algorithm_globals.py
new file mode 100644
index 000000000..44a170184
--- /dev/null
+++ b/test/utils/test_algorithm_globals.py
@@ -0,0 +1,80 @@
+# This code is part of a Qiskit project.
+#
+# (C) Copyright IBM 2019, 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
+# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
+#
+# Any modifications or derivative works of this code must retain this
+# copyright notice, and modified files need to carry a notice indicating
+# that they have been altered from the originals.
+
+"""Test QiskitAlgorithmGlobals."""
+
+from test import QiskitAlgorithmsTestCase
+from unittest.mock import patch
+import numpy as np
+
+from qiskit_machine_learning.utils.algorithm_globals import QiskitAlgorithmGlobals
+
+
+class TestQiskitAlgorithmGlobals(QiskitAlgorithmsTestCase):
+    """Test the QiskitAlgorithmGlobals class."""
+
+    def setUp(self):
+        super().setUp()
+        self.algorithm_globals = QiskitAlgorithmGlobals()
+
+    @patch("qiskit.utils.algorithm_globals", create=True)
+    def test_random_seed_getter_qiskit(self, mock_qiskit_globals):
+        """Test random_seed getter when qiskit_machine_learning.utils.algorithm_globals
+        is available."""
+        mock_qiskit_globals.random_seed = 42
+
+        seed = self.algorithm_globals.random_seed
+
+        self.assertEqual(seed, 42)
+
+    def test_random_seed_getter_local(self):
+        """Test random_seed getter when qiskit_machine_learning.utils.algorithm_globals
+        is not available."""
+        self.algorithm_globals._random_seed = 24
+
+        seed = self.algorithm_globals.random_seed
+
+        self.assertEqual(seed, 24)
+
+    @patch("qiskit.utils.algorithm_globals", create=True)
+    def test_random_seed_setter_qiskit(self, mock_qiskit_globals):
+        """Test random_seed setter when qiskit_machine_learning.utils.algorithm_globals
+        is available."""
+        self.algorithm_globals.random_seed = 15
+
+        self.assertEqual(mock_qiskit_globals.random_seed, 15)
+        self.assertEqual(self.algorithm_globals._random_seed, 15)
+
+    def test_random_seed_setter_local(self):
+        """Test random_seed setter when qiskit_machine_learning.utils.algorithm_globals
+        is not available."""
+        self.algorithm_globals.random_seed = 7
+
+        self.assertEqual(self.algorithm_globals._random_seed, 7)
+        self.assertIsNone(self.algorithm_globals._random)
+
+    def test_random_property_local(self):
+        """Test random property when qiskit_machine_learning.utils.algorithm_globals is not available."""
+        self.algorithm_globals.random_seed = 5
+        rng = self.algorithm_globals.random
+
+        self.assertEqual(self.algorithm_globals._random_seed, 5)
+        self.assertIsInstance(rng, np.random.Generator)
+        self.assertEqual(rng.bit_generator._seed_seq.entropy, 5)
+
+    def test_random_property_local_no_seed(self):
+        """Test random property when qiskit_machine_learning.utils.algorithm_globals
+        is not available and seed is None."""
+        rng = self.algorithm_globals.random
+
+        self.assertIsNone(self.algorithm_globals._random_seed)
+        self.assertIsInstance(rng, np.random.Generator)
diff --git a/test/utils/test_set_batching.py b/test/utils/test_set_batching.py
new file mode 100644
index 000000000..da5d029f0
--- /dev/null
+++ b/test/utils/test_set_batching.py
@@ -0,0 +1,53 @@
+# This code is part of a Qiskit project.
+#
+# (C) Copyright IBM 2022, 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
+# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
+#
+# Any modifications or derivative works of this code must retain this
+# copyright notice, and modified files need to carry a notice indicating
+# that they have been altered from the originals.
+
+"""Test setting default batch sizes for the optimizers."""
+
+from test import QiskitAlgorithmsTestCase
+
+from unittest.mock import Mock
+from qiskit_machine_learning.optimizers import Optimizer, SPSA
+from qiskit_machine_learning.utils.set_batching import _set_default_batchsize
+
+
+class TestSetDefaultBatchsize(QiskitAlgorithmsTestCase):
+    """Test the ``_set_default_batchsize`` utility function."""
+
+    def setUp(self):
+        super().setUp()
+        self.spsa_optimizer = Mock(spec=SPSA)
+        self.generic_optimizer = Mock(spec=Optimizer)
+
+    def test_set_default_batchsize_for_spsa_with_none(self):
+        """Test setting default batchsize for SPSA when _max_evals_grouped is None."""
+        self.spsa_optimizer._max_evals_grouped = None
+        self.spsa_optimizer.set_max_evals_grouped = Mock()
+
+        updated = _set_default_batchsize(self.spsa_optimizer)
+
+        self.spsa_optimizer.set_max_evals_grouped.assert_called_once_with(50)
+        self.assertTrue(updated)
+
+    def test_set_default_batchsize_for_spsa_with_value(self):
+        """Test setting default batchsize for SPSA when _max_evals_grouped is already set."""
+        self.spsa_optimizer._max_evals_grouped = 10
+
+        updated = _set_default_batchsize(self.spsa_optimizer)
+
+        self.spsa_optimizer.set_max_evals_grouped.assert_not_called()
+        self.assertFalse(updated)
+
+    def test_set_default_batchsize_for_generic_optimizer(self):
+        """Test setting default batchsize for a non-SPSA optimizer."""
+        updated = _set_default_batchsize(self.generic_optimizer)
+
+        self.assertFalse(updated)
diff --git a/test/utils/test_validate_bounds.py b/test/utils/test_validate_bounds.py
new file mode 100644
index 000000000..6dd723599
--- /dev/null
+++ b/test/utils/test_validate_bounds.py
@@ -0,0 +1,52 @@
+# This code is part of a Qiskit project.
+#
+# (C) Copyright IBM 2022, 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
+# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
+#
+# Any modifications or derivative works of this code must retain this
+# copyright notice, and modified files need to carry a notice indicating
+# that they have been altered from the originals.
+
+"""Test validate bounds."""
+
+from test import QiskitAlgorithmsTestCase
+
+from unittest.mock import Mock
+
+import numpy as np
+
+from qiskit_machine_learning.utils import algorithm_globals, validate_bounds
+
+
+class TestValidateBounds(QiskitAlgorithmsTestCase):
+    """Test the ``validate_bounds`` utility function."""
+
+    def setUp(self):
+        super().setUp()
+        algorithm_globals.random_seed = 0
+        self.bounds = [(-np.pi / 2, np.pi / 2)]
+        self.ansatz = Mock()
+
+    def test_with_no_ansatz_bounds(self):
+        """Test with no ansatz bounds."""
+        self.ansatz.num_parameters = 1
+        self.ansatz.parameter_bounds = None
+        bounds = validate_bounds(self.ansatz)
+        self.assertEqual(bounds, [(None, None)])
+
+    def test_with_ansatz_bounds(self):
+        """Test with ansatz bounds."""
+        self.ansatz.num_parameters = 1
+        self.ansatz.parameter_bounds = self.bounds
+        bounds = validate_bounds(self.ansatz)
+        self.assertEqual(bounds, self.bounds)
+
+    def test_with_mismatched_num_params(self):
+        """Test with a mismatched number of parameters and bounds"""
+        self.ansatz.num_parameters = 2
+        self.ansatz.parameter_bounds = self.bounds
+        with self.assertRaises(ValueError):
+            _ = validate_bounds(self.ansatz)
diff --git a/test/utils/test_validate_initial_point.py b/test/utils/test_validate_initial_point.py
new file mode 100644
index 000000000..d0fc9d309
--- /dev/null
+++ b/test/utils/test_validate_initial_point.py
@@ -0,0 +1,49 @@
+# This code is part of a Qiskit project.
+#
+# (C) Copyright IBM 2022, 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
+# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
+#
+# Any modifications or derivative works of this code must retain this
+# copyright notice, and modified files need to carry a notice indicating
+# that they have been altered from the originals.
+
+"""Test validate initial point."""
+
+from test import QiskitAlgorithmsTestCase
+
+from unittest.mock import Mock
+
+import numpy as np
+
+from qiskit_machine_learning.utils import algorithm_globals, validate_initial_point
+
+
+class TestValidateInitialPoint(QiskitAlgorithmsTestCase):
+    """Test the ``validate_initial_point`` utility function."""
+
+    def setUp(self):
+        super().setUp()
+        algorithm_globals.random_seed = 0
+        self.ansatz = Mock()
+        self.ansatz.num_parameters = 1
+
+    def test_with_no_initial_point_or_bounds(self):
+        """Test with no user-defined initial point and no ansatz bounds."""
+        self.ansatz.parameter_bounds = None
+        initial_point = validate_initial_point(None, self.ansatz)
+        np.testing.assert_array_almost_equal(initial_point, [1.721111])
+
+    def test_with_no_initial_point(self):
+        """Test with no user-defined initial point with ansatz bounds."""
+        self.ansatz.parameter_bounds = [(-np.pi / 2, np.pi / 2)]
+        initial_point = validate_initial_point(None, self.ansatz)
+        np.testing.assert_array_almost_equal(initial_point, [0.430278])
+
+    def test_with_mismatched_params(self):
+        """Test with mismatched parameters and bounds.."""
+        self.ansatz.parameter_bounds = None
+        with self.assertRaises(ValueError):
+            _ = validate_initial_point([1.0, 2.0], self.ansatz)
diff --git a/test/utils/test_validation.py b/test/utils/test_validation.py
new file mode 100644
index 000000000..845a79d6f
--- /dev/null
+++ b/test/utils/test_validation.py
@@ -0,0 +1,119 @@
+# This code is part of a Qiskit project.
+#
+# (C) Copyright IBM 2019, 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
+# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
+#
+# Any modifications or derivative works of this code must retain this
+# copyright notice, and modified files need to carry a notice indicating
+# that they have been altered from the originals.
+
+"""Test validation functions."""
+
+from test import QiskitAlgorithmsTestCase
+from qiskit_machine_learning.utils.validation import (
+    validate_in_set,
+    validate_min,
+    validate_min_exclusive,
+    validate_max,
+    validate_max_exclusive,
+    validate_range,
+    validate_range_exclusive,
+    validate_range_exclusive_min,
+    validate_range_exclusive_max,
+)
+
+
+class TestValidationFunctions(QiskitAlgorithmsTestCase):
+    """Test the validation functions."""
+
+    def test_validate_in_set_valid(self):
+        """Test validate_in_set with valid value."""
+        validate_in_set("param", "a", {"a", "b", "c"})  # Should not raise
+
+    def test_validate_in_set_invalid(self):
+        """Test validate_in_set with invalid value."""
+        with self.assertRaises(ValueError):
+            validate_in_set("param", "d", {"a", "b", "c"})
+
+    def test_validate_min_valid(self):
+        """Test validate_min with valid value."""
+        validate_min("param", 5, 1)  # Should not raise
+
+    def test_validate_min_invalid(self):
+        """Test validate_min with invalid value."""
+        with self.assertRaises(ValueError):
+            validate_min("param", 0, 1)
+
+    def test_validate_min_exclusive_valid(self):
+        """Test validate_min_exclusive with valid value."""
+        validate_min_exclusive("param", 5, 1)  # Should not raise
+
+    def test_validate_min_exclusive_invalid(self):
+        """Test validate_min_exclusive with invalid value."""
+        with self.assertRaises(ValueError):
+            validate_min_exclusive("param", 1, 1)
+
+    def test_validate_max_valid(self):
+        """Test validate_max with valid value."""
+        validate_max("param", 5, 10)  # Should not raise
+
+    def test_validate_max_invalid(self):
+        """Test validate_max with invalid value."""
+        with self.assertRaises(ValueError):
+            validate_max("param", 15, 10)
+
+    def test_validate_max_exclusive_valid(self):
+        """Test validate_max_exclusive with valid value."""
+        validate_max_exclusive("param", 5, 10)  # Should not raise
+
+    def test_validate_max_exclusive_invalid(self):
+        """Test validate_max_exclusive with invalid value."""
+        with self.assertRaises(ValueError):
+            validate_max_exclusive("param", 10, 10)
+
+    def test_validate_range_valid(self):
+        """Test validate_range with valid value."""
+        validate_range("param", 5, 1, 10)  # Should not raise
+
+    def test_validate_range_invalid(self):
+        """Test validate_range with invalid value."""
+        with self.assertRaises(ValueError):
+            validate_range("param", 0, 1, 10)
+        with self.assertRaises(ValueError):
+            validate_range("param", 15, 1, 10)
+
+    def test_validate_range_exclusive_valid(self):
+        """Test validate_range_exclusive with valid value."""
+        validate_range_exclusive("param", 5, 1, 10)  # Should not raise
+
+    def test_validate_range_exclusive_invalid(self):
+        """Test validate_range_exclusive with invalid value."""
+        with self.assertRaises(ValueError):
+            validate_range_exclusive("param", 1, 1, 10)
+        with self.assertRaises(ValueError):
+            validate_range_exclusive("param", 10, 1, 10)
+
+    def test_validate_range_exclusive_min_valid(self):
+        """Test validate_range_exclusive_min with valid value."""
+        validate_range_exclusive_min("param", 5, 1, 10)  # Should not raise
+
+    def test_validate_range_exclusive_min_invalid(self):
+        """Test validate_range_exclusive_min with invalid value."""
+        with self.assertRaises(ValueError):
+            validate_range_exclusive_min("param", 1, 1, 10)
+        with self.assertRaises(ValueError):
+            validate_range_exclusive_min("param", 11, 1, 10)
+
+    def test_validate_range_exclusive_max_valid(self):
+        """Test validate_range_exclusive_max with valid value."""
+        validate_range_exclusive_max("param", 5, 1, 10)  # Should not raise
+
+    def test_validate_range_exclusive_max_invalid(self):
+        """Test validate_range_exclusive_max with invalid value."""
+        with self.assertRaises(ValueError):
+            validate_range_exclusive_max("param", 0, 1, 10)
+        with self.assertRaises(ValueError):
+            validate_range_exclusive_max("param", 10, 1, 10)