Compare commits

..
3 Commits
Author SHA1 Message Date
loicvhandClaude Opus 5 8588bc9f30 Document how to create the virtual environment
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 22:00:04 +02:00
loicvhandClaude Opus 5 f193596743 Keep the legend lower left and make it smaller
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 21:56:23 +02:00
loicvhandClaude Opus 5 bdcf0e0b02 Plot the mu-trajectory for any non-empty 2D quadric
The poles, branches and mu range now follow the eigenvalues (one or two
poles of either sign), so hyperbolas, parabolas, line pairs, single
lines and points are supported, not only ellipses.

- Find every root of f from sign changes, including across inactive
  poles where quadproj's f is 0/0; mu* is the root between the active
  poles surrounding 0 and the other roots get their own marker.
- Mark the apex of conical quadrics as a degenerate KKT point.
- Draw the quadric as the contour psi = 0 and frame the trajectory
  panel on x0, the KKT points and the vertices.
- Use the correct asymptote direction for repeated eigenvalues.
- Test every quadric type against brute-force sampling.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 21:45:51 +02:00
3 changed files with 283 additions and 131 deletions

No files matched your search

+21 -11
View File
@@ -1,17 +1,27 @@
# mu-trajectories # mu-trajectories
Visualize the KKT trajectory of the Euclidean projection of a point `x0` onto an ellipse Visualize the KKT trajectory of the Euclidean projection of a point `x0` onto a 2D quadric
`x'Ax + b'x + c = 0`: `x'Ax + b'x + c = 0` (ellipse, hyperbola, parabola, two lines, a line or a point):
x(mu) = (I + mu A)^{-1} (x0 - mu b / 2) x(mu) = (I + mu A)^{-1} (x0 - mu b / 2)
The figure shows the trajectory in the plane, `f(mu) = psi(x(mu))`, the distance The figure shows the trajectory in the plane, the secular function `f(mu)`, the distance
`||x0 - x(mu)||` and `f'(mu)`, together with the nearest/farthest KKT points `x(mu*)`, `||x0 - x(mu)||` and `f'(mu)`, together with the KKT points: `x(mu*)`, the other roots of `f`
`x(mu**)` and, when `x0` has a zero standardized coordinate, the degenerate point `x^d`. and, when `x0` has a zero normalized coordinate, the degenerate points `x^d`.
## Setup
Create a virtual environment in `venv/` (ignored by git) and install the dependencies
(tested with Python 3.13):
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
Run `source venv/bin/activate` again in each new shell, and `deactivate` to leave the environment.
## Usage ## Usage
pip install -r requirements.txt
python plot_mu_trajectories.py # every case, into figures/ python plot_mu_trajectories.py # every case, into figures/
python plot_mu_trajectories.py x_trajectories_mu_2 # a single case python plot_mu_trajectories.py x_trajectories_mu_2 # a single case
pytest pytest
@@ -24,8 +34,8 @@ Text is typeset with LaTeX (`text.usetex`) when `latex` is on the `PATH`, with t
## Files ## Files
- `plot_mu_trajectories.py`: the figure. The quadric, `x(mu)`, `f`, `f'`, `mu*` and the degenerate - `plot_mu_trajectories.py`: the figure. The quadric, `x(mu)`, `f`, `f'` and the degenerate
points come from [quadproj](https://loicvh.gitlab.io/quadproj); the script only adds `mu**`, points come from [quadproj](https://loicvh.gitlab.io/quadproj); the script adds the roots of
the asymptotes and the ellipse boundary. `f`, the asymptotes and the plot limits.
- `test_plot_mu_trajectories.py`: checks these helpers against brute-force sampling of the ellipse - `test_plot_mu_trajectories.py`: checks, for each type of quadric, that the KKT points contain
and against `quadproj.project`. the projection found by brute-force sampling, and that every type can be plotted.
+176 -102
View File
@@ -1,9 +1,9 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Plot the KKT trajectory mu -> x(mu) of the projection of x0 onto an ellipse. """Plot the KKT trajectory mu -> x(mu) of the projection of x0 onto a 2D quadric.
Panels: the trajectory in the plane, the secular function f(mu), the distance ||x0 - x(mu)|| Panels: the trajectory in the plane, the secular function f(mu), the distance ||x0 - x(mu)||
and f'(mu). Colors encode mu, from mu -> -inf (blue) to mu -> +inf (red). The quadric, x(mu), and f'(mu). Colors encode mu, from mu -> -inf (blue) to mu -> +inf (red). The quadric, x(mu),
f, f' and mu* come from quadproj. f, f' and the degenerate points come from quadproj.
""" """
import argparse import argparse
@@ -18,65 +18,109 @@ from matplotlib.collections import LineCollection
from matplotlib.colors import Normalize from matplotlib.colors import Normalize
from matplotlib.lines import Line2D from matplotlib.lines import Line2D
from quadproj.fun import Fun from quadproj.fun import Fun
from quadproj.project import get_KKT_point_root, get_squared_radius_k, get_xd_k from quadproj.project import get_squared_radius_k, get_xd_k
from quadproj.quadrics import Quadric from quadproj.quadrics import Quadric
from scipy.optimize import brentq from scipy.optimize import brentq
# One figure per case, for the ellipse x'Ax + b'x + c = 0 (b = 0 and c = -1 by default). # One figure per case, for the quadric x'Ax + b'x + c = 0 (b = 0 and c = -1 by default).
CASES = { CASES = {
'x_trajectories_mu_1': dict(A=np.diag([2.0, 1.0]), x0=[0.0, -0.1]), 'x_trajectories_mu_1': dict(A=np.diag([2.0, 1.0]), x0=[0.0, -0.1]),
'x_trajectories_mu_2': dict(A=np.diag([2.0, 0.75]), x0=[0.0, -0.6]), 'x_trajectories_mu_2': dict(A=np.diag([2.0, 0.75]), x0=[0.0, -0.6]),
'x_trajectories_mu_parabola': dict(A=np.diag([1.0, 0.0]), b=[0.0, -1.0], c=0.0, x0=[0.3, 0.2]),
} }
TOL = 1e-6 # normalized components of x0 below TOL are considered zero TOL = 1e-6 # eigenvalues and normalized components of x0 below TOL are considered zero
N_PER_BRANCH = 266 # mu samples between consecutive poles N_PER_BRANCH = 266 # mu samples between consecutive poles
N_TAIL = 100 # extra samples extending the outer branches geometrically up to |mu| ~ 1e4 / lambda_min N_TAIL = 100 # extra samples extending the outer branches geometrically up to |mu| ~ 1e4 / min |lambda|
EPS = 0.01 # gap left on each side of a pole EPS = 0.01 # gap left on each side of a pole
MU_HALF_WIDTH = 3 # mu is sampled in [m - MU_HALF_WIDTH, m + MU_HALF_WIDTH], m the mean of the poles MU_MARGIN = 2.5 # the mu axis extends MU_MARGIN beyond the poles and 0
N_DOTTED_OUTER = 2 # points drawn dotted at the ends of the outer branches N_DOTTED_OUTER = 2 # points drawn dotted at the ends of the outer branches
Y_PADDING = 0.1 # fraction of the y-range of the features added below and above them Y_PADDING = 0.1 # fraction of the y-range of the features added below and above them
WINDOW_SCALE = 2 # the trajectory panel is WINDOW_SCALE times as wide as x0 and the KKT points
COLORMAP = 'RdYlBu_r' COLORMAP = 'RdYlBu_r'
ELLIPSE_COLOR = 'royalblue' QUADRIC_COLOR = 'royalblue'
STAR_STYLE = dict(color='purple', marker='v', s=12, zorder=2) STAR_STYLE = dict(color='purple', marker='v', s=12, zorder=2)
STAR_STAR_STYLE = dict(color='purple', marker='s', s=12, zorder=2) ROOT_STYLE = dict(color='purple', marker='s', s=12, zorder=2)
POLE_STYLE = dict(linestyle='dashed', color='grey') POLE_STYLE = dict(linestyle='dashed', color='grey')
TEXT_STYLE = dict(ha='center', va='center', fontsize=12, clip_on=True) TEXT_STYLE = dict(ha='center', va='center', fontsize=12, clip_on=True)
POLE_LABELS = [r'$\frac{-1}{\lambda_2}$', r'$\frac{-1}{\lambda_1}$']
DIST_D_LABEL = r'$||\mathbf{x}^0 - \mathbf{x}^\mathrm{d}||_2$' DIST_D_LABEL = r'$||\mathbf{x}^0 - \mathbf{x}^\mathrm{d}||_2$'
def kkt_roots(Q, x0): def f_poles(Q, x0):
"""Return (mu*, mu**), or None if x0 is the centre of the ellipse Q. """Return the poles of f as (mu, eigenvalue indices, active) tuples, sorted by mu.
mu* is quadproj's root of f right of the rightmost active pole and mu** the root left of the Equal eigenvalues share a pole, which is active when x0 has a normalized component along them.
leftmost active pole: x(mu*) and x(mu**) are the nearest and farthest non-degenerate KKT
points (a degenerate point may be nearer than x(mu*)).
""" """
x0_norm = Q.to_normalized(x0) x0_norm = Q.to_normalized(x0)
active = np.abs(x0_norm) > TOL groups = []
if not active.any(): for k in np.flatnonzero(np.abs(Q.eig) > TOL):
return None if groups and abs(Q.eig[k] - Q.eig[groups[-1][0]]) < TOL:
mu_star, _ = get_KKT_point_root(Q, x0) groups[-1].append(k)
else:
groups.append([k])
poles = [(-1 / Q.eig[ks[0]], ks, bool(np.any(np.abs(x0_norm[ks]) > TOL))) for ks in groups]
return sorted(poles, key=lambda pole: pole[0])
# f restricted to the active components, which avoids 0/0 at the poles of the other ones.
eig, u0 = Q.eig[active], x0_norm[active]
def f(mu): def sample_mu(Q, poles):
return np.sum(eig * (u0 / (1 + mu * eig))**2) - 1 """Return the mu samples of each segment between consecutive poles, and the range of the mu axis.
pole = -1 / eig.min() x(mu) only reaches its limit as |mu| -> inf, so the outer segments continue geometrically
near = far = 1.0 beyond the mu axis; these samples show up in the trajectory but not on the mu axes.
while f(pole - near) <= 0: """
near /= 2 pole_mus = [mu for mu, _, _ in poles]
while f(pole - far) >= 0: mu_min, mu_max = min(pole_mus + [0]) - MU_MARGIN, max(pole_mus + [0]) + MU_MARGIN
far *= 2 tail = np.geomspace(MU_MARGIN / N_PER_BRANCH, 1e4 / np.abs(Q.eig[np.abs(Q.eig) > TOL]).min(), N_TAIL)
return mu_star, brentq(f, pole - far, pole - near) starts = [mu_min] + [mu + EPS for mu in pole_mus]
stops = [mu - EPS for mu in pole_mus] + [mu_max]
segments = [np.linspace(start, stop, N_PER_BRANCH) for start, stop in zip(starts, stops)]
segments[0] = np.concatenate((mu_min - tail[::-1], segments[0]))
segments[-1] = np.concatenate((segments[-1], mu_max + tail))
return segments, (mu_min, mu_max)
def off_pole(fun):
"""Wrap a function of mu from quadproj, which is 0/0 exactly at an inactive pole, where it is continuous."""
def wrapped(mu):
with np.errstate(divide='ignore', invalid='ignore'):
value = fun(mu)
return fun(np.nextafter(mu, np.inf)) if np.any(np.isnan(value)) else value
return wrapped
def kkt_roots(Q, x0):
"""Return (mu*, the other roots of f), found from the sign changes of f between samples.
mu* is the root between the active poles surrounding 0, as in quadproj, or None.
"""
poles = f_poles(Q, x0)
segments, _ = sample_mu(Q, poles)
f = off_pole(Fun(Q, Q.to_normalized(x0)).f)
# f is continuous across inactive poles, so roots may lie in the gaps left around them.
branches = [segments[0]]
for samples, (_, _, is_active) in zip(segments[1:], poles):
if is_active:
branches.append(samples)
else:
branches[-1] = np.concatenate((branches[-1], samples))
roots = []
for samples in branches:
signs = np.sign([f(mu) for mu in samples])
roots += [brentq(f, samples[i], samples[i + 1]) for i in np.flatnonzero(signs[:-1] * signs[1:] < 0)]
active = [mu for mu, _, is_active in poles if is_active]
e1 = max((mu for mu in active if mu < 0), default=-np.inf)
e2 = min((mu for mu in active if mu > 0), default=np.inf)
mu_star = min((mu for mu in roots if e1 < mu < e2), key=abs, default=None)
return mu_star, [mu for mu in roots if mu != mu_star]
def degenerate_point(Q, x0, k): def degenerate_point(Q, x0, k):
"""Return quadproj's degenerate KKT point at mu = -1/eig_k, or None if it does not exist.""" """Return quadproj's degenerate KKT point at mu = -1/eig_k, or None if it does not exist."""
if abs(Q.eig[k]) < TOL:
return None
x0_norm = Q.to_normalized(x0) x0_norm = Q.to_normalized(x0)
L_k = [i for i in range(Q.dim) if abs(Q.eig[i] - Q.eig[k]) < TOL] L_k = [i for i in range(Q.dim) if abs(Q.eig[i] - Q.eig[k]) < TOL]
if np.any(np.abs(x0_norm[L_k]) > TOL) or get_squared_radius_k(Q, x0_norm, L_k, Q.eig[k]) <= 0: if np.any(np.abs(x0_norm[L_k]) > TOL) or get_squared_radius_k(Q, x0_norm, L_k, Q.eig[k]) <= 0:
@@ -85,23 +129,20 @@ def degenerate_point(Q, x0, k):
def pole_line(Q, x0, k): def pole_line(Q, x0, k):
"""Return (point, direction) of the line {u : u_i = u0_i / (1 - eig_i/eig_k) for eig_i != eig_k}. """Return (point, direction) of the line through lim x(mu) at mu = -1/eig_k.
Here u are normalized coordinates. The line is the asymptote of x(mu) at mu = -1/eig_k when The limit is taken with the components of the eig_k eigenspace set to 0. When x0 has a
u0_k != 0, and contains the degenerate points otherwise. component in that eigenspace, x(mu) diverges along it and the line is the asymptote;
otherwise the line is along eigenvector k and contains the degenerate points.
""" """
x0_norm = Q.to_normalized(x0) x0_norm = Q.to_normalized(x0)
on_pole = np.abs(Q.eig - Q.eig[k]) < TOL on_pole = np.abs(Q.eig - Q.eig[k]) < TOL
u = np.zeros(Q.dim) with np.errstate(divide='ignore', invalid='ignore'):
np.divide(x0_norm, 1 - Q.eig / Q.eig[k], out=u, where=~on_pole) u = Fun(Q, x0_norm).x_std(-1 / Q.eig[k])
return Q.to_non_normalized(u), Q.V[:, k] u[on_pole] = 0
direction = np.where(on_pole, x0_norm, 0) if np.any(np.abs(x0_norm[on_pole]) > TOL) else np.eye(Q.dim)[k]
origin = Q.to_non_normalized(np.zeros(Q.dim))
def ellipse_boundary(Q, n=1000): return Q.to_non_normalized(u), Q.to_non_normalized(direction) - origin
"""Return n points of the ellipse Q as an (n, 2) array."""
t = np.linspace(-np.pi, np.pi, n)
u = np.column_stack((np.cos(t), np.sin(t))) / np.sqrt(Q.eig)
return Q.d + np.sqrt(abs(Q.gamma)) * u @ Q.V.T
def auto_ylim(mu, values, branches, xlim, extra=(), floor=None): def auto_ylim(mu, values, branches, xlim, extra=(), floor=None):
@@ -125,6 +166,28 @@ def auto_ylim(mu, values, branches, xlim, extra=(), floor=None):
return low, high + padding return low, high + padding
def square_window(points):
"""Return (xlim, ylim) of a square centred on the points and WINDOW_SCALE times as wide (at least 2)."""
points = np.asarray(points)
low, high = points.min(axis=0), points.max(axis=0)
half = max(WINDOW_SCALE * (high - low).max() / 2, 1)
centre = (low + high) / 2
return (centre[0] - half, centre[0] + half), (centre[1] - half, centre[1] + half)
def draw_quadric(ax, Q, xlim, ylim):
if Q.is_single_plane: # psi >= 0 vanishes on a line, without sign change for the contour
k = np.flatnonzero(np.abs(Q.eig) <= TOL)[0]
ax.axline(Q.d, Q.d + Q.V[:, k], color=QUADRIC_COLOR, zorder=1)
elif Q.is_conical and Q.n == 0: # psi >= 0 vanishes at a single point
ax.scatter(*Q.d, color=QUADRIC_COLOR, zorder=1)
else:
X, Y = np.meshgrid(np.linspace(*xlim, 801), np.linspace(*ylim, 801))
P = np.stack((X, Y), axis=-1)
psi = np.einsum('...i,ij,...j->...', P, Q.A, P) + P @ Q.b + Q.c
ax.contour(X, Y, psi, levels=[0], colors=QUADRIC_COLOR, zorder=1)
def colored_line(ax, x, y, idx, cmap, norm, n_dotted=0): def colored_line(ax, x, y, idx, cmap, norm, n_dotted=0):
"""Draw y(x) colored by idx, with the first and last n_dotted points joined by dotted segments.""" """Draw y(x) colored by idx, with the first and last n_dotted points joined by dotted segments."""
points = np.column_stack((x, y)) points = np.column_stack((x, y))
@@ -143,109 +206,120 @@ def colored_line(ax, x, y, idx, cmap, norm, n_dotted=0):
def plot_trajectories(Q, x0): def plot_trajectories(Q, x0):
assert Q.is_ellipse, 'only 2D ellipses can be plotted' if Q.dim != 2 or Q.is_empty:
x0_norm = Q.to_normalized(x0) raise ValueError('only non-empty 2D quadrics can be plotted')
F = Fun(Q, x0_norm) F = Fun(Q, Q.to_normalized(x0))
active = np.abs(x0_norm) > TOL x, f, df = off_pole(F.x_not_std), off_pole(F.f), off_pole(F.d_f)
degenerate = not active.all() poles = f_poles(Q, x0)
pole_mus = np.array([mu for mu, _, _ in poles])
pole_active = np.array([is_active for _, _, is_active in poles], dtype=bool)
degenerate = not pole_active.all()
# Q.eig is positive and decreasing, so reversing it sorts the poles in increasing order. segments, xlim = sample_mu(Q, poles)
poles = -1 / Q.eig[::-1] mu = np.concatenate(segments)
pole_active = active[::-1]
mu_min = poles.mean() - MU_HALF_WIDTH
mu_max = poles.mean() + MU_HALF_WIDTH
# x(mu) only reaches the centre as |mu| -> inf, so the outer branches continue geometrically
# beyond [mu_min, mu_max]; these samples show up in the trajectory but not on the mu axes.
tail = np.geomspace(MU_HALF_WIDTH / N_PER_BRANCH, 1e4 / Q.eig.min(), N_TAIL)
branch_samples = [
np.concatenate((mu_min - tail[::-1], np.linspace(mu_min, poles[0] - EPS, N_PER_BRANCH))),
np.linspace(poles[0] + EPS, poles[1] - EPS, N_PER_BRANCH),
np.concatenate((np.linspace(poles[1] + EPS, mu_max, N_PER_BRANCH), mu_max + tail)),
]
mu = np.concatenate(branch_samples)
idx = np.arange(len(mu)) idx = np.arange(len(mu))
pole_indices = np.cumsum([len(samples) for samples in branch_samples[:-1]]) pole_indices = np.cumsum([len(samples) for samples in segments[:-1]], dtype=int)
# A pole where x0 has a zero normalized component vanishes and joins its neighbouring branches. # A pole where x0 has a zero normalized component vanishes and joins its neighbouring branches.
branches = np.split(idx, pole_indices[pole_active]) branches = np.split(idx, pole_indices[pole_active])
cmap = colormaps[COLORMAP] cmap = colormaps[COLORMAP]
norm = Normalize(0, len(mu) - 1) norm = Normalize(0, len(mu) - 1)
xlim = (mu_min, mu_max) pole_labels = [rf'$\frac{{-1}}{{\lambda_{ks[0] + 1}}}$' for _, ks, _ in poles]
ticks = [mu_min, poles[0], poles[1], 0, mu_max] ticks = [xlim[0], *pole_mus, 0, xlim[1]]
tick_labels = [r'$\mu \to -\infty$', *POLE_LABELS, r'$0$', r'$\mu \to \infty$'] tick_labels = [r'$\mu \to -\infty$', *pole_labels, r'$0$', r'$\mu \to \infty$']
roots = kkt_roots(Q, x0) mu_star, other_roots = kkt_roots(Q, x0)
markers = [] if roots is None else list(zip(roots, (STAR_STYLE, STAR_STAR_STYLE))) markers = ([] if mu_star is None else [(mu_star, STAR_STYLE)]) + [(root, ROOT_STYLE) for root in other_roots]
# (point, direction, degenerate point or None) for each pole cancelled by a zero component of x0
inactive = [(*pole_line(Q, x0, ks[0]), degenerate_point(Q, x0, ks[0]))
for _, ks, is_active in poles if not is_active]
xds = [xd for _, _, xd in inactive if xd is not None]
if Q.is_conical and not Q.is_single_plane: # the apex is a KKT point
xds.append(Q.d)
xd_dists = [np.linalg.norm(xd - x0) for xd in xds]
fig, axs = plt.subplots(2, 2, figsize=(10, 7)) fig, axs = plt.subplots(2, 2, figsize=(10, 7))
ax_x, ax_f, ax_dist, ax_df = axs.flat ax_x, ax_f, ax_dist, ax_df = axs.flat
# (point, direction, degenerate point or None) for each pole cancelled by a zero component of x0 # (axis, function, label, dotted points of the middle branches, extra values to show, lower bound)
inactive = [(*pole_line(Q, x0, k), degenerate_point(Q, x0, k)) for k in np.flatnonzero(~active)]
xd_dists = [np.linalg.norm(xd - x0) for _, _, xd in inactive if xd is not None]
# (axis, function, label, dotted points of the middle branch, extra values to show, lower bound)
panels = [ panels = [
(ax_f, F.f, r'$f(\mu)$', 5, [0], None), (ax_f, f, r'$f(\mu)$', 5, [0], None),
(ax_dist, lambda m: F.dist(F.x_not_std(m)), r'$||\mathbf{x}^0 - \mathbf{x}(\mu)||_2$', 6, (ax_dist, lambda m: F.dist(x(m)), r'$||\mathbf{x}^0 - \mathbf{x}(\mu)||_2$', 6,
[dist + 0.3 for dist in xd_dists], 0), # green line and its label above it [dist + 0.3 for dist in xd_dists], 0), # green line and its label above it
(ax_df, F.d_f, r'$f^\prime(\mu)$', 50, [], None), (ax_df, df, r'$f^\prime(\mu)$', 50, [], None),
] ]
for ax, fun, ylabel, n_dotted_middle, extra, floor in panels: for ax, fun, ylabel, n_dotted_middle, extra, floor in panels:
values = np.array([fun(m) for m in mu]) values = np.array([fun(m) for m in mu])
for i, branch in enumerate(branches): for i, branch in enumerate(branches):
n_dotted = 0 if degenerate else (n_dotted_middle if i == 1 else N_DOTTED_OUTER) if degenerate:
n_dotted = 0
else:
n_dotted = n_dotted_middle if 0 < i < len(branches) - 1 else N_DOTTED_OUTER
colored_line(ax, mu[branch], values[branch], branch, cmap, norm, n_dotted) colored_line(ax, mu[branch], values[branch], branch, cmap, norm, n_dotted)
for pole in poles[pole_active]: for pole in pole_mus[pole_active]:
ax.axvline(pole, **POLE_STYLE) ax.axvline(pole, **POLE_STYLE)
for root, style in markers: for root, style in markers:
ax.scatter(root, fun(root), **style) ax.scatter(root, fun(root), **style)
root_values = [fun(root) for root, _ in markers] marker_values = [fun(root) for root, _ in markers if xlim[0] <= root <= xlim[1]]
ylim = auto_ylim(mu, values, branches, xlim, extra + root_values, floor) ylim = auto_ylim(mu, values, branches, xlim, extra + marker_values + [fun(0)], floor) # x(0) = x0
ax.set(xlabel=r'$\mu$', ylabel=ylabel, xlim=xlim, ylim=ylim) ax.set(xlabel=r'$\mu$', ylabel=ylabel, xlim=xlim, ylim=ylim)
ax.set_xticks(ticks, tick_labels) ax.set_xticks(ticks, tick_labels)
ax_f.axhline(0, color='red', alpha=0.5, zorder=1) ax_f.axhline(0, color='red', alpha=0.5, zorder=1)
for ax in (ax_f, ax_df): for ax in (ax_f, ax_df):
ax.yaxis.set_ticks_position('right') ax.yaxis.set_ticks_position('right')
xs = np.array([F.x_not_std(m) for m in mu]) xs = np.array([x(m) for m in mu])
ax_x.plot(*ellipse_boundary(Q).T, color=ELLIPSE_COLOR, zorder=1) kkt_points = [x(root) for root, _ in markers] + xds
ax_x.scatter(*Q.d, color=ELLIPSE_COLOR) has_centre = not (Q.is_parabolic or Q.is_cylindrical) # otherwise the centre is not unique
window_points = [x0, *kkt_points]
if has_centre:
window_points.append(Q.center)
if Q.is_central: # the vertices, so that the whole shape (e.g. both arcs of a hyperbola) is shown
window_points += [Q.to_non_normalized(sign * np.eye(Q.dim)[k] / np.sqrt(Q.eig[k]))
for k in np.flatnonzero(Q.eig > TOL) for sign in (-1, 1)]
window_xlim, window_ylim = square_window(window_points)
draw_quadric(ax_x, Q, window_xlim, window_ylim)
if has_centre:
ax_x.scatter(*Q.center, color=QUADRIC_COLOR)
ax_x.scatter(xs[:, 0], xs[:, 1], c=idx, cmap=cmap, norm=norm, s=4) ax_x.scatter(xs[:, 0], xs[:, 1], c=idx, cmap=cmap, norm=norm, s=4)
ax_x.scatter(*x0, color='red', marker='x') ax_x.scatter(*x0, color='red', marker='x')
for root, style in markers: for (_, style), point in zip(markers, kkt_points):
ax_x.scatter(*F.x_not_std(root), **style) ax_x.scatter(*point, **style)
if not degenerate: if not degenerate:
for pole_index, label in zip(pole_indices, POLE_LABELS): for pole_index, label in zip(pole_indices, pole_labels):
for i in (pole_index - 1, pole_index): for i in (pole_index - 1, pole_index):
ax_x.text(*xs[i], label, **TEXT_STYLE) ax_x.text(*xs[i], label, **TEXT_STYLE)
for k in range(Q.dim): for _, ks, _ in poles:
point, direction = pole_line(Q, x0, k) point, direction = pole_line(Q, x0, ks[0])
ax_x.axline(point, point + direction, linestyle='dotted', color='grey') ax_x.axline(point, point + direction, linestyle='dotted', color='grey')
for point, direction, xd in inactive: for point, direction, xd in inactive:
if xd is None: if xd is None:
ax_x.plot(*np.column_stack((point - direction, point + direction)), color='grey', zorder=0) ax_x.plot(*np.column_stack((point - direction, point + direction)), color='grey', zorder=0)
continue else:
ax_x.plot(*np.column_stack((point, xd)), color='grey', zorder=0) ax_x.plot(*np.column_stack((point, xd)), color='grey', zorder=0)
for xd, dist in zip(xds, xd_dists):
ax_x.scatter(*xd, color='green', zorder=2) ax_x.scatter(*xd, color='green', zorder=2)
dist = np.linalg.norm(xd - x0)
ax_dist.axhline(dist + 0.1, color='green') ax_dist.axhline(dist + 0.1, color='green')
ax_dist.text(mu_max + (poles[1] - mu_max) * 2 / 3, dist + 0.2, DIST_D_LABEL, color='green') ax_dist.text(xlim[1] + (pole_mus[-1] - xlim[1]) * 2 / 3, dist + 0.2, DIST_D_LABEL, color='green')
legend = [ legend = [Line2D([], [], color=QUADRIC_COLOR, lw=2, label=r'$\mathcal{Q}(\Psi)$')]
Line2D([], [], color=ELLIPSE_COLOR, lw=2, label=r'$\mathcal{Q}(\Psi)$'), if mu_star is not None:
Line2D([], [], linestyle='none', marker='v', color='purple', markersize=6, label=r'$\mathbf{x}(\mu^*)$'), legend.append(Line2D([], [], linestyle='none', marker='v', color='purple', markersize=6,
Line2D([], [], linestyle='none', marker='s', color='purple', markersize=6, label=r'$\mathbf{x}(\mu^{**})$'), label=r'$\mathbf{x}(\mu^*)$'))
Line2D([], [], linestyle='none', marker='x', color='red', markersize=8, label=r'$\mathbf{x}^0 = \mathbf{x}(0)$'), if other_roots:
] legend.append(Line2D([], [], linestyle='none', marker='s', color='purple', markersize=6,
if degenerate: label=r'$\mathbf{x}(\mu)$, $f(\mu) = 0$'))
legend.append(Line2D([], [], linestyle='none', marker='x', color='red', markersize=8,
label=r'$\mathbf{x}^0 = \mathbf{x}(0)$'))
if xds:
legend.append(Line2D([], [], linestyle='none', marker='o', color='green', markersize=8, legend.append(Line2D([], [], linestyle='none', marker='o', color='green', markersize=8,
label=r'$\mathbf{x}^\mathrm{d}$')) label=r'$\mathbf{x}^\mathrm{d}$'))
ax_x.legend(handles=legend, loc='lower left', fontsize=9) ax_x.legend(handles=legend, loc='lower left', fontsize=8, markerscale=0.8, handlelength=1.5, labelspacing=0.3)
ax_x.set(xlabel=r'$x_1$', ylabel=r'$x_2$', xlim=(-2, 2), ylim=(-2, 2), aspect='equal') ax_x.set(xlabel=r'$x_1$', ylabel=r'$x_2$', xlim=window_xlim, ylim=window_ylim, aspect='equal')
fig.tight_layout(pad=2.1) fig.tight_layout(pad=2.1)
colorbar = fig.colorbar(ScalarMappable(norm, cmap), ax=ax_x, ticks=[0, len(mu) - 1]) colorbar = fig.colorbar(ScalarMappable(norm, cmap), ax=ax_x, ticks=[0, len(mu) - 1])
+86 -18
View File
@@ -1,44 +1,100 @@
import contourpy
import matplotlib
import matplotlib.pyplot as plt
import numpy as np import numpy as np
import pytest import pytest
from quadproj.fun import Fun from quadproj.fun import Fun
from quadproj.project import project from quadproj.project import project
from quadproj.quadrics import Quadric from quadproj.quadrics import Quadric
from plot_mu_trajectories import degenerate_point, ellipse_boundary, kkt_roots, pole_line from plot_mu_trajectories import degenerate_point, kkt_roots, off_pole, plot_trajectories, pole_line
ROTATED_ELLIPSE = Quadric(np.array([[3.0, 1.0], [1.0, 2.0]]), np.array([1.0, -1.0]), -4.0) matplotlib.use('Agg')
GRID = np.linspace(-4, 4, 4001)
# (A, b, c, x0): generic points, and points with a zero normalized component (degenerate)
QUADRICS = {
'ellipse': ([[3, 1], [1, 2]], [1, -1], -4, [0.3, 0.2]),
'ellipse_outside': ([[3, 1], [1, 2]], [1, -1], -4, [2.0, -1.5]),
'ellipse_degenerate': ([[2, 0], [0, 1]], [0, 0], -1, [0.0, -0.1]),
'ellipse_degenerate_2': ([[2, 0], [0, 1]], [0, 0], -1, [0.0, 0.5]),
'hyperbola': ([[1, 2], [2, -1]], [1, 0], -1, [0.3, 0.2]),
'hyperbola_degenerate': ([[1, 0], [0, -1]], [0, 0], -1, [0.0, 0.5]),
'parabola': ([[1, 1], [1, 1]], [1, -1], 0, [0.3, 0.2]),
'parabola_degenerate': ([[1, 0], [0, 0]], [0, -1], 0, [0.0, 0.5]),
'crossing_lines': ([[1, 0], [0, -1]], [0, 0], 0, [0.3, 0.2]),
'parallel_lines': ([[1, 0], [0, 0]], [0, 0], -1, [0.3, 0.2]),
}
# Quadrics without finite roots of f: the projection is the limit of x(mu) as |mu| -> inf.
LIMIT_QUADRICS = {
'single_line': ([[1, 0], [0, 0]], [0, 0], 0, [0.3, 0.2]),
'point': ([[1, 0], [0, 1]], [0, 0], 0, [0.3, 0.2]),
}
def make_quadric(A, b, c):
return Quadric(np.array(A, dtype=float), np.array(b, dtype=float), float(c))
ROTATED_ELLIPSE = make_quadric(*QUADRICS['ellipse'][:3])
def trajectory(Q, x0): def trajectory(Q, x0):
return Fun(Q, Q.to_normalized(x0)).x_not_std return off_pole(Fun(Q, Q.to_normalized(x0)).x_not_std)
def boundary_distances(Q, x0): def ellipse_boundary(Q, n=1000):
return np.linalg.norm(ellipse_boundary(Q, 200_000) - x0, axis=1) """Return n points of the ellipse Q as an (n, 2) array."""
t = np.linspace(-np.pi, np.pi, n)
u = np.column_stack((np.cos(t), np.sin(t))) / np.sqrt(Q.eig)
return Q.d + np.sqrt(abs(Q.gamma)) * u @ Q.V.T
def distances_to_quadric(Q, x0):
"""Distances from x0 to the points of the contour psi = 0 on a fine grid."""
X, Y = np.meshgrid(GRID, GRID)
P = np.stack((X, Y), axis=-1)
psi = np.einsum('...i,ij,...j->...', P, Q.A, P) + P @ Q.b + Q.c
return np.linalg.norm(np.vstack(contourpy.contour_generator(X, Y, psi).lines(0)) - x0, axis=1)
@pytest.mark.parametrize('name', QUADRICS)
def test_kkt_points_contain_the_projection(name):
*quadric, x0 = QUADRICS[name]
Q, x0 = make_quadric(*quadric), np.array(x0)
mu_star, other_roots = kkt_roots(Q, x0)
x = trajectory(Q, x0)
points = [x(mu) for mu in other_roots + ([] if mu_star is None else [mu_star])]
points += [xd for k in range(Q.dim) if (xd := degenerate_point(Q, x0, k)) is not None]
if Q.is_conical:
points.append(Q.d)
assert all(abs(Q.evaluate_point(point)) < 1e-8 for point in points)
nearest = min(np.linalg.norm(point - x0) for point in points)
assert nearest == pytest.approx(distances_to_quadric(Q, x0).min(), abs=1e-4)
@pytest.mark.parametrize('x0', [[0.3, 0.2], [2.0, -1.5], [-0.1, 0.4]])
def test_ellipse_roots_give_nearest_and_farthest_points(x0):
Q, x0 = ROTATED_ELLIPSE, np.array(x0)
mu_star, other_roots = kkt_roots(Q, x0)
x = trajectory(Q, x0)
distances = np.linalg.norm(ellipse_boundary(Q, 200_000) - x0, axis=1)
assert np.linalg.norm(x(mu_star) - x0) == pytest.approx(distances.min(), abs=1e-6)
assert max(np.linalg.norm(x(mu) - x0) for mu in other_roots) == pytest.approx(distances.max(), abs=1e-6)
def test_boundary_is_on_the_ellipse(): def test_boundary_is_on_the_ellipse():
assert np.all([abs(ROTATED_ELLIPSE.evaluate_point(x)) < 1e-10 for x in ellipse_boundary(ROTATED_ELLIPSE)]) assert np.all([abs(ROTATED_ELLIPSE.evaluate_point(x)) < 1e-10 for x in ellipse_boundary(ROTATED_ELLIPSE)])
@pytest.mark.parametrize('x0', [[0.3, 0.2], [2.0, -1.5], [-0.1, 0.4]])
def test_kkt_roots_give_nearest_and_farthest_points(x0):
Q, x0 = ROTATED_ELLIPSE, np.array(x0)
mu_star, mu_star_star = kkt_roots(Q, x0)
x = trajectory(Q, x0)
distances = boundary_distances(Q, x0)
assert abs(Q.evaluate_point(x(mu_star_star))) < 1e-8
assert np.linalg.norm(x(mu_star) - x0) == pytest.approx(distances.min(), abs=1e-6)
assert np.linalg.norm(x(mu_star_star) - x0) == pytest.approx(distances.max(), abs=1e-6)
@pytest.mark.parametrize('eig, x0', [([2.0, 1.0], [0.0, -0.1]), ([2.0, 0.75], [0.0, -0.6])]) @pytest.mark.parametrize('eig, x0', [([2.0, 1.0], [0.0, -0.1]), ([2.0, 0.75], [0.0, -0.6])])
def test_degenerate_point_is_the_projection(eig, x0): def test_degenerate_point_is_the_projection(eig, x0):
Q, x0 = Quadric(np.diag(eig), np.zeros(2), -1.0), np.array(x0) Q, x0 = Quadric(np.diag(eig), np.zeros(2), -1.0), np.array(x0)
xd = degenerate_point(Q, x0, 0) xd = degenerate_point(Q, x0, 0)
np.testing.assert_allclose(xd, project(Q, x0)) np.testing.assert_allclose(xd, project(Q, x0))
assert np.linalg.norm(xd - x0) == pytest.approx(boundary_distances(Q, x0).min(), abs=1e-6)
assert degenerate_point(Q, x0, 1) is None assert degenerate_point(Q, x0, 1) is None
@@ -46,5 +102,17 @@ def test_pole_line_is_the_asymptote():
Q, x0 = ROTATED_ELLIPSE, np.array([0.3, 0.2]) Q, x0 = ROTATED_ELLIPSE, np.array([0.3, 0.2])
point, direction = pole_line(Q, x0, 1) point, direction = pole_line(Q, x0, 1)
x = trajectory(Q, x0)(-1 / Q.eig[1] + 1e-9) x = trajectory(Q, x0)(-1 / Q.eig[1] + 1e-9)
direction = direction / np.linalg.norm(direction)
residual = (x - point) - ((x - point) @ direction) * direction residual = (x - point) - ((x - point) @ direction) * direction
np.testing.assert_allclose(residual, 0, atol=1e-6) np.testing.assert_allclose(residual, 0, atol=1e-6)
@pytest.mark.parametrize('name', [*QUADRICS, *LIMIT_QUADRICS])
def test_every_quadric_type_can_be_plotted(name):
*quadric, x0 = {**QUADRICS, **LIMIT_QUADRICS}[name]
plt.close(plot_trajectories(make_quadric(*quadric), np.array(x0, dtype=float)))
def test_empty_quadric_is_rejected():
with pytest.raises(ValueError):
plot_trajectories(make_quadric([[1, 0], [0, 1]], [0, 0], 1), np.zeros(2))