Skip to content

Commit 561499b

Browse files
authored
Squeeze morph warning handling (#272)
* Squeeze morph warning handling * Clarity update * rework test * Update test docstrings, warning message * Update duplicate test docstring
1 parent f5fc9ac commit 561499b

File tree

5 files changed

+235
-12
lines changed

5 files changed

+235
-12
lines changed

news/squeeze_warnings.rst

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
**Added:**
2+
3+
* Warnings added to `squeeze` morph if the squeeze causes the grid to become non-monotonic.
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/morph/morph_io.py

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -408,9 +408,9 @@ def tabulate_results(multiple_morph_results):
408408
return tabulated_results
409409

410410

411-
def handle_warnings(squeeze_morph):
412-
if squeeze_morph is not None:
413-
extrapolation_info = squeeze_morph.extrapolation_info
411+
def handle_extrapolation_warnings(morph):
412+
if morph is not None:
413+
extrapolation_info = morph.extrapolation_info
414414
is_extrap_low = extrapolation_info["is_extrap_low"]
415415
is_extrap_high = extrapolation_info["is_extrap_high"]
416416
cutoff_low = extrapolation_info["cutoff_low"]
@@ -443,3 +443,35 @@ def handle_warnings(squeeze_morph):
443443
wmsg,
444444
UserWarning,
445445
)
446+
447+
448+
def handle_check_increase_warning(squeeze_morph):
449+
if squeeze_morph is not None:
450+
if not squeeze_morph.strictly_increasing:
451+
wmsg = (
452+
"Warning: The squeeze morph has interpolated your morphed "
453+
"function from a non-monotonically increasing grid. "
454+
"\nThis may not be an issue, but please check for your "
455+
"particular case. "
456+
"\nTo avoid squeeze making your grid non-monotonic, "
457+
"here are some suggested fixes: "
458+
"\n(1) Please decrease the order of your polynomial and "
459+
"try again. "
460+
"\n(2) If you are using initial guesses of all 0, please "
461+
"ensure your objective function only requires a small "
462+
"polynomial squeeze to match your reference. "
463+
"(In other words, there is good agreement between the two "
464+
"functions.) "
465+
"\n(3) If you expect a large polynomial squeeze to be "
466+
"needed, please ensure your initial parameters for the "
467+
"polynomial morph result in good agreement between your "
468+
"reference and objective functions. "
469+
"One way to obtain such parameters is to "
470+
"first apply a --hshift and --stretch morph. "
471+
"Then, use the hshift parameter for a0 and stretch "
472+
"parameter for a1."
473+
)
474+
warnings.warn(
475+
wmsg,
476+
UserWarning,
477+
)

src/diffpy/morph/morphapp.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -707,9 +707,10 @@ def single_morph(
707707
chain(x_morph, y_morph, x_target, y_target)
708708

709709
# THROW ANY WARNINGS HERE
710-
io.handle_warnings(squeeze_morph)
711-
io.handle_warnings(shift_morph)
712-
io.handle_warnings(stretch_morph)
710+
io.handle_extrapolation_warnings(squeeze_morph)
711+
io.handle_check_increase_warning(squeeze_morph)
712+
io.handle_extrapolation_warnings(shift_morph)
713+
io.handle_extrapolation_warnings(stretch_morph)
713714

714715
# Get Rw for the morph range
715716
rw = tools.getRw(chain)

src/diffpy/morph/morphs/morphsqueeze.py

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Class MorphSqueeze -- Apply a polynomial to squeeze the morph
22
function."""
33

4+
import numpy
45
from numpy.polynomial import Polynomial
56
from scipy.interpolate import CubicSpline
67

@@ -67,10 +68,36 @@ class MorphSqueeze(Morph):
6768
extrap_index_high = None
6869
squeeze_cutoff_low = None
6970
squeeze_cutoff_high = None
71+
strictly_increasing = None
7072

7173
def __init__(self, config=None):
7274
super().__init__(config)
7375

76+
def _check_strictly_increasing(self, x, x_sorted):
77+
if list(x) != list(x_sorted):
78+
self.strictly_increasing = False
79+
else:
80+
self.strictly_increasing = True
81+
82+
def _sort_squeeze(self, x, y):
83+
"""Sort x,y according to the value of x."""
84+
xy = list(zip(x, y))
85+
xy_sorted = sorted(xy, key=lambda pair: pair[0])
86+
x_sorted, y_sorted = numpy.array(list(zip(*xy_sorted)))
87+
return x_sorted, y_sorted
88+
89+
def _handle_duplicates(self, x, y):
90+
"""Remove duplicated x and use the mean value of y corresponded
91+
to the duplicated x."""
92+
x_unique, inv = numpy.unique(x, return_inverse=True)
93+
if len(x_unique) == len(x):
94+
return x, y
95+
else:
96+
y_avg = numpy.zeros_like(x_unique, dtype=float)
97+
for idx, _ in enumerate(x_unique):
98+
y_avg[idx] = y[inv == idx].mean()
99+
return x_unique, y_avg
100+
74101
def morph(self, x_morph, y_morph, x_target, y_target):
75102
"""Apply a polynomial to squeeze the morph function.
76103
@@ -82,9 +109,16 @@ def morph(self, x_morph, y_morph, x_target, y_target):
82109
coeffs = [self.squeeze[f"a{i}"] for i in range(len(self.squeeze))]
83110
squeeze_polynomial = Polynomial(coeffs)
84111
x_squeezed = self.x_morph_in + squeeze_polynomial(self.x_morph_in)
85-
self.y_morph_out = CubicSpline(x_squeezed, self.y_morph_in)(
112+
x_squeezed_sorted, y_morph_sorted = self._sort_squeeze(
113+
x_squeezed, self.y_morph_in
114+
)
115+
self._check_strictly_increasing(x_squeezed, x_squeezed_sorted)
116+
x_squeezed_sorted, y_morph_sorted = self._handle_duplicates(
117+
x_squeezed_sorted, y_morph_sorted
118+
)
119+
self.y_morph_out = CubicSpline(x_squeezed_sorted, y_morph_sorted)(
86120
self.x_morph_in
87121
)
88-
self.set_extrapolation_info(x_squeezed, self.x_morph_in)
122+
self.set_extrapolation_info(x_squeezed_sorted, self.x_morph_in)
89123

90124
return self.xyallout

tests/test_morphsqueeze.py

Lines changed: 137 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ def test_morphsqueeze(x_morph, x_target, squeeze_coeffs):
5454
x_target_expected = x_target
5555
y_target_expected = y_target
5656
# actual output
57+
# turn the coefficients into a list for passing to Polynomial
58+
# the morphsqueeze function itself requires a dictionary
5759
coeffs = [squeeze_coeffs[f"a{i}"] for i in range(len(squeeze_coeffs))]
5860
squeeze_polynomial = Polynomial(coeffs)
5961
x_squeezed = x_morph + squeeze_polynomial(x_morph)
@@ -139,16 +141,16 @@ def test_morphsqueeze_extrapolate(user_filesystem, squeeze_coeffs, wmsg_gen):
139141
coeffs = [squeeze_coeffs[f"a{i}"] for i in range(len(squeeze_coeffs))]
140142
squeeze_polynomial = Polynomial(coeffs)
141143
x_squeezed = x_morph + squeeze_polynomial(x_morph)
142-
with pytest.warns() as w:
144+
with pytest.warns() as warning:
143145
morphpy.morph_arrays(
144146
np.array([x_morph, y_morph]).T,
145147
np.array([x_target, y_target]).T,
146148
squeeze=coeffs,
147149
apply=True,
148150
)
149-
assert len(w) == 1
150-
assert w[0].category is UserWarning
151-
actual_wmsg = str(w[0].message)
151+
assert len(warning) == 1
152+
assert warning[0].category is UserWarning
153+
actual_wmsg = str(warning[0].message)
152154
expected_wmsg = wmsg_gen([min(x_squeezed), max(x_squeezed)])
153155
assert actual_wmsg == expected_wmsg
154156

@@ -170,3 +172,134 @@ def test_morphsqueeze_extrapolate(user_filesystem, squeeze_coeffs, wmsg_gen):
170172
)
171173
with pytest.warns(UserWarning, match=expected_wmsg):
172174
single_morph(parser, opts, pargs, stdout_flag=False)
175+
176+
177+
def test_non_unique_grid():
178+
# Test giving morphsqueeze a non-unique grid
179+
# Expect it to return a unique grid
180+
squeeze_coeffs = {"a0": 0.01, "a1": 0.01, "a2": -0.1}
181+
x_grid = np.linspace(0, 10, 101)
182+
183+
coeffs = [squeeze_coeffs[f"a{i}"] for i in range(len(squeeze_coeffs))]
184+
squeeze_polynomial = Polynomial(coeffs)
185+
x_morph = x_grid + squeeze_polynomial(x_grid)
186+
x_gradient = np.diff(x_morph)
187+
x_gradient_sign = np.sign(x_gradient)
188+
# non-strictly increasing means the gradient becomes 0 or negative
189+
assert not np.all(x_gradient_sign > 0)
190+
191+
x_target = np.linspace(min(x_morph), max(x_morph), len(x_morph))
192+
y_target = np.sin(x_target)
193+
194+
y_morph = np.sin(x_morph)
195+
# apply no squeeze, but the morph should sort the function
196+
_, table = morphpy.morph_arrays(
197+
np.array([x_morph, y_morph]).T,
198+
np.array([x_target, y_target]).T,
199+
squeeze=[0, 0, 0],
200+
apply=True,
201+
)
202+
x_refined, _ = table[:, 0], table[:, 1]
203+
204+
# grid should be properly sorted
205+
assert np.allclose(x_refined, x_target)
206+
# note that the function itself may be distorted
207+
208+
209+
@pytest.mark.parametrize(
210+
"squeeze_coeffs, x_morph",
211+
[
212+
# The following squeezes make the function non-monotonic.
213+
# Expect code to work but issue the correct warning.
214+
([-1, -1, 2], np.linspace(-1, 1, 101)),
215+
(
216+
[-1, -1, 0, 0, 2],
217+
np.linspace(-1, 1, 101),
218+
),
219+
],
220+
)
221+
def test_squeeze_warnings(user_filesystem, squeeze_coeffs, x_morph):
222+
# call in .py
223+
x_target = x_morph
224+
y_target = np.sin(x_target)
225+
squeeze_polynomial = Polynomial(squeeze_coeffs)
226+
x_squeezed = x_morph + squeeze_polynomial(x_morph)
227+
y_morph = np.sin(x_squeezed)
228+
morph = MorphSqueeze()
229+
morph.squeeze = squeeze_coeffs
230+
with pytest.warns() as warning:
231+
morphpy.morph_arrays(
232+
np.array([x_morph, y_morph]).T,
233+
np.array([x_target, y_target]).T,
234+
squeeze=squeeze_coeffs,
235+
apply=True,
236+
)
237+
assert len(warning) == 1
238+
assert warning[0].category is UserWarning
239+
actual_wmsg = str(warning[0].message)
240+
expected_wmsg = (
241+
"Warning: The squeeze morph has interpolated your morphed "
242+
"function from a non-monotonically increasing grid. "
243+
"\nThis may not be an issue, but please check for your "
244+
"particular case. "
245+
"\nTo avoid squeeze making your grid non-monotonic, "
246+
"here are some suggested fixes: "
247+
"\n(1) Please decrease the order of your polynomial and try again. "
248+
"\n(2) If you are using initial guesses of all 0, please ensure "
249+
"your objective function only requires a small polynomial "
250+
"squeeze to match your reference. "
251+
"(In other words, there is good agreement between the two "
252+
"functions.) "
253+
"\n(3) If you expect a large polynomial squeeze to be needed, "
254+
"please ensure your initial parameters for the polynomial "
255+
"morph result in good agreement between your reference and "
256+
"objective functions. One way to obtain such parameters is to "
257+
"first apply a --hshift and --stretch morph. "
258+
"Then, use the hshift parameter for a0 and stretch parameter for a1."
259+
)
260+
assert expected_wmsg in actual_wmsg
261+
262+
# call in CLI
263+
morph_file, target_file = create_morph_data_file(
264+
user_filesystem / "cwd_dir", x_morph, y_morph, x_target, y_target
265+
)
266+
parser = create_option_parser()
267+
(opts, pargs) = parser.parse_args(
268+
[
269+
"--squeeze",
270+
",".join(map(str, squeeze_coeffs)),
271+
f"{morph_file.as_posix()}",
272+
f"{target_file.as_posix()}",
273+
"--apply",
274+
"-n",
275+
]
276+
)
277+
with pytest.warns(UserWarning) as warning:
278+
single_morph(parser, opts, pargs, stdout_flag=False)
279+
assert len(warning) == 1
280+
actual_wmsg = str(warning[0].message)
281+
assert expected_wmsg in actual_wmsg
282+
283+
284+
@pytest.mark.parametrize(
285+
"x_sampled",
286+
[
287+
# Expected output: all repeated datapoints are removed
288+
# Test one duplicate per number
289+
np.array([0, 0, 1, 1, 2, 2, 3, 3]),
290+
# Test more than one duplicates per number
291+
np.array([0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2, 2]),
292+
# Test with only one grid number
293+
np.array([0, 0, 0, 0]),
294+
# Test no duplicates
295+
np.array([0, 1, 2, 3, 4]),
296+
],
297+
)
298+
def test_handle_duplicates(x_sampled):
299+
morph = MorphSqueeze()
300+
y_sampled = np.sin(x_sampled)
301+
x_handled, y_handled = morph._handle_duplicates(x_sampled, y_sampled)
302+
x_target = np.unique(x_sampled)
303+
y_target = np.array([y_sampled[x_sampled == x].mean() for x in x_target])
304+
assert np.allclose(x_handled, x_target)
305+
assert np.allclose(y_handled, y_target)

0 commit comments

Comments
 (0)