3. DMRG¶
By : Ke Hsu, Kai-Hsin Wu
The density matrix renormalization group (DMRG) is one of the powerful algorithm for study quantum systems. The algorithm is especially useful in study 1D systems, while the extension to study 2D systems are also possible. The original formulation [Whi92] of DMRG is based on the desity matrix, and had been later-on being re-formulated with the concept of matrix product state (MPS) [Sch11].
In the following, we are going use the MPS language for explaination. There are two important objects Matrix product state (MPS) and Matrix product operator (MPO) which we will explain them in a moment.
Using 1D spin-1/2 XY model as an example system, we are going to introduce how to use Cytnx to implement the DMRG algorithm, and benchmark our results with the exact analytic solution avaliable via bethe ansatz.
3.1. The model– XY chain¶
Before introduce the algorithm, let’s start with the system that we are going to study. Consider the XY model where the Hamiltonian defines as:
where
3.2. Basic components¶
Matrix product state (MPS):
Our major goal in this example is, of course, to get the ground state. The state
is represent in terms of the MPS, which is variational wave function written in an effieient way to represent a many-body wave function. The tensor notation are shown in following figure (a)Matrix product operator (MPO):
The concept of MPO is actually quite simple, for which it is just the way we decompose our many-body Hamiltonian into local operators M (on each site) In our XY model, we define the MPO (on each site j) as:
with the left and right boundary:
which is shown in the following figure (b). One can easily verify that succesive product of number of M operators along with the left and right boundaries gives the desire Hamitonian of the model.

3.3. Preparing the MPO¶
Now, let’s first prepare the MPO, M. Here, the d is the physical bond dimension. For spin-half it is d=2. s is the spin, for spin-half s=0.5. Here, since we have translational invariant, so all M_j for each site j will be the same. Thus, we only need to define a single M operator.
L0 and R0 are the left and right boundary as mentioned previously.
In Python:
1d = 2 #physical dimension
2s = 0.5 #spin-half
3
4sx = cytnx.physics.spin(0.5,'x')
5sy = cytnx.physics.spin(0.5,'y')
6sp = sx+1j*sy
7sm = sx-1j*sy
8
9eye = cytnx.eye(d)
10M = cytnx.zeros([4, 4, d, d])
11M[0,0] = M[3,3] = eye
12M[0,1] = M[2,3] = 2**0.5*sp.real()
13M[0,2] = M[1,3] = 2**0.5*sm.real()
14M = cytnx.UniTensor(M,0)
15
16L0 = cytnx.UniTensor(cytnx.zeros([4,1,1]), rowrank = 0) #Left boundary
17R0 = cytnx.UniTensor(cytnx.zeros([4,1,1]), rowrank = 0) #Right boundary
18L0[0,0,0] = 1.; R0[3,0,0] = 1.
Here, we first provide the Matrix data via Tensor, and convert then to UniTensor, which gives enhanced functionality (such as labels for each bond).
At this moment, let’s print out to show what M, L0 and R0 looks like:
In Python:
1M.print_diagram()
2L0.print_diagram()
3R0.print_diagram()
Output >>
-----------------------
tensor Name :
tensor Rank : 4
block_form : false
is_diag : False
on device : cytnx device: CPU
-------------
/ \
| 4 |____ 0
| |
| 4 |____ 1
| |
| 2 |____ 2
| |
| 2 |____ 3
\ /
-------------
-----------------------
tensor Name :
tensor Rank : 3
block_form : false
is_diag : False
on device : cytnx device: CPU
-------------
/ \
| 4 |____ 0
| |
| 1 |____ 1
| |
| 1 |____ 2
\ /
-------------
-----------------------
tensor Name :
tensor Rank : 3
block_form : false
is_diag : False
on device : cytnx device: CPU
-------------
/ \
| 4 |____ 0
| |
| 1 |____ 1
| |
| 1 |____ 2
\ /
-------------
3.4. Preparing the MPS and enviroments¶
Next, we are going to prepare our variational ansatz (MPS). Here, chi is the virtual bond dimension, and Nsites is the number of sites.
In Python:
1# MPS, chi is virtual bond dimension
2chi = 32
3Nsites = 20
4lbls = [] # List for storing the MPS labels
5A = [None for i in range(Nsites)]
6A[0] = cytnx.UniTensor(cytnx.random.normal([1, d, min(chi, d)], 0., 1.), rowrank = 2)
7A[0].relabels_(["0","1","2"])
8lbls.append(["0","1","2"]) # store the labels for later convinience.
9
10for k in range(1,Nsites):
11 dim1 = A[k-1].shape()[2]; dim2 = d
12 dim3 = min(min(chi, A[k-1].shape()[2] * d), d ** (Nsites - k - 1))
13 A[k] = cytnx.UniTensor(cytnx.random.normal([dim1, dim2, dim3],0.,1.), rowrank = 2)
14
15 lbl = [str(2*k),str(2*k+1),str(2*k+2)]
16 A[k].relabels_(lbl)
17 lbls.append(lbl) # store the labels for later convinience.
The resulting MPS would look like a tensor train, stored in the list A:

The dim3 of each tensor may look a little bit tricky, but we are simply comparing the “maximum dimension needed to span the information for the left part” and that of the right part, while we also want the disired dim3 not larger than our bond dimension.
The alternative way to assign dim3 is min(chi, d ** (k+1), d ** (Nsites - k - 1))
The MPS created at this moment are not physically sound. The one more thing we need to do is to make these MPS state into so called canonical form, for which we achieve this by iteratively performing svd and get it’s left (or right, depending on how you do it.) unitary matrix. Here, we do it from left to right, and we decompose each tensor into its U, s and vT, then “throw” the s and vT part into next tensor, until the mps becomes its left normal form:

The othogonal form of the MPS looks like:

Further more, as a naive implementation, here, at the same time we also store all the left and right enviroments LR, assocate to each site just for convenience. These include contracting 4 tensors L, M, A and
Here, the contraction can be easily performed using cytnx.Network with the contraction graph defined by the network file (L_AMAH.net) as following:
L_AMAH.net:
1L: -2,-1,-3
2A: -1,-4,1
3M: -2,0,-4,-5
4A_Conj: -3,-5,2
5TOUT: 0,1,2
we load it, put tensors in, then call “Launch”, all the four tensors got contracted properly and optimally, in the for loop, the whole process looks like following:

The full implementation looks like:
In Python:
1LR = [None for i in range(Nsites+1)]
2LR[0] = L0
3LR[-1] = R0
4
5for p in range(Nsites - 1):
6
7 ## Changing to canonical form site by site:
8 s, A[p] ,vt = cytnx.linalg.Gesvd(A[p])
9 A[p+1] = cytnx.Contract(cytnx.Contract(s,vt),A[p+1])
10
11 ## Calculate enviroments:
12 anet = cytnx.Network("L_AMAH.net")
13 anet.PutUniTensors(["L","A","A_Conj","M"],[LR[p],A[p],A[p].Conj(),M])
14 LR[p+1] = anet.Launch()
15
16 # Recover the original MPS labels
17 A[p].relabels_(lbls[p])
18 A[p+1].relabels_(lbls[p+1])
19
20_,A[-1] = cytnx.linalg.Gesvd(A[-1],is_U=True,is_vT=False) ## last one.
21A[-1].relabels_(lbls[-1]) # Recover the original MPS labels
In the line 20, we perform SVD on the last tensor but only save the U part, this is the case since the shape of the original tensor is (A[Nsites-2].shape[2], 1, 1), what we get from SVD is 1*1 matrix (or a number) for both s and Vt, moreover, these two numbers are just identity, so U is all we need.
3.5. Optimization of MPS (update sweep)¶
Now we are ready for describing the main DMRG algorithm that optimize our MPS, the way we are going to do this, is so called “sweeping” update. First, we provide the full code for a single sweep process:
In Python:
1numsweeps = 4 # number of DMRG sweeps
2maxit = 2 # iterations of Lanczos method
3
4for p in range(Nsites-2,-1,-1):
5 dim_l = A[p].shape()[0]
6 dim_r = A[p+1].shape()[2]
7 new_dim = min(dim_l*d,dim_r*d,chi)
8
9 psi = cytnx.Contract(A[p],A[p+1]) # contract
10 psi, Entemp = optimize_psi(psi, (LR[p],M,M,LR[p+2]), maxit, krydim)
11
12 psi.set_rowrank_(2) # maintain rowrank to perform the svd
13 s,A[p],A[p+1] = cytnx.linalg.Svd_truncate(psi,new_dim)
14 A[p+1].relabels_(lbls[p+1]); # set the label back to be consistent
15
16 s = s/s.Norm().item() # normalize s
17
18 A[p] = cytnx.Contract(A[p],s) # absorb s into next neighbor
19 A[p].relabels_(lbls[p]); # set the label back to be consistent
20
21 # update LR from right to left:
22 anet = cytnx.Network("R_AMAH.net")
23 anet.PutUniTensors(["R","B","M","B_Conj"],[LR[p+2],A[p+1],M,A[p+1].Conj()])
24 LR[p+1] = anet.Launch()
25
26 print('Sweep[r->l], Loc:%d,Energy: %f'%(p,Ekeep[-1]))
27
28A[0].set_rowrank_(1) # maintain rowrank to perform the svd
29_,A[0] = cytnx.linalg.Gesvd(A[0],is_U=False, is_vT=True)
30A[0].relabels_(lbls[0]); # set the label back to be consistent
There are lots of things happening here, let’s break it up a bit, from right to left, the first thing we do is to contract two tensors A[p] and A[p+1]:

Generally, the idea is pretty simple, for each local two sites, one contract the left and right enviroments
The lowest eigen vector of this operator will be our optimized local state, which we call this psi. Of course, one can performs eigH directly with this
Instead, we use iterative solver (Lanczos method) to get our ground state, and use the A[p] and A[p+1] as our initial trial state for performing Lanczos with our local operator
The
projector.net:
1psi: -1,-2,-3,-4
2L: -5,-1,0
3R: -7,-4,3
4M1: -5,-6,-2,1
5M2: -6,-7,-3,2
6TOUT: 0,1,2,3
which in tensor notation looks like this:

To ultilize the Lanczos function, the opertion of acting Hamitonian (which involves contraction using a network) is implemented using LinOp class (See Iterative Solver section for furtuer details).
In Python:
1class Hxx(cytnx.LinOp):
2
3 def __init__(self, anet, psidim):
4 cytnx.LinOp.__init__(self,"mv", psidim, cytnx.Type.Double, cytnx.Device.cpu)
5 self.anet = anet
6
7 def matvec(self, v):
8 lbl = v.labels()
9 self.anet.PutUniTensor("psi",v)
10 out = self.anet.Launch()
11 out.relabels_(lbl)
12 return out
the class itself contain this projector network and do the contraction job for the input vector(state). We then pass this linear operation to the Lanczos algorithm to use as the operation of optimization.
So now the optimize_psi function looks like:
In Python:
1def optimize_psi(psi, functArgs, maxit=2, krydim=4):
2
3 L,M1,M2,R = functArgs
4 anet = cytnx.Network("projector.net")
5 anet.PutUniTensors(["L","M1","M2","R"],[L,M1,M2,R])
6
7 H = Hxx(anet, psi.shape()[0]*psi.shape()[1]*psi.shape()[2]*psi.shape()[3])
8 energy, psivec = cytnx.linalg.Lanczos(Hop = H, method = "Gnd", Maxiter = 4, CvgCrit = 9999999999, Tin = psi)
9
10 return psivec, energy[0].item()
Where we constructed the network (put tensors in) then pass it to our linear operation H.
Now, we get our energy and ground state for a two-sites system, after some re-labeling (in order to contract UniTensor) and reshape, we have to make our psi into the canonical form, for which we do the SVD for the ground state we just obtained, then let the left hand side site keep the U and s, while the other site became Vt. The intermediate bond are truncated such that the maximum virtual bond dimension is limited to chi.
In Python:
1new_dim = min(dim_l*d,dim_r*d,chi)
2
3psi.set_rowrank_(2) # maintain rowrank to perform the svd
4s,A[p],A[p+1] = cytnx.linalg.Svd_truncate(psi,new_dim)
5A[p+1].relabels_(lbls[p+1]); # set the label back to be consistent
6
7s = s/s.Norm().item() # normalize s
8
9A[p] = cytnx.Contract(A[p],s) # absorb s into next neighbor
10A[p].relabels_(lbls[p]); # set the label back to be consistent


remember that the right hand side vTs are obtained after we do the optimization, those are immediately used to calculate the updated right enviroment using the network
R_AMAH.net:
1R: -2,-1,-3
2B: 1,-4,-1
3M: 0,-2,-4,-5
4B_Conj: 2,-5,-3
5TOUT: 0,1,2
graphically it looks like:

So our enviroments are also updated by the vT from the optimized two-side states.
The Svd_truncate is used to limit the tensor size, followed by a normalization on singular values, which is the physical requirement for the state of the whole system to be in the Schimit form.
The for loop is finished, now we arrived at the left end of the system, with the last two line
In Python:
1A[0].set_rowrank_(1)
2_,A[0] = cytnx.linalg.Gesvd(A[0],is_U=False, is_vT=True)
3A[0].relabels_(lbls[0]); #set the label back to be consistent
looks like the same as we did for the right-end site in the beginning, this time we saves the vT, the purpose of the set_rowrank_(1) is only for the convenience of calling Svd/Svd_truncate in the next sweeping procedure from left to right.
We can now sweep from left to the right. The code is pretty much the same as we went through, with only a few modifications.
So we are done! With the other loop to control the number of times we sweep, we get the full DMRG sweep code:
In Python:
1# DMRG sweep
2
3Ekeep = [] # For storing the energy
4
5for k in range(1, numsweeps+1):
6
7 for p in range(Nsites-2,-1,-1):
8 dim_l = A[p].shape()[0]
9 dim_r = A[p+1].shape()[2]
10 new_dim = min(dim_l*d,dim_r*d,chi)
11
12 psi = cytnx.Contract(A[p],A[p+1]) # contract
13 psi, Entemp = optimize_psi(psi, (LR[p],M,M,LR[p+2]), maxit)
14 Ekeep.append(Entemp)
15
16 psi.set_rowrank_(2) # maintain rowrank to perform the svd
17 s,A[p],A[p+1] = cytnx.linalg.Svd_truncate(psi,new_dim)
18 A[p+1].relabels_(lbls[p+1]); # set the label back to be consistent
19
20 s = s/s.Norm().item() # normalize s
21
22 A[p] = cytnx.Contract(A[p],s) # absorb s into next neighbor
23 A[p].relabels_(lbls[p]); # set the label back to be consistent
24
25 # update LR from right to left:
26 anet = cytnx.Network("R_AMAH.net")
27 anet.PutUniTensors(["R","B","M","B_Conj"],[LR[p+2],A[p+1],M,A[p+1].Conj()])
28 LR[p+1] = anet.Launch()
29
30 print('Sweep[r->l]: %d/%d, Loc: %d,Energy: %f' % (k, numsweeps, p, Ekeep[-1]))
31
32 A[0].set_rowrank_(1)
33 _,A[0] = cytnx.linalg.Gesvd(A[0],is_U=False, is_vT=True)
34 A[0].relabels_(lbls[0]); #set the label back to be consistent
35
36 for p in range(Nsites-1):
37 dim_l = A[p].shape()[0]
38 dim_r = A[p+1].shape()[2]
39 new_dim = min(dim_l*d,dim_r*d,chi)
40
41 psi = cytnx.Contract(A[p],A[p+1]) ## contract
42 psi, Entemp = optimize_psi(psi, (LR[p],M,M,LR[p+2]), maxit)
43 Ekeep.append(Entemp)
44
45 psi.set_rowrank_(2) # maintain rowrank to perform the svd
46 s,A[p],A[p+1] = cytnx.linalg.Svd_truncate(psi,new_dim)
47 A[p].relabels_(lbls[p]); #set the label back to be consistent
48
49 s = s/s.Norm().item() # normalize s
50
51 A[p+1] = cytnx.Contract(s,A[p+1]) ## absorb s into next neighbor.
52 A[p+1].relabels_(lbls[p+1]); #set the label back to be consistent
53
54 # update LR from left to right:
55 anet = cytnx.Network("L_AMAH.net")
56 anet.PutUniTensors(["L","A","A_Conj","M"],[LR[p],A[p],A[p].Conj(),M])
57 LR[p+1] = anet.Launch()
58
59 print('Sweep[l->r]: %d/%d, Loc: %d,Energy: %f' % (k, numsweeps, p, Ekeep[-1]))
60
61 A[-1].set_rowrank_(2)
62 _,A[-1] = cytnx.linalg.Gesvd(A[-1],is_U=True,is_vT=False) ## last one.
63 A[-1].relabels_(lbls[-1]); #set the label back to be consistent
3.6. Compare DMRG Results¶
Here, we plot the energy as a function of iteration. We see that after iterations, the energy successfully converge to a value that is consistent with the exact solution.
In Python:
1#### Compare with exact results (computed from free fermions)
2from numpy import linalg as LA
3# import matplotlib.pyplot as plt
4H = np.diag(np.ones(Nsites-1),k=1) + np.diag(np.ones(Nsites-1),k=-1)
5D = LA.eigvalsh(H)
6EnExact = 2*sum(D[D < 0])
7
8##### Plot results
9plt.figure(1)
10plt.yscale('log')
11plt.plot(range(len(Ekeep)), np.array(Ekeep) - EnExact, 'b', label="chi = %d"%(chi), marker = 'o')
12plt.legend()
13plt.title('DMRG for XX model')
14plt.xlabel('Update Step')
15plt.ylabel('Ground Energy Error')
16plt.show()
For the 20 sites system, the result is:

Ulrich Schollwöck. The density-matrix renormalization group in the age of matrix product states. Annals of Physics, 326(1):96–192, 2011. January 2011 Special Issue. URL: https://www.sciencedirect.com/science/article/pii/S0003491610001752, doi:https://doi.org/10.1016/j.aop.2010.09.012.
Steven R. White. Density matrix formulation for quantum renormalization groups. Phys. Rev. Lett., 69:2863–2866, Nov 1992. URL: https://link.aps.org/doi/10.1103/PhysRevLett.69.2863, doi:10.1103/PhysRevLett.69.2863.