Skip to content

Commit 956240b

Browse files
thibaut-germainosheasiennarflamary
authored
Fixing issue in SGOT example and SGOT cost function. (#830)
* correction SGOT cost matrix, added to contributor list, move sgot example to other * updated graphs * fix plots * update example * fix releases.md --------- Co-authored-by: Sienna O'Shea <osheasienna@gmail.com> Co-authored-by: Rémi Flamary <remi.flamary@gmail.com>
1 parent f1e0c5c commit 956240b

5 files changed

Lines changed: 142 additions & 121 deletions

File tree

CITATION.cff

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,10 +81,17 @@ authors:
8181
- given-names: David
8282
family-names: Coeurjolly
8383
affiliation: CNRS, LIRIS
84+
- given-names: Thibaut
85+
family-names: Germain
86+
affiliation: Ecole Polytechnique
87+
- given-names: Sienna
88+
family-names: O'Shea
89+
affiliation: Ecole Polytechnique
8490
- given-names: Marco
8591
family-names: Corneli
8692
affiliation: Université Côte d'Azur
87-
- given-names: Ferdinand Genans
93+
- given-names: Ferdinand
94+
family-names: Genans
8895
affiliation: Sorbonne Université, LPSM, CNRS
8996
identifiers:
9097
- type: url

CONTRIBUTORS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ The contributors to this library are:
5959
* [Julie Delon](https://judelo.github.io/) (GMM OT)
6060
* [Samuel Boïté](https://samuelbx.github.io/) (GMM OT)
6161
* [Nathan Neike](https://github.com/nathanneike) (Sparse EMD solver)
62+
* [Thibaut Germain](https://thibaut-germain.github.io) (SGOT)
63+
* Sienna O'Shea (SGOT)
6264

6365

6466
## Acknowledgments

RELEASES.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ This new release adds support for sparse cost matrices and a new lazy exact OT s
3838
- Update the geomloss wrapper to the new version and API (PR #826)
3939
- Fix docstrings for `lowrank_gromov_wasserstein_samples` and `lowrank_sinkhorn` (PR #823)
4040
- Reorganize all tests per backend (PR #828)
41+
- Update sgot cost function and example (PR #830)
4142

4243

4344
#### Closed issues
Lines changed: 130 additions & 119 deletions
Original file line numberDiff line numberDiff line change
@@ -66,33 +66,35 @@
6666
theta_0 = np.pi / 4
6767

6868

69+
def rotation_matrix(theta):
70+
return np.array([[np.cos(theta), -np.sin(theta)], [np.sin(theta), np.cos(theta)]])
71+
72+
6973
def generate_data(time, tau, freq, theta):
7074
t_ = np.sin(2 * np.pi * freq[None, :] * time[:, None]) * np.exp(
7175
-tau[None, :] * time[:, None]
7276
)
7377
t_ = t_.sum(axis=1)
7478
traj_0 = np.zeros((t_.shape[0], 2))
7579
traj_0[:, 0] = t_
76-
rotation_matrix = np.array(
77-
[[np.cos(theta), -np.sin(theta)], [np.sin(theta), np.cos(theta)]]
78-
)
79-
traj_0 = traj_0 @ rotation_matrix.T
80+
R_ = rotation_matrix(theta)
81+
traj_0 = traj_0 @ R_.T
8082
return traj_0
8183

8284

8385
traj_0 = generate_data(time, tau_0, freq_0, theta_0)
86+
traj_0_proj = traj_0 @ rotation_matrix(theta_0)[:, 0]
8487

8588

8689
# plot the observed signal components and their sum
8790
plt.figure(figsize=(10, 4))
88-
plt.plot(time, traj_0, label="base trajectory", linewidth=2)
91+
plt.plot(time, traj_0_proj, label="projected trajectory", linewidth=2)
8992
plt.xlabel("time")
9093
plt.ylabel("amplitude")
9194
plt.legend()
9295
plt.title(r"Observed scalar signal along $\vec{e}(\theta)$")
9396
plt.show()
9497

95-
9698
# %%
9799
# 2. Interpret the signal as coming from a continuous linear dynamical system
98100
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
@@ -274,12 +276,8 @@ def augment(traj, window_length=2):
274276
# Processing Systems, 35, pp.4017-4031.
275277

276278

277-
def estimator(X, Y, rank=4):
278-
# X: (n_samples, n_features)
279-
# Y: (n_samples, n_features)
280-
281-
# estimate operator
282-
cxx = X.T @ X
279+
def estimator(X, Y, rank=4, eps=1e-8):
280+
cxx = X.T @ X + eps * np.eye(X.shape[1])
283281
U, S, Vt = np.linalg.svd(cxx)
284282
S_inv = np.divide(1, S, out=np.zeros_like(S), where=S != 0)
285283
cxx_inv_half = Vt.T @ np.diag(np.sqrt(S_inv)) @ U.T
@@ -416,6 +414,24 @@ def estimator(X, Y, rank=4):
416414
# spectral atoms, taking into account both the location of eigenvalues and the
417415
# relative geometry of their eigenspaces.
418416

417+
# %%
418+
# A wider delay window for the SGOT experiments below
419+
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
420+
#
421+
# The window of length 4 used above is enough to identify a single reference
422+
# operator, but the experiments below also probe signals whose two modes
423+
# nearly coincide in frequency (e.g. :math:`\omega_2'\to\omega_1`). Telling
424+
# such near-degenerate modes apart requires the delay embedding to span
425+
# enough time to "see" their differing decay, so we re-embed the reference
426+
# signal with a longer window before running the sweeps.
427+
428+
sgot_window = 10
429+
Z = augment(traj_0, sgot_window)
430+
_, B_0_spec_sgot = estimator(Z[:-1], Z[1:])
431+
D_0_sgot = np.log(B_0_spec_sgot["eig_val"]) * fs
432+
L_0_sgot = B_0_spec_sgot["eig_vec_left"]
433+
R_0_sgot = B_0_spec_sgot["eig_vec_right"]
434+
419435
# %%
420436
# SGOT distance versus rotation angle
421437
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
@@ -434,52 +450,66 @@ def estimator(X, Y, rank=4):
434450
# this experiment isolates the effect of rotating the underlying one-dimensional
435451
# subspace in the observation plane.
436452

437-
thetas = np.linspace(0, np.pi / 2, 50)
438-
lst = []
439-
for i, theta in enumerate(thetas):
440-
traj = generate_data(time, tau_0, freq_0, theta)
441-
Z = augment(traj, 4)
442-
X = Z[:-1]
443-
Y = Z[1:]
444-
B, B_spec = estimator(X, Y, rank=4)
445-
D, R, L = B_spec["eig_val"], B_spec["eig_vec_right"], B_spec["eig_vec_left"]
446-
D = np.log(D) * fs
447-
lst.append(sgot_metric(D_0, R_0, L_0, D, R, L, eta=0.01))
448-
449-
plt.figure(figsize=(8, 5))
450-
plt.plot(thetas, lst)
451-
plt.xlabel("theta")
452-
plt.ylabel("SGOT distance")
453-
plt.title("SGOT distance vs rotation angle")
453+
thetas = np.linspace(0, np.pi / 2, 51)
454+
rotation_scores = []
455+
456+
for theta in thetas:
457+
Z = augment(generate_data(time, tau_0, freq_0, theta), sgot_window)
458+
B, B_spec = estimator(Z[:-1], Z[1:])
459+
D = np.log(B_spec["eig_val"]) * fs
460+
L = B_spec["eig_vec_left"]
461+
R = B_spec["eig_vec_right"]
462+
rotation_scores.append(
463+
sgot_metric(
464+
D_0_sgot, R_0_sgot, L_0_sgot, D, R, L, eta=0.9, grassmann_metric="chordal"
465+
)
466+
)
467+
468+
fig, ax = plt.subplots(figsize=(7, 4))
469+
ax.plot(thetas, rotation_scores, linewidth=1.8)
470+
ax.axvline(theta_0, color="gray", linestyle="--", linewidth=0.8)
471+
ax.set_xlabel(r"Rotation angle $\theta$ (rad)")
472+
ax.set_ylabel(r"$d_S$")
473+
ax.set_title("SGOT distance vs. rotation angle")
474+
fig.tight_layout()
454475
plt.show()
455476

456477
# %%
457478
# Comparison across Grassmannian metrics for SGOT distance versus rotation angle
458479
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
459480

460-
thetas = np.linspace(0, np.pi / 2, 50)
461-
lst = []
462-
for i, theta in enumerate(thetas):
463-
traj = generate_data(time, tau_0, freq_0, theta)
464-
Z = augment(traj, 4)
465-
X = Z[:-1]
466-
Y = Z[1:]
467-
B, B_spec = estimator(X, Y, rank=4)
468-
D, R, L = B_spec["eig_val"], B_spec["eig_vec_right"], B_spec["eig_vec_left"]
469-
D = np.log(D) * fs
470-
lst1 = []
471-
for name in ["chordal", "martin", "geodesic", "procrustes"]:
472-
lst1.append(sgot_metric(D_0, R_0, L_0, D, R, L, eta=0.9, grassmann_metric=name))
473-
lst.append(lst1)
474-
lst2 = np.array(lst)
475-
plt.figure(figsize=(8, 5))
476-
for i, name in enumerate(["chordal", "martin", "geodesic", "procrustes"]):
477-
plt.plot(thetas, lst2[:, i], label=name)
478-
479-
plt.xlabel("theta")
480-
plt.ylabel("SGOT distance")
481-
plt.title("SGOT distance vs rotation angle")
482-
plt.legend()
481+
metrics = ["chordal", "geodesic", "procrustes", "martin"]
482+
styles = {"chordal": "-", "geodesic": "--", "procrustes": "-.", "martin": ":"}
483+
rotation_results = {m: [] for m in metrics}
484+
485+
for theta in thetas:
486+
Z = augment(generate_data(time, tau_0, freq_0, theta), sgot_window)
487+
B, B_spec = estimator(Z[:-1], Z[1:])
488+
D = np.log(B_spec["eig_val"]) * fs
489+
L = B_spec["eig_vec_left"]
490+
R = B_spec["eig_vec_right"]
491+
for m in metrics:
492+
rotation_results[m].append(
493+
sgot_metric(
494+
D_0_sgot, R_0_sgot, L_0_sgot, D, R, L, eta=0.9, grassmann_metric=m
495+
)
496+
)
497+
498+
fig, ax = plt.subplots(figsize=(7, 4))
499+
for m in metrics:
500+
ax.plot(thetas, rotation_results[m], styles[m], label=m, linewidth=1.8)
501+
ax.axvline(
502+
theta_0,
503+
color="gray",
504+
linestyle="--",
505+
linewidth=0.8,
506+
label=r"$\theta_0 = \pi/4$ (reference)",
507+
)
508+
ax.set_xlabel(r"Rotation angle $\theta$ (rad)")
509+
ax.set_ylabel(r"$d_S$")
510+
ax.set_title("SGOT distance vs. rotation angle across Grassmannian metrics")
511+
ax.legend()
512+
fig.tight_layout()
483513
plt.show()
484514

485515
# %%
@@ -501,38 +531,38 @@ def estimator(X, Y, rank=4):
501531
# distance changes as a function of the perturbed frequency :math:`\omega_2'`.
502532

503533
omegas = np.linspace(0.5, 3.0, 21)
504-
methods = ["chordal", "martin", "geodesic", "procrustes"]
505-
scores_omega = []
506-
theta = theta_0
534+
frequency_scores = {m: [] for m in metrics}
507535

508-
eta_fixed = 0.9
509536
for omega in omegas:
510-
freq_1 = np.array([freq_0[0], omega])
511-
traj = generate_data(time, tau_0, freq_1, theta)
512-
Z = augment(traj, 4)
513-
X = Z[:-1]
514-
Y = Z[1:]
515-
516-
B, B_spec = estimator(X, Y, rank=4)
517-
D, R, L = B_spec["eig_val"], B_spec["eig_vec_right"], B_spec["eig_vec_left"]
518-
D = np.log(D) * fs
519-
520-
row = []
521-
for name in methods:
522-
row.append(
523-
sgot_metric(D_0, R_0, L_0, D, R, L, eta=eta_fixed, grassmann_metric=name)
537+
Z = augment(
538+
generate_data(time, tau_0, np.array([freq_0[0], omega]), theta_0), sgot_window
539+
)
540+
B, B_spec = estimator(Z[:-1], Z[1:])
541+
D = np.log(B_spec["eig_val"]) * fs
542+
L = B_spec["eig_vec_left"]
543+
R = B_spec["eig_vec_right"]
544+
for m in metrics:
545+
frequency_scores[m].append(
546+
sgot_metric(
547+
D_0_sgot, R_0_sgot, L_0_sgot, D, R, L, eta=0.9, grassmann_metric=m
548+
)
524549
)
525-
scores_omega.append(row)
526-
527-
scores_omega = np.array(scores_omega)
528-
plt.figure(figsize=(8, 5))
529-
for i, name in enumerate(methods):
530-
plt.plot(omegas, scores_omega[:, i], label=name)
531550

532-
plt.xlabel("omega")
533-
plt.ylabel("SGOT distance")
534-
plt.title("SGOT distance vs omega")
535-
plt.legend()
551+
fig, ax = plt.subplots(figsize=(7, 4))
552+
for m in metrics:
553+
ax.plot(omegas, frequency_scores[m], styles[m], label=m, linewidth=1.8)
554+
ax.axvline(
555+
freq_0[1],
556+
color="gray",
557+
linestyle="--",
558+
linewidth=0.8,
559+
label=r"$\omega_2 = 2.0$ Hz (reference)",
560+
)
561+
ax.set_xlabel(r"Frequency $\omega_2'$ (Hz)")
562+
ax.set_ylabel(r"$d_S$")
563+
ax.set_title("SGOT distance vs. frequency across Grassmannian metrics")
564+
ax.legend()
565+
fig.tight_layout()
536566
plt.show()
537567

538568
# %%
@@ -553,47 +583,28 @@ def estimator(X, Y, rank=4):
553583
# In this way, both modes share the same modified decay parameter
554584
# :math:`\tau`, allowing us to isolate the influence of dissipation on the SGOT
555585
# distance.
556-
decays = np.linspace(0.1, 3.0, 20) # adjust range as needed
557-
methods = ["chordal", "martin", "geodesic", "procrustes"]
558-
scores_decay = []
559-
theta = theta_0
560-
561-
for tau in decays:
562-
freq_1 = np.array([freq_0[0], recovered_freqs[1]])
563-
tau_1 = np.array([tau, tau]) # or whatever structure your generator expects
564-
565-
traj = generate_data(time, tau_1, freq_1, theta)
566-
Z = augment(traj, 4)
567-
X = Z[:-1]
568-
Y = Z[1:]
569-
570-
B, B_spec = estimator(X, Y, rank=4)
571-
D, R, L = B_spec["eig_val"], B_spec["eig_vec_right"], B_spec["eig_vec_left"]
572-
D = np.log(D) * fs
573-
574-
row = []
575-
for name in methods:
576-
row.append(
586+
taus = np.linspace(0.1, 3.0, 21)
587+
decay_scores = {m: [] for m in metrics}
588+
589+
for tau in taus:
590+
Z = augment(generate_data(time, np.array([tau, tau]), freq_0, theta_0), sgot_window)
591+
B, B_spec = estimator(Z[:-1], Z[1:])
592+
D = np.log(B_spec["eig_val"]) * fs
593+
L = B_spec["eig_vec_left"]
594+
R = B_spec["eig_vec_right"]
595+
for m in metrics:
596+
decay_scores[m].append(
577597
sgot_metric(
578-
D_0,
579-
R_0,
580-
L_0,
581-
D,
582-
R,
583-
L,
584-
eta=0.9, # keep eta fixed here
585-
grassmann_metric=name,
598+
D_0_sgot, R_0_sgot, L_0_sgot, D, R, L, eta=0.9, grassmann_metric=m
586599
)
587600
)
588-
scores_decay.append(row)
589601

590-
scores_decay = np.array(scores_decay)
591-
plt.figure(figsize=(8, 5))
592-
for i, name in enumerate(methods):
593-
plt.plot(decays, scores_decay[:, i], label=name)
594-
595-
plt.xlabel("decay")
596-
plt.ylabel("SGOT distance")
597-
plt.title("SGOT distance vs decay")
598-
plt.legend()
602+
fig, ax = plt.subplots(figsize=(7, 4))
603+
for m in metrics:
604+
ax.plot(taus, decay_scores[m], styles[m], label=m, linewidth=1.8)
605+
ax.set_xlabel(r"Decay rate $\tau$")
606+
ax.set_ylabel(r"$d_S$")
607+
ax.set_title("SGOT distance vs. decay across Grassmannian metrics")
608+
ax.legend()
609+
fig.tight_layout()
599610
plt.show()

ot/sgot.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ def _delta_matrix_1d(Rs, Ls, Rt, Lt, nx=None, eps=1e-12):
124124
Ltn = _normalize_columns(Lt, nx=nx, eps=eps)
125125

126126
Cr = nx.dot(nx.conj(Rsn).T, Rtn)
127-
Cl = nx.dot(nx.conj(Lsn).T, Ltn)
127+
Cl = nx.dot(Lsn.T, nx.conj(Ltn))
128128

129129
delta = nx.abs(Cr * Cl)
130130
delta = nx.clip(delta, 0.0, 1.0)

0 commit comments

Comments
 (0)