How do I create a parameterized Hamiltonian with the same coefficient for certain terms?

Hello! I’m trying to create a Hamiltonian that will be used in conjunction with qml.evolve(). However, I want the Hamiltonian to be a parameterized one since I want the coefficients to also be optimized during the learning process. I’m creating my parameterized Hamiltonian as follows:

import pennylane as qml
import pennylane.numpy as np
import jax.numpy as jnp
import jax

class QuantumPerceptron():
    def __init__ (self, n_wires, L, native_coupling = 1.0):    
        self.n_wires = n_wires
        self.native_coupling = native_coupling
        self.L = L

        self.create_parameterized_hamiltonian()

    def create_parameterized_hamiltonian(self):
        
        self.create_native_hamiltonian()

        self.H = self. H_native

    def create_native_hamiltonian(self):

        self.native_fields_squeezing = [self.get_control_field() for i in range(self.n_wires-1)]
        self.H_native_operators_squeezing = [qml.PauliZ(i) @ qml.PauliZ(i+1) for i in range(self.n_wires-1)] # Assuming nearest neighbor interactions

        self.native_fields_identity = [self.get_control_field() for i in range(self.n_wires)]
        self.H_native_operators_identity = [qml.Identity(i) for i in range(self.n_wires)]

        self.total_native_fields = self.native_fields_squeezing + self.native_fields_identity
        self.H_total_native_operators = self.H_native_operators_squeezing + self.H_native_operators_identity

        self.H_native = qml.dot(self.total_native_fields, self.H_total_native_operators)

def get_control_field(self):

        def control_field(p,t):
            # p is the trainable parameter
            # t is the time
            return p

        return control_field

In particular, let’s say that I want to create this Hamiltonian for 5 wires. If I create my Hamiltonian using the above functions, I will get something like

  (control_field(params_0, t)*(PauliZ(wires=[0]) @ PauliZ(wires=[1])))
+ (control_field(params_1, t)*(PauliZ(wires=[1]) @ PauliZ(wires=[2])))
+ (control_field(params_2, t)*(PauliZ(wires=[2]) @ PauliZ(wires=[3])))
+ (control_field(params_3, t)*(PauliZ(wires=[3]) @ PauliZ(wires=[4])))
+ (control_field(params_4, t)*(Identity(wires=[0])))
+ (control_field(params_5, t)*(Identity(wires=[1])))
+ (control_field(params_6, t)*(Identity(wires=[2])))
+ (control_field(params_7, t)*(Identity(wires=[3])))
+ (control_field(params_8, t)*(Identity(wires=[4])))

Thus, when I do qml.evolve(Hamiltonian) it must be called as qml.evolve(Hamiltonian)([0,1,2,3,4,5,6,7,8], 1) for it to run.

As one can see, each PauliZ(i) @ PauliZ(i+1) term contains a different control_field(params, t) coefficient. Ideally, I would want each PauliZ(i) @ PauliZ(i+1) term to have the same coefficient and similarly for each Identity(i) term. So, in this particular example, I’d like for my Hamiltonian to look like

  (control_field(params_0, t)*(PauliZ(wires=[0]) @ PauliZ(wires=[1])))
+ (control_field(params_0, t)*(PauliZ(wires=[1]) @ PauliZ(wires=[2])))
+ (control_field(params_0, t)*(PauliZ(wires=[2]) @ PauliZ(wires=[3])))
+ (control_field(params_0, t)*(PauliZ(wires=[3]) @ PauliZ(wires=[4])))
+ (control_field(params_1, t)*(Identity(wires=[0])))
+ (control_field(params_1, t)*(Identity(wires=[1])))
+ (control_field(params_1, t)*(Identity(wires=[2])))
+ (control_field(params_1, t)*(Identity(wires=[3])))
+ (control_field(params_1, t)*(Identity(wires=[4])))

I want it such that I can just call qml.evolve(Hamiltonian)([0,1], 1) to run the evolution.

Thank you in advance!

To further explain: The reason I want to do this is so that during the training process, the coefficient parameters are updated “as one” - the coefficient affecting PauliZ(wires=[0]) @ PauliZ(wires=[1]), for example, is the same coefficient affecting the PauliZ(wires=[2]) @ PauliZ(wires=[3]) term.

Here’s my qml.about() if it helps!

Name: PennyLane
Version: 0.33.1
Summary: PennyLane is a Python quantum machine learning library by Xanadu Inc.
Home-page: https://github.com/PennyLaneAI/pennylane
Author: 
Author-email: 
License: Apache License 2.0
Location: /Users/.../python3.11/site-packages
Requires: appdirs, autograd, autoray, cachetools, networkx, numpy, pennylane-lightning, requests, rustworkx, scipy, semantic-version, toml, typing-extensions
Required-by: PennyLane-Lightning

Platform info:           macOS-13.4.1-x86_64-i386-64bit
Python version:          3.11.5
Numpy version:           1.26.2
Scipy version:           1.11.4
Installed devices:
- default.gaussian (PennyLane-0.33.1)
- default.mixed (PennyLane-0.33.1)
- default.qubit (PennyLane-0.33.1)
- default.qubit.autograd (PennyLane-0.33.1)
- default.qubit.jax (PennyLane-0.33.1)
- default.qubit.legacy (PennyLane-0.33.1)
- default.qubit.tf (PennyLane-0.33.1)
- default.qubit.torch (PennyLane-0.33.1)
- default.qutrit (PennyLane-0.33.1)
- null.qubit (PennyLane-0.33.1)
- lightning.qubit (PennyLane-Lightning-0.33.1)

Hey @NickGut0711,

This should all be possible in PennyLane with just a bit of control flow on your coefficients when you construct the parameterized Hamiltonian. This demo will be helpful: Differentiable pulse programming with qubits in PennyLane | PennyLane Demos. Particularly this code block:

coeffs = [1.0] * 2
coeffs += [lambda p, t: jnp.sin(p[0] * t) + jnp.sin(p[1] * t) for _ in range(3)]
ops = [qml.PauliX(i) @ qml.PauliX(i + 1) for i in range(2)]
ops += [qml.PauliZ(i) for i in range(3)]

Ht = qml.dot(coeffs, ops)

# random coefficients
key = jax.random.PRNGKey(777)
subkeys = jax.random.split(key, 3) # create list of 3 subkeys
params = [jax.random.uniform(subkeys[i], shape=[2], maxval=5) for i in range(3)]
print(Ht(params, 0.5))

Let me know if that helps!

Hello @isaacdevlugt!

Thank you for the reply :star_struck:

I looked at the demo, and it’s effectively doing the same thing as before. Here’s what I changed in my code

self.native_fields_squeezing = [lambda p, t: p[0] for _ in range(self.n_wires-1)]
self.H_native_operators_squeezing = [qml.PauliZ(i) @ qml.PauliZ(i+1) for i in range(self.n_wires-1)] # Assuming nearest neighbor interactions

self.native_fields_identity = [lambda p, t: p[1] for _ in range(self.n_wires)]
self.H_native_operators_identity = [qml.Identity(i) for i in range(self.n_wires)]

To test this I do

perceptron = QuantumPerceptron(n_wires, L)
H_perceptron = perceptron.H

H_perceptron([0.001, 0.002], t=1)

and I still get the error where it expects 9 parameters and instead receives just 2 (to make this clear I just want the first 4 coefficients to be p[0] and the last 5 coefficients to be p[1]).

---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
/Users/.../... .ipynb Cell 5 line 1
----> H_perceptron([0.001, 0.002], t=1)

File ~/.../pennylane/pulse/parametrized_hamiltonian.py:238, in ParametrizedHamiltonian.__call__(self, params, t)
    236 def __call__(self, params, t):
    237     if len(params) != len(self.coeffs_parametrized):
--> 238         raise ValueError(
    239             \"The length of the params argument and the number of scalar-valued functions \"
    240             f\"must be the same. Received len(params) = {len(params)} parameters but \"
    241             f\"expected {len(self.coeffs_parametrized)} parameters.\"
    242         )
    243     return self.H_fixed() + self.H_parametrized(params, t)

ValueError: The length of the params argument and the number of scalar-valued functions must be the same. Received len(params) = 2 parameters but expected 9 parameters."
}

But as you can see, my idea is to just have params be a length=2 array such that each term that is multiplied by some coefficient is being multiplied by the same “parameter/coefficient object”. If they are different parameters then, when my model is training, each term that has a certain parameter/coefficient will be updated independently, and I don’t want that. I know that this is probably some simple control flow issue like you said that I’m not really seeing right now. Do you see the solution here? I’ll keep looking at this.

Hey @NickGut0711, I might be misunderstanding what you want to do but let me consult another person internally here and see what they say!

1 Like

Hi @NickGut0711 I think I can help here.
We designed ParametrizedHamiltonian such that it would always require the same number of coefficients as there are terms in the parametrized Hamiltonian. That means for the case of having multiple equal terms you need to repeat the coefficients (solution 1 below) OR you define those equal terms as one operator and add those to the ParametrizedHamiltonian (solution 2 below, probably what you are looking for)

import pennylane as qml
from pennylane import X, Y, Z, I
import jax.numpy as jnp
import jax
jax.config.update("jax_enable_x64", True)
qml.operation.enable_new_opmath()

n = 5
ops = [Z(i) @ Z(i+1) for i in range(n-1)]
ops += [I(i) for i in range(n)]
coeffs = [qml.pulse.constant for _ in range(len(ops))] # same as your control field that is just a constant
H = qml.dot(coeffs, ops)

# In case you want to use the parametrized Hamiltonian in a qnode:

dev = qml.device("default.qubit.jax", wires=range(n))
@qml.qnode(dev, interface="jax")
def qnode(p):
    qml.evolve(H)([p[0]] * (n-1) + [p[1]] * n, t=10.)
    return qml.expval(Z(0))

p = jnp.array([1., 2.])
print(qnode(p))

# In case you really want to work on the Hamiltonian level directly, you can wrap it in a callable

def Ham(p, t):
    return H([p[0]] * (n-1) + [p[1]] * n, t)

print(Ham(p, 0.5))

Solution2: Here you would summarize all the Z@Z terms and Identity terms into one operator, each:

import pennylane as qml
from pennylane import X, Y, Z, I
import jax.numpy as jnp
import jax
jax.config.update("jax_enable_x64", True)
qml.operation.enable_new_opmath()

n = 5
H1 = qml.sum(*[Z(i) @ Z(i+1) for i in range(n-1)])
H2 = qml.sum(*[I(i) for i in range(n)])

H = qml.pulse.constant * H1 + qml.pulse.constant * H2 # requires only 2 parameters

# Working on the Hamiltonian level and qnode level is straight forward now

dev = qml.device("default.qubit.jax", wires=range(n))
@qml.qnode(dev, interface="jax")
def qnode(p):
    qml.evolve(H)(p, t=10.)
    return qml.expval(Z(0))

p = jnp.array([1., 2.])
print(qnode(p))

print(H(p, 0.5)) # call the Hamiltonian directly

Hope that helps! Let me know if something is unclear

1 Like

Hi @Qottmann! Both of these solutions really helped. I was thinking about using qml.pulse.constant anyways. Thank you! :star_struck:

Happy to hear it helped :slight_smile:

Just a small FYI, the qml.pulse.constant is just a shorthand for your

def control_field(p,t):
      # p is the trainable parameter
      # t is the time
      return p

and is doing the exact same :slight_smile:

1 Like