Skip to content

Entropy (partial trace, von Neumann entropy, mutual information)

A qubit entangled with another one has no well-defined state of its own -- only the pair has a pure state. Measuring "how mixed" that single qubit looks on its own is exactly how entanglement shows up numerically: a qubit maximally entangled with its partner looks maximally random in isolation, even though the two-qubit system as a whole is perfectly pure. This module measures that: reducing a multi-qubit state down to a subsystem (partial_trace), quantifying how mixed the result is (von_neumann_entropy), and how much two subsystems know about each other (mutual_information).

Step 1. Reduce a Bell state down to one qubit

import numpy as np
import dense_evolution as de
from dense_evolution.physics.entropy import partial_trace

qasm = 'OPENQASM 2.0; include "qelib1.inc"; qreg q[2]; h q[0]; cx q[0],q[1];'
circuit = de.QASMParser().parse(qasm)
sim = de.DenseSVSimulator(2)
sim.run_circuit_jit(circuit.to_tuples())
sv = sim.get_statevector()

rho0 = partial_trace(sv, n_qubits=2, keep_qubits=[0])
np.round(rho0, 4)
array([[0.5+0.j, 0. +0.j],
       [0. +0.j, 0.5+0.j]])

sv is the same Bell state built on the Simulator page. partial_trace "forgets" qubit 1 and returns qubit 0's own 2x2 density matrix -- I/2, maximally mixed. That's the signature of entanglement: qubit 0 alone looks like a fair coin flip, even though the full 2-qubit state is perfectly pure and deterministic.

Step 2. How mixed is that, exactly?

from dense_evolution.physics.entropy import von_neumann_entropy

von_neumann_entropy(rho0)
0.6931471805599454

von_neumann_entropy is -Tr(rho * log(rho)), the quantum generalization of Shannon entropy -- 0 for a pure state, and ln(2) = 0.6931... (matching the printed value) for a single qubit's maximum possible mixedness. Step 1's rho0 hit that ceiling exactly, which is only possible when qubit 0 is maximally entangled with the rest of the system.

Step 3. Do the two qubits know about each other?

from dense_evolution.physics.entropy import mutual_information

mutual_information(sv, n_qubits=2, qubits_a=[0], qubits_b=[1])
1.3862943611189236

mutual_information(state, n_qubits, qubits_a, qubits_b) measures correlation between two subsystems directly from the full state -- no need to call partial_trace on each side yourself first. 2*ln(2) = 1.3862... is the maximum two qubits can share, and a Bell pair hits it exactly. This is the tool for a case a single-qubit expectation value structurally cannot catch: a qubit entangled in a Bell pair (or any subsystem maximally mixed on its own) has <Z> = 0 regardless of what happened to its partner -- the no-signaling theorem, not a measurement limitation -- but its mutual information with that partner is very much nonzero.

Step 4. Fitting a whole entropy curve to CFT theory

from dense_evolution.physics.entropy import central_charge

N = 20
Ls = [2, 4, 6, 8, 10]
S = [(0.5 / 6.0) * np.log((2 * N / np.pi) * np.sin(np.pi * L / N)) + 0.3 for L in Ls]

central_charge(Ls, S, n_qubits=N)
(0.5000000000000007, 1.0)

central_charge(Ls, S, n_qubits) fits an already-measured entanglement-entropy curve S(L) (entropy of the first L qubits, at several subsystem sizes L) to the Calabrese-Cardy CFT prediction and returns (c, r_squared) -- backend-agnostic, it doesn't compute entropies itself, only fits a curve you already measured (e.g. via Steps 1-2 above, repeated at each L). S here is built directly from the Calabrese-Cardy formula at the known value c=0.5, so a perfect round-trip (r_squared=1.0, recovered c matching to 1e-15) confirms the fit itself is correct -- a real critical spin chain's measured entropies won't be this clean, but the fit machinery is the same either way.


Details

Indexing convention: qubit 0 is the most significant bit of the basis-state index throughout this module, matching observables/ pauli_hamiltonian_to_matrix -- not the little-endian convention some other libraries use. The only prior partial trace in this package before this module existed (dashboard_core/state_visuals.py's private, single-qubit-only _reduced_density_matrix) used the opposite convention -- do not reuse that helper here, it would silently transpose which qubits get traced out.

A high r_squared alone doesn't mean the extracted c is trustworthy: Dense-Evolution-Discovery Experiment 36 found that fitting a real critical Ising chain's entropy curve at a finite-size susceptibility-peak pseudo-critical point (instead of the true self-dual CFT point) gives a deceptively clean fit (r_squared=0.999997) to a wrong answer -- c off by roughly 2x from the known Ising value c=1/2. Fitting at the correct critical point recovered c=0.565, much closer to the true 0.5.

entropy

Multi-qubit partial trace, von Neumann entropy, and mutual information.

Nothing like this existed anywhere in the package before: the only prior partial trace (dashboard_core/state_visuals.py's private _reduced_density_matrix) is single-qubit-only and uses the opposite, little-endian convention (qubit 0 = least significant bit). Everything here uses this package's own convention instead, matching dense_evolution.observables/pauli_hamiltonian_to_matrix: qubit 0 is the most significant bit of the basis-state index. Do not mix the two -- reusing dashboard_core's helper here would silently transpose which qubits get traced out.

Originated in research/wormhole_syk.py (a traversable-wormhole-inspired quantum teleportation reproduction) -- promoted here because these are generic quantum-information utilities, not specific to that experiment. Any state can have a subsystem's reduced density matrix, entropy, or the mutual information between two subsystems computed with these three functions; the wormhole work needed all three because the physically meaningful readout there (a message injected into one system showing up correlated with a reference qubit) is not visible in any single-qubit expectation value -- see mutual_information's docstring.

partial_trace

partial_trace(state, n_qubits, keep_qubits)

Reduced density matrix on keep_qubits, tracing out the rest.

MSB-first (qubit 0 = most significant bit), this package's own convention everywhere else -- NOT the same as dashboard_core. state_visuals._reduced_density_matrix, which is deliberately little-endian (Qiskit's convention) for its own Bloch-sphere/Q-sphere display consumers. Two genuinely different conventions for two different consumers, not an accidental divergence.

Parameters:

Name Type Description Default
state ndarray

A pure statevector of length 2**n_qubits.

required
n_qubits int
required
keep_qubits list[int]

Qubit indices (this package's MSB-first convention) to keep.

required

Returns:

Type Description
ndarray

Density matrix of shape (2len(keep_qubits), 2len(keep_qubits)).

Source code in dense_evolution/physics/entropy.py
def partial_trace(state, n_qubits, keep_qubits):
    """Reduced density matrix on `keep_qubits`, tracing out the rest.

    MSB-first (qubit 0 = most significant bit), this package's own
    convention everywhere else -- NOT the same as dashboard_core.
    state_visuals._reduced_density_matrix, which is deliberately
    little-endian (Qiskit's convention) for its own Bloch-sphere/Q-sphere
    display consumers. Two genuinely different conventions for two
    different consumers, not an accidental divergence.

    Parameters
    ----------
    state : np.ndarray
        A pure statevector of length 2**n_qubits.
    n_qubits : int
    keep_qubits : list[int]
        Qubit indices (this package's MSB-first convention) to keep.

    Returns
    -------
    np.ndarray
        Density matrix of shape (2**len(keep_qubits), 2**len(keep_qubits)).
    """
    keep_qubits = sorted(keep_qubits)
    trace_qubits = [q for q in range(n_qubits) if q not in keep_qubits]
    psi = np.transpose(np.asarray(state).reshape([2] * n_qubits), keep_qubits + trace_qubits)
    keep_dim, trace_dim = 2 ** len(keep_qubits), 2 ** len(trace_qubits)
    psi = psi.reshape(keep_dim, trace_dim)
    return psi @ psi.conj().T

von_neumann_entropy

von_neumann_entropy(rho)

S(rho) = -Tr(rho log rho), computed from rho's eigenvalues. Nearly- zero eigenvalues (which a numerically pure/near-pure state produces, and which are mathematically forbidden from being exactly negative for a real density matrix but can land at a tiny negative float) are clipped before the log rather than raising or propagating a NaN.

Natural log (nats), NOT log2 (bits) -- unlike this package's other entropy-family quantities (magic_entropy, kl_divergence, sandwiched_renyi_divergence, stabilizer_renyi_entropy), which all use log2 and say so explicitly. mutual_information/central_charge below inherit this same nats convention.

Source code in dense_evolution/physics/entropy.py
def von_neumann_entropy(rho):
    """S(rho) = -Tr(rho log rho), computed from rho's eigenvalues. Nearly-
    zero eigenvalues (which a numerically pure/near-pure state produces,
    and which are mathematically forbidden from being exactly negative
    for a real density matrix but can land at a tiny negative float) are
    clipped before the log rather than raising or propagating a NaN.

    Natural log (nats), NOT log2 (bits) -- unlike this package's other
    entropy-family quantities (magic_entropy, kl_divergence,
    sandwiched_renyi_divergence, stabilizer_renyi_entropy), which all
    use log2 and say so explicitly. mutual_information/central_charge
    below inherit this same nats convention."""
    eigs = np.clip(np.linalg.eigvalsh(rho).real, 1e-14, None)
    return float(-np.sum(eigs * np.log(eigs)))

mutual_information

mutual_information(state, n_qubits, qubits_a, qubits_b)

I(A:B) = S(A) + S(B) - S(A union B), the standard quantum mutual information between two disjoint subsystems of a pure global state. In nats (natural log), same as von_neumann_entropy above -- see its docstring for how this differs from this package's other, log2-based entropy quantities.

Why this and not a single-qubit expectation value: a qubit entangled in a Bell pair (or more generally, maximally mixed on its own) has a marginal of exactly 0 regardless of what operation was applied to its partner -- this is the no-signaling theorem, not a measurement limitation, and no amount of clever circuit design around a single-qubit readout can get around it. Mutual information can reveal correlations a marginal expectation value structurally cannot, because it depends on the joint state of A and B, not either one alone. Verified in tests/unit/test_entropy.py against the exact textbook value for a Bell pair (I = 2*ln(2), maximal) and a GHZ state.

Source code in dense_evolution/physics/entropy.py
def mutual_information(state, n_qubits, qubits_a, qubits_b):
    """I(A:B) = S(A) + S(B) - S(A union B), the standard quantum mutual
    information between two disjoint subsystems of a pure global state.
    In nats (natural log), same as von_neumann_entropy above -- see its
    docstring for how this differs from this package's other,
    log2-based entropy quantities.

    Why this and not a single-qubit expectation value: a qubit entangled
    in a Bell pair (or more generally, maximally mixed on its own) has a
    marginal <Z> of exactly 0 regardless of what operation was applied to
    its partner -- this is the no-signaling theorem, not a measurement
    limitation, and no amount of clever circuit design around a
    single-qubit readout can get around it. Mutual information *can*
    reveal correlations a marginal expectation value structurally cannot,
    because it depends on the *joint* state of A and B, not either one
    alone. Verified in tests/unit/test_entropy.py against the exact textbook
    value for a Bell pair (I = 2*ln(2), maximal) and a GHZ state.
    """
    if not set(qubits_a).isdisjoint(qubits_b):
        raise ValueError(
            f"qubits_a and qubits_b must be disjoint, got qubits_a={qubits_a!r}, "
            f"qubits_b={qubits_b!r} -- an overlapping qubit would be traced "
            "into S(A), S(B) AND S(A union B) inconsistently, silently "
            "corrupting the result rather than raising."
        )
    s_a = von_neumann_entropy(partial_trace(state, n_qubits, qubits_a))
    s_b = von_neumann_entropy(partial_trace(state, n_qubits, qubits_b))
    s_ab = von_neumann_entropy(partial_trace(state, n_qubits, list(qubits_a) + list(qubits_b)))
    return s_a + s_b - s_ab

central_charge

central_charge(Ls, S, n_qubits)

Fit an open-chain entanglement entropy curve S(L) to the Calabrese- Cardy CFT prediction S(L) = (c/6)ln[(2N/pi)sin(pi*L/N)] + const (Calabrese & Cardy, J. Stat. Mech. 2004, P06002, eq. 4/19 combined via the standard open-chain doubling trick) and return (c, r_squared). The fit itself is in the natural-log (nats) convention shown above -- S must be too (von_neumann_entropy's own convention) for the fitted c to come out right; a log2-based S would scale it off by ln(2).

Backend-agnostic: S can come from any source (exact diagonalization via partial_trace/von_neumann_entropy on this package's own DenseSVSimulator, MPSSimulator, Chunk, or elsewhere) -- this doesn't compute the entropy itself, only fits an already-measured curve. Meant as a benchmark diagnostic: does a given backend/ truncation scheme preserve genuine critical CFT scaling, and with what effective central charge?

A high r_squared alone does NOT mean the extracted c is trustworthy -- Dense-Evolution-Discovery Experiment 36 found fitting at a finite-size pseudo-critical point (a susceptibility peak, not the true CFT point) gives a deceptively clean fit (r_squared=0.999997) to a wrong answer (c off by 2x). Only trust this near a genuine, independently-verified critical point.

Parameters:

Name Type Description Default
Ls array-like of int

Subsystem sizes, each counted from one physical boundary of an open chain of n_qubits sites (not a bulk interval -- see Discovery Experiment 36 for the periodic/bulk c/3 case instead).

required
S array-like of float

Entanglement entropy at each L in Ls, same length.

required
n_qubits int

Total open-chain length N.

required

Returns:

Name Type Description
c float

Extracted central charge (theory: 0.5 for Ising, 1.0 for a free boson/XX chain, ...).

r_squared float

Fit quality, in [0, 1] for a sane fit (can go negative for a pathological fit worse than the mean).

Source code in dense_evolution/physics/entropy.py
def central_charge(Ls, S, n_qubits):
    """Fit an open-chain entanglement entropy curve S(L) to the Calabrese-
    Cardy CFT prediction S(L) = (c/6)*ln[(2N/pi)*sin(pi*L/N)] + const
    (Calabrese & Cardy, J. Stat. Mech. 2004, P06002, eq. 4/19 combined via
    the standard open-chain doubling trick) and return (c, r_squared).
    The fit itself is in the natural-log (nats) convention shown above --
    `S` must be too (von_neumann_entropy's own convention) for the fitted
    `c` to come out right; a log2-based S would scale it off by ln(2).

    Backend-agnostic: `S` can come from any source (exact diagonalization
    via `partial_trace`/`von_neumann_entropy` on this package's own
    `DenseSVSimulator`, `MPSSimulator`, `Chunk`, or elsewhere) -- this
    doesn't compute the entropy itself, only fits an already-measured
    curve. Meant as a benchmark diagnostic: does a given backend/
    truncation scheme preserve genuine critical CFT scaling, and with
    what effective central charge?

    A high r_squared alone does NOT mean the extracted c is trustworthy --
    Dense-Evolution-Discovery Experiment 36 found fitting at a finite-size
    pseudo-critical point (a susceptibility peak, not the true CFT point)
    gives a deceptively clean fit (r_squared=0.999997) to a wrong answer
    (c off by 2x). Only trust this near a genuine, independently-verified
    critical point.

    Parameters
    ----------
    Ls : array-like of int
        Subsystem sizes, each counted from one physical boundary of an
        open chain of `n_qubits` sites (not a bulk interval -- see
        Discovery Experiment 36 for the periodic/bulk c/3 case instead).
    S : array-like of float
        Entanglement entropy at each L in `Ls`, same length.
    n_qubits : int
        Total open-chain length N.

    Returns
    -------
    c : float
        Extracted central charge (theory: 0.5 for Ising, 1.0 for a free
        boson/XX chain, ...).
    r_squared : float
        Fit quality, in [0, 1] for a sane fit (can go negative for a
        pathological fit worse than the mean).
    """
    Ls = np.asarray(Ls, dtype=float)
    S = np.asarray(S, dtype=float)
    x = np.log((2.0 * n_qubits / np.pi) * np.sin(np.pi * Ls / n_qubits))
    slope, intercept = np.polyfit(x, S, 1)
    c = 6.0 * slope
    pred = slope * x + intercept
    ss_res = float(np.sum((S - pred) ** 2))
    ss_tot = float(np.sum((S - S.mean()) ** 2))
    r_squared = 1.0 - ss_res / ss_tot if ss_tot > 0 else float("nan")
    return float(c), r_squared

See also: fermions and trotter, the other two modules promoted alongside this one from a real traversable-wormhole-inspired quantum teleportation reproduction (arXiv:2604.10090) -- see Dense-Evolution-Discovery for the real experiments, including a control run confirming mutual_information correctly returns exactly 0 when two subsystems are structurally disconnected.