File size: 8,218 Bytes
2a87ede | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 | """Sandwiched Quantum Renyi Divergence for full density-matrix diagnostics
(Muller-Lennert, Reeb, Wolf, Wilde, "On quantum Renyi entropies: a new
generalization and some applications", arXiv:1306.3142, Definition 1).
D_alpha(rho||sigma) = 1/(alpha-1) * log2 Tr[(sigma^e rho sigma^e)^alpha],
e = (1-alpha)/(2*alpha), with the alpha->1 limit reducing to the standard
quantum relative entropy and alpha=1/2 reducing to a fidelity-based form.
Originated from a Colab proposal with a real bug in its case_general
branch: `tr_inner = jnp.maximum(tr_inner, 1.0)` floors the inner trace at
1.0 even when the true value is < 1 (the normal case for non-commuting
rho, sigma), silently forcing every result to log2(1)=0 -- confirmed
directly in the Colab's own printed output (alpha=1.5 gave exactly
0.000000 across an entire rotation sweep). A second, deeper bug survived
the floor-value fix alone: for alpha > 1, a trace below 1 is not a
numerical artifact to clamp away, it is the genuine signature of a
support mismatch (supp(rho) not contained in supp(sigma)), which the
divergence must report as +inf, not a finite (and wrong-signed) number --
verified by hand on two different pure states: Tr[Q^1.5] = 0.6759,
matching the closed-form prediction (|<sigma|rho>|^2)^alpha exactly, and
plugging that into the naive formula gives a finite NEGATIVE divergence,
worse than the original bug's silent zero since it looks plausible
instead of visibly wrong.
Fixed and validated in Dense-Evolution-Discovery, Experiment 29
(https://tatopenn-cell.github.io/Dense-Evolution-Discovery/sandwiched_renyi_density_matrix/):
against the alpha->1 relative-entropy limit (matches an independent numpy
reference to 4 decimal places), the commuting/diagonal case (reduces
exactly to the classical Renyi divergence), and the support-violation
+inf case (verified at alpha>1, confirmed the branch does not fire
spuriously at alpha<1).
Its originally proposed use case -- replacing the JSD-based truncation
criterion in dense_evolution.mps's bond-dimension search -- was
independently disproven: on the diagonal singular-value spectrum used
there, rho and sigma commute, so a non-commuting-aware divergence induces
the exact same truncation ordering as JSD (5 benchmark configurations,
byte-identical chi_used and truncation error every time) -- nothing for
it to add in that setting. Promoted here instead for the genuinely
non-commuting full-density-matrix diagnostic use case it WAS validated
against: alongside uhlmann_fidelity, tracking a Bell state degraded by
amplitude damping, where the two metrics' noise-sensitivity curves
visibly diverge from each other.
"""
import jax
import jax.numpy as jnp
__all__ = ["sandwiched_renyi_divergence", "sandwiched_renyi_divergence_jit"]
_EPS = 1e-12
def _case_half(rho, sigma):
ev_r, ec_r = jnp.linalg.eigh(rho)
safe_ev_r = jnp.where(ev_r > _EPS, ev_r, 0.0)
sqrt_rho = (ec_r * jnp.sqrt(safe_ev_r)) @ jnp.conj(ec_r).T
uhlmann_mat = sqrt_rho @ sigma @ sqrt_rho
ev_u = jnp.linalg.eigvalsh(uhlmann_mat)
safe_ev_u = jnp.where(ev_u > _EPS, ev_u, 0.0)
fidelity = jnp.clip(jnp.sum(jnp.sqrt(safe_ev_u)), 0.0, 1.0)
return -2.0 * jnp.log2(jnp.maximum(fidelity, _EPS))
def _case_one(rho, sigma):
ev_r, ec_r = jnp.linalg.eigh(rho)
safe_ev_r = jnp.where(ev_r > _EPS, ev_r, 1.0)
log_rho = (ec_r * jnp.where(ev_r > _EPS, jnp.log2(safe_ev_r), 0.0)) @ jnp.conj(ec_r).T
ev_s, ec_s = jnp.linalg.eigh(sigma)
safe_ev_s = jnp.where(ev_s > _EPS, ev_s, 1.0)
log_sigma = (ec_s * jnp.where(ev_s > _EPS, jnp.log2(safe_ev_s), 0.0)) @ jnp.conj(ec_s).T
return jnp.trace(rho @ (log_rho - log_sigma)).real
def _case_general(rho, sigma, alpha):
exponent = (1.0 - alpha) / (2.0 * alpha)
ev_s, ec_s = jnp.linalg.eigh(sigma)
mask_s = ev_s > _EPS
safe_ev_s = jnp.where(mask_s, ev_s, 1.0)
pow_ev_s = jnp.where(mask_s, safe_ev_s ** exponent, 0.0)
sigma_pow = (ec_s * pow_ev_s) @ jnp.conj(ec_s).T
int_m = sigma_pow @ rho @ sigma_pow
v_int = jnp.linalg.eigvalsh(int_m)
mask_int = v_int > _EPS
safe_v_int = jnp.where(mask_int, v_int, 1.0)
pow_v_int = jnp.where(mask_int, safe_v_int ** alpha, 0.0)
tr_inner = jnp.sum(pow_v_int)
# For alpha > 1, D_alpha is finite only when supp(rho) subset supp(sigma);
# tr_inner < 1 there is the genuine signature of a support mismatch, not
# a numerical artifact -- see the module docstring's bug-history note.
is_support_violation = (alpha > 1.0) & (tr_inner < 1.0 - 1e-9)
tr_inner_safe = jnp.maximum(tr_inner, _EPS)
finite_result = ((1.0 / (alpha - 1.0)) * jnp.log2(tr_inner_safe)).real
return jnp.where(is_support_violation, jnp.inf, finite_result)
def _sandwiched_renyi_divergence_core(rho: jnp.ndarray, sigma: jnp.ndarray, alpha: float = 1.5) -> jnp.ndarray:
# alpha must be a jnp value, not a raw Python float, before it reaches
# _case_general: jax.lax.cond traces BOTH branches regardless of which
# one runs, and `1.0 / (alpha - 1.0)` at alpha=1.0 raises a Python
# ZeroDivisionError if alpha is still a plain float at that point (the
# case_one branch's own guard never gets a chance to skip it) -- under
# jax.jit this conversion happens automatically (all arguments get
# traced), which is why the original un-split Discovery version never
# hit this; the eager (non-jit) core path here needs it explicitly.
alpha = jnp.asarray(alpha, dtype=jnp.float64)
is_half = jnp.isclose(alpha, 0.5)
is_one = jnp.isclose(alpha, 1.0)
return jax.lax.cond(
is_half, lambda: _case_half(rho, sigma),
lambda: jax.lax.cond(is_one, lambda: _case_one(rho, sigma), lambda: _case_general(rho, sigma, alpha)),
)
def sandwiched_renyi_divergence(rho: jnp.ndarray, sigma: jnp.ndarray, alpha: float = 1.5) -> float:
"""Sandwiched quantum Renyi divergence D_alpha(rho||sigma), in bits
(log2). `rho`, `sigma` are density matrices of the same dimension;
`alpha` selects the order (0.5 -> fidelity-based, 1.0 -> standard
relative entropy, both handled as exact closed-form limits rather
than through the general formula's own alpha->0.5/1 numerical
instability).
Zero when rho == sigma at every alpha (verified). For alpha > 1,
returns `+inf` when supp(rho) is not contained in supp(sigma) --
e.g. two different pure (rank-1) states -- rather than a finite
number; see the module docstring for why this is the mathematically
correct behavior, not an edge-case failure.
KNOWN LIMITATION at exactly alpha=1.0: the same support-violation
check is NOT applied to the alpha=1 (relative-entropy) branch, which
instead clips log(0)-type contributions to 0 rather than diverging --
e.g. D_1(rho||sigma) for two different pure states returns 0.0, not
+inf, even though the true relative entropy diverges there too.
Experiment 29 validated the alpha=1 branch only against full-rank
(depolarized) inputs specifically to sidestep this exactly-singular
case (`scipy.linalg.logm` itself raises `LogmExactlySingularWarning`
on singular inputs -- an inherent ill-conditioning of relative
entropy near degenerate support, not unique to this implementation).
Do not rely on alpha=1 to correctly flag a support mismatch; use
alpha slightly above 1 (e.g. 1.001) if that matters for your use case.
Validation-only, like `uhlmann_fidelity`: meant to grade a correction
against a known reference state, not to feed into one (see
`uhlmann_fidelity`'s docstring for the full "ideal state as oracle"
argument, which applies here identically).
"""
rho = jnp.asarray(rho, dtype=jnp.complex128)
sigma = jnp.asarray(sigma, dtype=jnp.complex128)
return float(_sandwiched_renyi_divergence_core(rho, sigma, alpha))
sandwiched_renyi_divergence_jit = jax.jit(_sandwiched_renyi_divergence_core)
"""`jax.jit`-compiled entry point for `sandwiched_renyi_divergence`. `rho`/
`sigma` must already be `complex128`. Returns a jnp scalar, not a Python
`float`."""
|