Skip to content

Commit 3916f48

Browse files
john-halloranJohn Halloran
andauthored
feat: optimize stretch using a Hessian matrix (#204)
* feat: Optimize stretch using a Hessian matrix * test: add test for hessian structure * docs: add docstring for regularize_function_hessian --------- Co-authored-by: John Halloran <jhalloran@oxy.edu>
1 parent 0c0267c commit 3916f48

3 files changed

Lines changed: 155 additions & 6 deletions

File tree

news/hessian-fix.rst

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
**Added:**
2+
3+
* Optimize stretch using a Hessian matrix
4+
5+
**Changed:**
6+
7+
* <news item>
8+
9+
**Deprecated:**
10+
11+
* <news item>
12+
13+
**Removed:**
14+
15+
* <news item>
16+
17+
**Fixed:**
18+
19+
* <news item>
20+
21+
**Security:**
22+
23+
* <news item>

src/diffpy/stretched_nmf/snmf_class.py

Lines changed: 87 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1031,10 +1031,7 @@ def _update_weights(self):
10311031
)
10321032
self.weights_[:, signal] = new_weight
10331033

1034-
def _regularize_function(self, stretch=None):
1035-
if stretch is None:
1036-
stretch = self.stretch_
1037-
1034+
def _stretch_residual_and_derivatives(self, stretch):
10381035
stretched_components, d_stretch_comps, dd_stretch_comps = (
10391036
self._compute_stretched_components(stretch=stretch)
10401037
)
@@ -1048,6 +1045,15 @@ def _regularize_function(self, stretch=None):
10481045
)
10491046
- self._source_matrix
10501047
)
1048+
return residuals, d_stretch_comps, dd_stretch_comps
1049+
1050+
def _regularize_function(self, stretch=None):
1051+
if stretch is None:
1052+
stretch = self.stretch_
1053+
1054+
residuals, d_stretch_comps, _ = self._stretch_residual_and_derivatives(
1055+
stretch
1056+
)
10511057

10521058
fun = self._get_objective_function(residuals, stretch)
10531059

@@ -1062,10 +1068,60 @@ def _regularize_function(self, stretch=None):
10621068
@ (self._spline_smooth_operator.T @ self._spline_smooth_operator)
10631069
)
10641070

1065-
# Hessian would go here
1066-
10671071
return fun, gra
10681072

1073+
def _regularize_function_hessian(self, stretch):
1074+
"""Calculate the Hessian for the stretch optimization objective.
1075+
1076+
The Hessian combines the Gauss-Newton curvature from the stretched
1077+
component derivatives, the residual-weighted second derivatives of
1078+
those stretched components, and the quadratic smoothing penalty on
1079+
neighboring stretch factors.
1080+
1081+
Parameters
1082+
----------
1083+
stretch : ndarray of shape (n_components, n_signals)
1084+
Stretching factors at which to evaluate the objective curvature.
1085+
1086+
Returns
1087+
-------
1088+
ndarray of shape (n_components * n_signals, n_components * n_signals)
1089+
Symmetric Hessian matrix for the flattened stretch variables.
1090+
"""
1091+
residuals, d_stretch_comps, dd_stretch_comps = (
1092+
self._stretch_residual_and_derivatives(stretch)
1093+
)
1094+
n_variables = self.n_components_ * self.n_signals_
1095+
hessian = np.zeros((n_variables, n_variables), dtype=float)
1096+
1097+
for signal in range(self.n_signals_):
1098+
variable_indices = (
1099+
np.arange(self.n_components_) * self.n_signals_ + signal
1100+
)
1101+
d_signal = d_stretch_comps[:, variable_indices]
1102+
dd_signal = dd_stretch_comps[:, variable_indices]
1103+
hessian[np.ix_(variable_indices, variable_indices)] += (
1104+
d_signal.T @ d_signal
1105+
)
1106+
hessian[variable_indices, variable_indices] += np.sum(
1107+
dd_signal * residuals[:, signal, None],
1108+
axis=0,
1109+
)
1110+
1111+
smooth_hessian = (
1112+
self._spline_smooth_operator.T @ self._spline_smooth_operator
1113+
).toarray()
1114+
for comp in range(self.n_components_):
1115+
component_slice = slice(
1116+
comp * self.n_signals_,
1117+
(comp + 1) * self.n_signals_,
1118+
)
1119+
hessian[component_slice, component_slice] += (
1120+
self.rho * smooth_hessian
1121+
)
1122+
1123+
return 0.5 * (hessian + hessian.T)
1124+
10691125
def _update_stretch(self):
10701126
"""Updates stretching matrix using constrained optimization
10711127
(equivalent to fmincon in MATLAB)."""
@@ -1085,6 +1141,30 @@ def objective(stretch_vec):
10851141
gra = gra.flatten()
10861142
return fun, gra
10871143

1144+
def hessian(stretch_vec):
1145+
stretch_matrix = stretch_vec.reshape(self.stretch_.shape)
1146+
return self._regularize_function_hessian(stretch_matrix)
1147+
1148+
unconstrained_result = minimize(
1149+
fun=lambda stretch_vec: objective(stretch_vec)[0],
1150+
x0=stretch_flat_initial,
1151+
method="trust-exact",
1152+
jac=lambda stretch_vec: objective(stretch_vec)[1],
1153+
hess=hessian,
1154+
options={"maxiter": 300},
1155+
)
1156+
unconstrained_stretch = unconstrained_result.x.reshape(
1157+
self.stretch_.shape
1158+
)
1159+
if np.all(unconstrained_stretch >= 0.1):
1160+
current_objective = self._regularize_function(self.stretch_)[0]
1161+
candidate_objective = self._regularize_function(
1162+
unconstrained_stretch
1163+
)[0]
1164+
if candidate_objective <= current_objective:
1165+
self.stretch_ = unconstrained_stretch
1166+
return
1167+
10881168
# Optimization constraints: lower bound 0.1, no upper bound
10891169
bounds = [
10901170
(0.1, None)
@@ -1096,6 +1176,7 @@ def objective(stretch_vec):
10961176
x0=stretch_flat_initial,
10971177
method="trust-constr", # Substitute for 'trust-region-reflective'
10981178
jac=lambda stretch_vec: objective(stretch_vec)[1], # Gradient
1179+
hess=hessian,
10991180
bounds=bounds,
11001181
)
11011182

tests/test_snmf_optimizer.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import numpy as np
22
import pytest
3+
from scipy.sparse import csr_matrix
34

45
from diffpy.stretched_nmf.snmf_class import SNMFOptimizer
56

@@ -110,3 +111,47 @@ def test_compute_objective_function(inputs, expected):
110111
spline_smooth_operator=operator,
111112
)
112113
assert np.isclose(result, expected)
114+
115+
116+
def test_regularize_function_hessian_has_expected_structure():
117+
model = SNMFOptimizer(n_components=2, rho=0.5)
118+
model.n_components_ = 2
119+
model.n_signals_ = 3
120+
model._spline_smooth_operator = csr_matrix(
121+
[[1.0, -1.0, 0.0], [0.0, 1.0, -1.0]]
122+
)
123+
124+
residuals = np.array([[2.0, -1.0, 4.0], [1.0, 3.0, -2.0]])
125+
d_stretch_comps = np.array(
126+
[
127+
[1.0, 0.0, 1.0, 2.0, 0.0, 1.0],
128+
[0.0, 1.0, 1.0, 0.0, 3.0, -1.0],
129+
]
130+
)
131+
dd_stretch_comps = np.array(
132+
[
133+
[0.5, 1.0, 0.0, 1.0, 0.0, -0.5],
134+
[1.0, 0.0, 0.25, 0.0, 1.0, 0.5],
135+
]
136+
)
137+
model._stretch_residual_and_derivatives = lambda stretch: (
138+
residuals,
139+
d_stretch_comps,
140+
dd_stretch_comps,
141+
)
142+
143+
hessian = model._regularize_function_hessian(np.ones((2, 3)))
144+
145+
expected = np.array(
146+
[
147+
[3.5, -0.5, 0.0, 2.0, 0.0, 0.0],
148+
[-0.5, 1.0, -0.5, 0.0, 3.0, 0.0],
149+
[0.0, -0.5, 2.0, 0.0, 0.0, 0.0],
150+
[2.0, 0.0, 0.0, 6.5, -0.5, 0.0],
151+
[0.0, 3.0, 0.0, -0.5, 13.0, -0.5],
152+
[0.0, 0.0, 0.0, 0.0, -0.5, -0.5],
153+
]
154+
)
155+
assert hessian.shape == (6, 6)
156+
assert np.allclose(hessian, hessian.T)
157+
assert np.allclose(hessian, expected)

0 commit comments

Comments
 (0)