Add mu-trajectory plot for the projection onto an ellipse

Cleaned-up extraction of test_mu_ellipse from proj_ellipsoid:
- quadric.py: minimal Ellipsoid class (standardization, KKT trajectory,
  extreme roots by exact bracketing, degenerate points, asymptotes).
- plot_mu_trajectories.py: the 4-panel figure, typeset with LaTeX.
- test_quadric.py: checks against brute-force sampling of the ellipse.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
loicvhandClaude Opus 5 committed 2026-09-15 20:50:05 +02:00
commit b2b782394a
6 files changed
+403

No files matched your search

+129
View File
@@ -0,0 +1,129 @@
"""Ellipsoid x'Ax + b'x + c = 0 and the KKT trajectory of the projection onto it.
The KKT conditions of min ||x - x0|| subject to x on the ellipsoid give
x(mu) = (I + mu A)^{-1} (x0 - mu b / 2),
and the projections are the points x(mu) with f(mu) = psi(x(mu)) = 0.
With d = -A^{-1} b / 2, gamma = c + b'd + d'Ad < 0 and A = V diag(eig) V', the standardized
coordinates u = V'(x - d) / sqrt(-gamma) turn the ellipsoid into sum_i eig_i u_i^2 = 1 and the
trajectory into u_i(mu) = u0_i / (1 + mu eig_i), which has a pole at mu = -1/eig_i when u0_i != 0.
"""
import numpy as np
from scipy.optimize import brentq
TOL = 1e-6
class Ellipsoid:
def __init__(self, A, b, c):
A = np.asarray(A, dtype=float)
b = np.asarray(b, dtype=float)
d = np.linalg.solve(A, -b / 2)
gamma = c + b @ d + d @ A @ d
if gamma > 0: # same set, written with gamma < 0
A, b, c, gamma = -A, -b, -c, -gamma
eig, V = np.linalg.eigh(A)
order = np.argsort(-eig)
self.eig = eig[order]
self.V = V[:, order]
if not np.all(self.eig > 0):
raise ValueError('the quadric is not a non-empty ellipsoid')
self.A, self.b, self.c, self.d = A, b, c, d
self.dim = len(b)
self.scale = np.sqrt(-gamma)
def psi(self, x):
return x @ self.A @ x + self.b @ x + self.c
def to_standardized(self, x):
return self.V.T @ (x - self.d) / self.scale
def to_non_standardized(self, u):
return self.d + self.scale * (self.V @ u)
def _solve(self, mu, y):
"""Return (I + mu A)^{-1} y."""
return self.V @ ((self.V.T @ y) / (1 + mu * self.eig))
def x(self, mu, x0):
return self._solve(mu, x0 - mu * self.b / 2)
def f(self, mu, x0):
return self.psi(self.x(mu, x0))
def df(self, mu, x0):
x = self.x(mu, x0)
dx = -self._solve(mu, self.A @ x + self.b / 2)
return (2 * self.A @ x + self.b) @ dx
def extreme_roots(self, x0):
"""Return (mu*, mu**), the roots of f right of the rightmost and left of the leftmost active pole.
x(mu*) and x(mu**) are the nearest and farthest non-degenerate KKT points; a degenerate
point (see degenerate_point) may be nearer than x(mu*). Return None if x0 is the centre.
"""
u0 = self.to_standardized(x0)
active = np.abs(u0) > TOL
if not active.any():
return None
eig, u0 = self.eig[active], u0[active]
def secular(mu):
return np.sum(eig * (u0 / (1 + mu * eig))**2) - 1
mu_star = brentq(secular, *_bracket(secular, -1 / eig.max(), +1))
mu_star_star = brentq(secular, *_bracket(secular, -1 / eig.min(), -1))
return mu_star, mu_star_star
def _pole_point(self, u0, k):
"""Standardized limit of u(mu) as mu -> -1/eig_k, with the eig_k components set to 0."""
on_pole = np.abs(self.eig - self.eig[k]) < TOL
u = np.zeros(self.dim)
np.divide(u0, 1 - self.eig / self.eig[k], out=u, where=~on_pole)
return u, on_pole
def pole_line(self, x0, k):
"""Return (point, direction) of the line {x : u_i = u0_i / (1 - eig_i/eig_k) for eig_i != eig_k}.
It is the asymptote of x(mu) at mu = -1/eig_k when u0_k != 0, and the line containing
the degenerate points otherwise.
"""
u, _ = self._pole_point(self.to_standardized(x0), k)
return self.to_non_standardized(u), self.V[:, k]
def degenerate_point(self, x0, k):
"""Return the KKT point at mu = -1/eig_k when u0 vanishes on that eigenspace, or None."""
u0 = self.to_standardized(x0)
u, on_pole = self._pole_point(u0, k)
if np.any(np.abs(u0[on_pole]) > TOL):
return None
r = (1 - self.eig @ u**2) / self.eig[k]
if r <= 0:
return None
u[np.argmax(on_pole)] = np.sqrt(r)
return self.to_non_standardized(u)
def boundary(self, n=1000):
"""Return n points of a 2D ellipse as an (n, 2) array."""
assert self.dim == 2, 'only 2D ellipses can be sampled'
t = np.linspace(-np.pi, np.pi, n)
u = np.column_stack((np.cos(t), np.sin(t))) / np.sqrt(self.eig)
return self.d + self.scale * u @ self.V.T
def _bracket(f, pole, direction):
"""Return an interval on the `direction` side of `pole` where f changes sign.
Assumes f -> +inf next to the pole and f -> -1 far from it.
"""
near = far = 1.0
while f(pole + direction * near) <= 0:
near /= 2
while f(pole + direction * far) >= 0:
far *= 2
return sorted((pole + direction * near, pole + direction * far))