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>
130 lines
4.6 KiB
Python
130 lines
4.6 KiB
Python
"""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))
|