Haar Measure & Weingarten Calculus, numerical solution.
Tensor Network Weingarten Calculus
This notebook builds fluency with the tensor diagrammatic rules for computing Haar integrals over the unitary group. Every analytical step is derived symbolically in SymPy and independently verified by Monte Carlo sampling of Haar-random unitaries via NumPy/SciPy.
Background
A Haar average over the unitary group is the integral of a polynomial built from the entries \(U_{ij}\) and their complex conjugates, taken uniformly over all unitaries of a fixed dimension. Such integrals look forbidding because the group is continuous and high-dimensional, yet they collapse to something finite and combinatorial. The Weingarten calculus is the tool that carries out this collapse.
The rule expresses an average with \(k\) copies of \(U\) and \(k\) of \(U^\dagger\) as a sum over pairs of permutations of \(k\) objects. One permutation records how the output indices of the \(U\) factors are matched to those of the \(U^\dagger\) factors, the other does the same for the input indices, and each pairing carries a rational weight given by the Weingarten function. The dimension \(D\) enters only through powers that count the closed loops formed when the index matchings are drawn as a tensor diagram.
This mirrors Wick’s theorem for Gaussian integrals, where expectations reduce to sums over ways of pairing variables. Here the pairings are permutations rather than simple contractions, and the Weingarten weights replace the Gaussian propagator. The identity permutation, the uncrossed pairing, carries the largest weight and dominates at large \(D\), while crossings are suppressed by powers of \(1/D\), which makes the leading behavior of many quantities easy to read off.
The notebook works the lowest moments by hand within this scheme, climbing from the first-order trace product through the second-order purity to \(\mathbb{E}[|\mathrm{Tr}\,U|^6]=6\), and checks each step against symbolic algebra and Monte Carlo sampling.
Mathematical background
Weingarten averages have a diagrammatic form that makes the bookkeeping systematic. Each \(U\) and \(U^\dagger\) is a vertex, the average pairs every \(U\) with a \(U^\dagger\) through a permutation \(\sigma\) of the row indices and \(\tau\) of the column indices, and each closed loop of contracted indices contributes a factor \(D\). The general moment reads
with \(\mathrm{Wg}\) the inverse of the \(t!\times t!\) Gram matrix of permutation overlaps. At large \(D\) the Weingarten function behaves as \(\mathrm{Wg}(\sigma, D) \sim D^{-t-|\sigma|}\), where \(|\sigma|\) is the minimal number of transpositions in \(\sigma\), so non-identity pairings are suppressed by powers of \(1/D\). The leading contributions are the planar (non-crossing) contractions, and each crossing costs a factor \(1/D\). This is the genus expansion that organizes matrix models and their link to two-dimensional surfaces. The third trace moment evaluated in the notebook is the smallest case in which a non-planar diagram appears, so it is a clean place to watch the \(1/D\) hierarchy at work.
1. Physical Motivation
Many quantities in quantum information, purities, frame potentials, OTOCs, channel fidelities, reduce to polynomial averages over Haar-random unitaries. The Weingarten calculus is a systematic tool for computing these averages. It is the unitary-group analogue of Wick’s theorem for Gaussian integrals: just as Gaussian expectations reduce to sums over pair contractions, Haar expectations reduce to sums over permutation pairings, weighted by the Weingarten function.
The tensor network (Penrose) representation makes these sums geometrically transparent. The goal of this notebook is to master the three-step recipe: 1. Draw the unevaluated tensor network 2. Replace\(U/U^*\) pairs with permutation pairings 3. Count closed loops (each loop = factor of \(D\))
2. Mathematical Preliminaries (Self-Contained)
2.1 The symmetric group \(S_k\)
The symmetric group\(S_k\) is the group of all permutations of \(k\) objects \(\{1, 2, \ldots, k\}\). It has \(k!\) elements.
\(S_1 = \{\mathrm{id}\}\): one element (the identity).
\(S_2 = \{\mathrm{id}, (12)\}\): two elements, the identity and the swap of objects 1 and 2.
\(S_3\): six elements, \(\mathrm{id}\), three transpositions \((12), (13), (23)\), and two 3-cycles \((123), (132)\).
A permutation \(\pi \in S_k\) maps each index \(a \in \{1, \ldots, k\}\) to \(\pi(a)\). The cycle structure of \(\pi\) decomposes it into disjoint cycles. For example, \((123)\) means \(1 \to 2 \to 3 \to 1\), a single cycle of length 3. The number of cycles determines how many independent index sums survive after applying Kronecker deltas.
2.2 The Weingarten function
The Weingarten function\(\mathrm{Wg}(\pi, D)\) is a real-valued function on \(S_k\) (depending on Hilbert space dimension \(D\)) that serves as the weight for each permutation pairing in the Haar integral. It is defined implicitly as the inverse of the Gram matrix: \[\sum_{\sigma \in S_k} \mathrm{Wg}(\sigma, D) \cdot D^{\#\mathrm{cycles}(\pi \sigma^{-1})} = \delta_{\pi, \mathrm{id}}.\]
Key properties: - \(\mathrm{Wg}(\pi, D)\) depends only on the cycle type (conjugacy class) of \(\pi\) - The identity permutation has the largest weight, it is the “planar” (uncrossed) pairing - Non-identity permutations (“crossings”) are suppressed by powers of \(1/D\)
2.3 The Weingarten formula
The \(k\)-th moment of the Haar measure over \(U(D)\) is: \[\boxed{\mathbb{E}_{\mathrm{Haar}}[U_{i_1 j_1} \cdots U_{i_k j_k} U^*_{\ell_1 m_1} \cdots U^*_{\ell_k m_k}] = \sum_{\pi,\sigma \in S_k} \mathrm{Wg}(\pi^{-1}\sigma, D) \prod_{a=1}^k \delta_{i_a \ell_{\pi(a)}} \delta_{j_a m_{\sigma(a)}}}\]
Interpretation: The permutation \(\pi\) controls how the output (left) indices of \(U\) are paired with those of \(U^*\). The permutation \(\sigma\) does the same for input (right) indices. Each pairing is weighted by \(\mathrm{Wg}(\pi^{-1}\sigma, D)\).
Closed loop (wire with no open ends) = contributes a factor of \(D\)
Cup (semicircular arc connecting two wires) = Kronecker delta \(\delta_{ij}\)
Code
# Imports used throughoutimport numpy as npfrom scipy.stats import unitary_groupimport sympy as spfrom sympy import symbols, Matrix, trace as sp_trace, simplify, expand, Symbolfrom itertools import permutationsfrom collections import defaultdict, Counter# === Utility functions for permutation algebra ===def compose(p1, p2):"""Compose two permutations (as tuples): (p1 ∘ p2)(i) = p1(p2(i))."""returntuple(p1[p2[i]] for i inrange(len(p1)))def inverse(p):"""Inverse of a permutation (as tuple).""" r = [0]*len(p)for i, v inenumerate(p): r[v] = ireturntuple(r)def cycle_type(p):"""Return the cycle type of permutation p as a sorted tuple.""" visited =set(); lengths = []for s inrange(len(p)):if s notin visited: l =0; k = swhile k notin visited: visited.add(k); k = p[k]; l +=1 lengths.append(l)returntuple(sorted(lengths, reverse=True))def num_cycles(p):"""Count the number of cycles in permutation p.""" visited =set(); c =0for s inrange(len(p)):if s notin visited: c +=1; k = swhile k notin visited: visited.add(k); k = p[k]return c# Demonstrate S_2 and S_3print('=== The symmetric group S_k ===')print()S2 =list(permutations(range(2)))print(f'S_2 has {len(S2)} elements:')for p in S2:print(f' {p} - cycle type {cycle_type(p)}, {num_cycles(p)} cycle(s)')print()S3 =list(permutations(range(3)))print(f'S_3 has {len(S3)} elements:')for p in S3:print(f' {p} - cycle type {cycle_type(p)}, {num_cycles(p)} cycle(s)')
=== The symmetric group S_k ===
S_2 has 2 elements:
(0, 1) - cycle type (1, 1), 2 cycle(s)
(1, 0) - cycle type (2,), 1 cycle(s)
S_3 has 6 elements:
(0, 1, 2) - cycle type (1, 1, 1), 3 cycle(s)
(0, 2, 1) - cycle type (2, 1), 2 cycle(s)
(1, 0, 2) - cycle type (2, 1), 2 cycle(s)
(1, 2, 0) - cycle type (3,), 1 cycle(s)
(2, 0, 1) - cycle type (3,), 1 cycle(s)
(2, 1, 0) - cycle type (2, 1), 2 cycle(s)
3. Part (a): First Moment, \(\mathbb{E}[\mathrm{Tr}(UAU^\dagger B)]\)
Analytical derivation
Goal: Compute \(\mathbb{E}_U[\mathrm{Tr}(\hat{U}\hat{A}\hat{U}^\dagger\hat{B})]\) for fixed matrices \(\hat{A}, \hat{B}\).
Step 1, Index expansion:\[\mathrm{Tr}(UAU^\dagger B) = \sum_{i,j,k,l} U_{ij}\, A_{jk}\, U^*_{lk}\, B_{li}\]
print('=== Part (a): SymPy symbolic verification ===')print()# Step 1: Build symbolic matrices (D=3 for concreteness)D_val =3A = Matrix([[symbols(f'a{i}{j}') for j inrange(D_val)] for i inrange(D_val)])B = Matrix([[symbols(f'b{i}{j}') for j inrange(D_val)] for i inrange(D_val)])# Step 2: Apply Weingarten formula by brute-force index contractionprint('Step 2: Applying E[U_{ij}U*_{lk}] = δ_{il}δ_{jk}/D index by index...')result_indices = sp.Integer(0)for i inrange(D_val):for j inrange(D_val):for k inrange(D_val):for l inrange(D_val):if i == l and j == k: # δ_{il} * δ_{jk} result_indices += A[j,k] * B[l,i] / D_val# Step 3: Compare with closed-formresult_formula = sp_trace(A) * sp_trace(B) / D_valprint(f' Index contraction: {simplify(result_indices)}')print(f' Formula Tr(A)Tr(B)/D: {simplify(result_formula)}')assert expand(result_indices - result_formula) ==0print(' MATCH')
=== Part (a): SymPy symbolic verification ===
Step 2: Applying E[U_{ij}U*_{lk}] = δ_{il}δ_{jk}/D index by index...
Index contraction: a00*b00/3 + a00*b11/3 + a00*b22/3 + a11*b00/3 + a11*b11/3 + a11*b22/3 + a22*b00/3 + a22*b11/3 + a22*b22/3
Formula Tr(A)Tr(B)/D: (a00 + a11 + a22)*(b00 + b11 + b22)/3
MATCH
Code
print('=== Part (a): NumPy Monte Carlo cross-check ===')np.random.seed(42)for D_val in [2, 4, 8]:# Random complex matrices A_n = np.random.randn(D_val, D_val) +1j*np.random.randn(D_val, D_val) B_n = np.random.randn(D_val, D_val) +1j*np.random.randn(D_val, D_val)# Monte Carlo: sample Haar-random U, compute Tr(UAU†B) mc_vals = [np.trace(U @ A_n @ U.conj().T @ B_n)for U in [unitary_group.rvs(D_val) for _ inrange(15000)]] mc_mean = np.mean(mc_vals)# Analytical exact = np.trace(A_n) * np.trace(B_n) / D_valprint(f' D={D_val}: MC = {mc_mean:.4f}, analytic = {exact:.4f}, 'f'|err| = {abs(mc_mean - exact):.4f}')assertabs(mc_mean - exact) <0.3*max(abs(exact), 1.0)print(' All first-moment checks PASS')
=== Part (a): NumPy Monte Carlo cross-check ===
D=2: MC = -0.8851-0.5541j, analytic = -0.8590-0.5729j, |err| = 0.0321
D=4: MC = -0.3010+0.1774j, analytic = -0.3099+0.1877j, |err| = 0.0136
D=8: MC = 0.2752-0.0297j, analytic = 0.2306+0.0000j, |err| = 0.0536
All first-moment checks PASS
4. Part (b): Second Moment, Haar-Averaged Purity
Analytical derivation
Goal: Derive \(\mathbb{E}[\mathrm{Tr}(\hat{\rho}_A^2)]\) for a Haar-random pure state \(|\psi\rangle = U|0\rangle\) on \(\mathcal{H}_A \otimes \mathcal{H}_B\) with \(\dim\mathcal{H}_A = D_A\), \(\dim\mathcal{H}_B = D_B\), \(D = D_A D_B\).
Step 1, Setup: Using the SWAP trick, \(\mathrm{Tr}(\hat{\rho}_A^2) = \mathrm{Tr}(\mathbb{F}_A \cdot \hat{\rho}^{\otimes 2})\). This requires \(k=2\) copies of \(U\) and \(U^*\).
Step 2, Index structure: All input indices are fixed to the reference state \(|0\rangle\), meaning \(j_1 = j_2 = m_1 = m_2 = 0\). This makes \(\delta_{j_a, m_{\sigma(a)}} = \delta_{0,0} = 1\) for every\(\sigma \in S_2\). Therefore the \(\sigma\) sum is free: every permutation \(\sigma\) contributes equally.
Step 3, Sigma sum:\[\sum_{\sigma \in S_2} \mathrm{Wg}(\pi^{-1}\sigma, D) = \sum_{\tau \in S_2} \mathrm{Wg}(\tau, D) = \frac{1}{D^2-1} - \frac{1}{D(D^2-1)} = \frac{1}{D(D+1)}\] This is the same for every \(\pi\) (since we’re summing \(\tau = \pi^{-1}\sigma\) over all of \(S_2\)).
Step 4, Loop counting: The output wiring has SWAP on \(A\) and identity on \(B\). The effective permutation for output pairing \(\pi\) is:
This sets \(j_a = i_a\) and \(m_a = \ell_a\) (the trace constraint: all indices are “diagonal”).
Step 2, Weingarten sum: For each \((\pi, \sigma) \in S_3 \times S_3\), the delta product \(\prod_a \delta_{i_a, \ell_{\pi(a)}} \delta_{i_a, \ell_{\sigma(a)}}\) forces \(\ell_{\pi(a)} = \ell_{\sigma(a)}\) for all \(a\). The number of free summation indices equals \(D^{\#\text{cycles}(\pi^{-1}\sigma)}\).
Step 3, Group by cycle type of \(\pi^{-1}\sigma\): Since \(\mathrm{Wg}\) depends only on cycle type, we count how many of the \(36\) pairs \((\pi, \sigma)\) give each cycle type of \(\pi^{-1}\sigma\).
Code
print('=== Part (c): Full S₃ Weingarten calculus ===')print()# Step 1: Enumerate S_3 and show cycle typesS3 =list(permutations(range(3)))print(f'Step 1: S₃ has {len(S3)} elements, giving {len(S3)**2} pairs (π,σ)')print()ct_count = Counter(cycle_type(p) for p in S3)for ct, n insorted(ct_count.items()):print(f' Cycle type {ct}: {n} permutations')# Step 2: Count (π,σ) pairs by cycle type of π^{-1}σprint()print('Step 2: Distribution of π⁻¹σ cycle types across all 36 pairs')pair_types = defaultdict(lambda: {'count': 0, 'ncyc': 0})for pi in S3:for sig in S3: prod = compose(inverse(pi), sig) ct = cycle_type(prod) pair_types[ct]['count'] +=1 pair_types[ct]['ncyc'] = num_cycles(prod)print(' cycle type | # pairs | # cycles | loop factor')print('-'*50)for ct insorted(pair_types.keys()): d = pair_types[ct] cnt = d["count"] ncyc = d["ncyc"]print(f'{str(ct):>12} | {cnt:7d} | {ncyc:8d} | D^{ncyc}')
=== Part (c): Full S₃ Weingarten calculus ===
Step 1: S₃ has 6 elements, giving 36 pairs (π,σ)
Cycle type (1, 1, 1): 1 permutations
Cycle type (2, 1): 3 permutations
Cycle type (3,): 2 permutations
Step 2: Distribution of π⁻¹σ cycle types across all 36 pairs
cycle type | # pairs | # cycles | loop factor
--------------------------------------------------
(1, 1, 1) | 6 | 3 | D^3
(2, 1) | 18 | 2 | D^2
(3,) | 12 | 1 | D^1
Code
# Step 3: Compute E[|Tr(U)|^6] for several Dprint('Step 3: Explicit evaluation of the Weingarten sum')print()def wg3_values(D):"""Return k=3 Weingarten function values by cycle type.""" dn = D * (D**2-1) * (D**2-4)return { (1,1,1): (D**2-2) / dn, (2,1): -1.0/ ((D**2-1) * (D**2-4)), (3,): 2.0/ dn }print(' D | ', end='')for ct insorted(pair_types.keys()): n = pair_types[ct]['count'] nc = pair_types[ct]['ncyc'] ct_str =str(ct)print(f'{n} x Wg({ct_str}) x D^{nc}', end=' | ')print('Total')print('-'*80)for D in [3, 4, 5, 10, 50, 200]: W = wg3_values(D) terms = [] total =0.0for ct insorted(pair_types.keys()): count = pair_types[ct]['count'] nc = pair_types[ct]['ncyc'] contrib = count * W[ct] * D**nc terms.append(f'{contrib:+10.4f}') total += contribprint(f'{D:4d} | '+' | '.join(terms) +f' | {total:6.1f}')print()print('Result: E[|Tr(U)|⁶] = 6 = 3! for all D ≥ 3')print('This is the CUE moment theorem: E[|Tr(U)|^{2k}] = k!')
Step 3: Explicit evaluation of the Weingarten sum
D | 6 x Wg((1, 1, 1)) x D^3 | 18 x Wg((2, 1)) x D^2 | 12 x Wg((3,)) x D^1 | Total
--------------------------------------------------------------------------------
3 | +9.4500 | -4.0500 | +0.6000 | 6.0
4 | +7.4667 | -1.6000 | +0.1333 | 6.0
5 | +6.8452 | -0.8929 | +0.0476 | 6.0
10 | +6.1869 | -0.1894 | +0.0025 | 6.0
50 | +6.0072 | -0.0072 | +0.0000 | 6.0
200 | +6.0005 | -0.0005 | +0.0000 | 6.0
Result: E[|Tr(U)|⁶] = 6 = 3! for all D ≥ 3
This is the CUE moment theorem: E[|Tr(U)|^{2k}] = k!
Code
# Step 4: NumPy Monte Carlo cross-checkprint('Step 4: NumPy Monte Carlo cross-check')np.random.seed(42)for D in [3, 5, 10]: vals = [abs(np.trace(unitary_group.rvs(D)))**6for _ inrange(20000)] mc = np.mean(vals) mc_err = np.std(vals) / np.sqrt(len(vals))print(f' D={D:3d}: MC = {mc:.3f} ± {mc_err:.3f}, analytic = 6.000, 'f'|err| = {abs(mc -6):.3f}')assertabs(mc -6.0) <3*mc_err +0.2print(' All sixth-moment checks PASS')
D= 10: MC = 6.089 ± 0.178, analytic = 6.000, |err| = 0.089
All sixth-moment checks PASS
6. Physical Interpretation
The Gaussian limit of the trace moments
This result connects Haar-random unitaries to complex Gaussian random variables. For a single complex Gaussian \(z \sim \mathcal{N}_\mathbb{C}(0,1)\), the moments are \(\mathbb{E}[|z|^{2k}] = k!\) (by Wick’s theorem). In the large-\(D\) limit, \(\mathrm{Tr}(U)\) for a Haar-random \(U \in U(D)\) behaves like a standard complex Gaussian, a result of Diaconis and Shahshahani that holds despite the strong correlations between the eigenphases \(e^{i\theta_j}\) of \(U\).
The remarkable fact is that the Weingarten calculus gives \(k!\)exactly for all \(D \geq k\), not just asymptotically. This is because the Weingarten function is the exact inverse of the representation-theoretic Gram matrix, and the exact cancellations between planar and non-planar contributions produce dimension-independent trace moments.
The topological structure
Planar pairings (identity permutation, uncrossed wires) produce the most loops and dominate at large \(D\)
Non-planar pairings (crossings) produce fewer loops and are suppressed by \(1/D\) per crossing
The \(1/D\) suppression hierarchy mirrors the genus expansion of matrix models, a deep connection between random matrix theory and two-dimensional quantum gravity
Takeaway
Each Haar average collapses to a weighted sum over permutations, with loop counting fixing the dimension factors and the Weingarten function fixing the weights, reproducing the closed forms through \(\mathbb{E}[|\mathrm{Tr}\,U|^6] = 6\). Symbolic algebra and Monte Carlo sampling agree at every moment.
References
Collins and Nechita, Random quantum channels I: graphical calculus and the Bell state phenomenon, Communications in Mathematical Physics (2010).
Collins et al., The Weingarten calculus, Notices of the American Mathematical Society (2022).
Fukuda et al., RTNI, A symbolic integrator for Haar-random tensor networks, Journal of Physics A: Mathematical and Theoretical (2019). arXiv:1902.08539
Puchała and Miszczak, Symbolic integration with respect to the Haar measure on the unitary group, Bulletin of the Polish Academy of Sciences: Technical Sciences (2017). arXiv:1109.4244
Płodzień, Information Scrambling, Quantum Chaos, and Haar-Random States, lecture notes (2025). arXiv:2511.14397