<SYSTEM_TASK:>
Given the following text description, write Python code to implement the functionality described below step by step
<END_TASK>
<USER_TASK:>
Description:
 We can extract just the unitary part of the circuit as a tensor network like so
Step1:  You can see it already has various tags identifying its structure (indeed enough to uniquely identify each gate)
Step2:  The core object describing how similar two unitaries are is
Step3:  For our loss function we'll normalize this and negate it (since the optimizer minimizes).
Step4:  We could call optimize for pure gradient based optimization, but since unitary circuits can be tricky we'll use optimize_basinhopping which combines gradient descent with 'hopping' to escape local minima
Step5:  The optimized tensor network still contains PTensor instances but now with optimized parameters. 
For example, here's the tensor of the U3 gate acting on qubit-2 in round-4
Step6:  We can see the parameters have been updated by the training
Step7:  We can see what gate these parameters would generate
Step8:  A final sanity check we can perform is to try evolving a random state with the target unitary and trained circuit and check the fidelity between the resulting states.
First we turn the tensor network version of $V$ into a dense matrix
Step9:  Next we create a random initial state, and evolve it with the
Step10:  The (in)fidelity should broadly match our training loss
<ASSISTANT_TASK:>
Python Code:
V = circ.uni
Explanation: We can extract just the unitary part of the circuit as a tensor network like so:
End of explanation
V.graph(color=['U3', gate2], show_inds=True)
V.graph(color=[f'ROUND_{i}' for i in range(depth)], show_inds=True)
V.graph(color=[f'I{i}' for i in range(n)], show_inds=True)
# the hamiltonian
H = qu.ham_ising(n, jz=1.0, bx=0.7, cyclic=False)
# the propagator for the hamiltonian
t = 2
U_dense = qu.expm(-1j * t * H)
# 'tensorized' version of the unitary propagator
U = qtn.Tensor(
    data=U_dense.reshape([2] * (2 * n)),
    inds=[f'k{i}' for i in range(n)] + [f'b{i}' for i in range(n)],
    tags={'U_TARGET'}
)
U.graph(color=['U3', gate2, 'U_TARGET'])
Explanation: You can see it already has various tags identifying its structure (indeed enough to uniquely identify each gate):
End of explanation
(V.H & U).graph(color=['U3', gate2, 'U_TARGET'])
Explanation: The core object describing how similar two unitaries are is: $\mathrm{Tr}(V^{\dagger}U)$, which we can naturally visualize at a tensor network:
End of explanation
def loss(V, U):
    return 1 - abs((V.H & U).contract(all, optimize='auto-hq')) / 2**n
# check our current unitary 'infidelity':
loss(V, U)
# use the autograd/jax based optimizer
import quimb.tensor.optimize_autograd as qto
tnopt = qto.TNOptimizer(
    V,                        # the tensor network we want to optimize
    loss,                     # the function we want to minimize
    loss_constants={'U': U},  # supply U to the loss function as a constant TN
    constant_tags=[gate2],    # within V we also want to keep all the CZ gates constant
    autograd_backend='jax',   # use 'autograd' for non-compiled optimization
    optimizer='L-BFGS-B',     # the optimization algorithm
)
Explanation: For our loss function we'll normalize this and negate it (since the optimizer minimizes).
End of explanation
# allow 10 hops with 500 steps in each 'basin'
V_opt = tnopt.optimize_basinhopping(n=500, nhop=10)
Explanation: We could call optimize for pure gradient based optimization, but since unitary circuits can be tricky we'll use optimize_basinhopping which combines gradient descent with 'hopping' to escape local minima:
End of explanation
V_opt['U3', 'I2', 'ROUND_4']
Explanation: The optimized tensor network still contains PTensor instances but now with optimized parameters. 
For example, here's the tensor of the U3 gate acting on qubit-2 in round-4:
End of explanation
# the initial values
V['U3', 'ROUND_4', 'I2'].params
# the optimized values
V_opt['U3', 'ROUND_4', 'I2'].params
Explanation: We can see the parameters have been updated by the training:
End of explanation
qu.U_gate(*V_opt['U3', 'ROUND_4', 'I2'].params)
Explanation: We can see what gate these parameters would generate:
End of explanation
V_opt_dense = V_opt.to_dense([f'k{i}' for i in range(n)], [f'b{i}' for i in range(n)])
Explanation: A final sanity check we can perform is to try evolving a random state with the target unitary and trained circuit and check the fidelity between the resulting states.
First we turn the tensor network version of $V$ into a dense matrix:
End of explanation
psi0 = qu.rand_ket(2**n)
# this is the exact state we want
psif_exact = U_dense @ psi0
# this is the state our circuit will produce if fed `psi0`
psif_apprx = V_opt_dense @ psi0
Explanation: Next we create a random initial state, and evolve it with the
End of explanation
f"Fidelity: {100 * qu.fidelity(psif_apprx, psif_exact):.2f} %"
Explanation: The (in)fidelity should broadly match our training loss:
End of explanation
<END_TASK>