Skip to content

Commit 8d96fe4

Browse files
committed
Expand unit test coverage
1 parent a4d1d1e commit 8d96fe4

4 files changed

Lines changed: 898 additions & 0 deletions

File tree

‎spectral/tests/test_detectors.py‎

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,33 @@ def test_mf_windowed_target_eq_one(self):
5555
y = spy.matched_filter(X, X[ij], window=(3, 7), cov=self.background.cov)
5656
np.allclose(1, y[ij])
5757

58+
def test_mf_windowed_target_eq_one_no_cov(self):
59+
'''Windowed Matched Filter without a supplied `cov` (background
60+
covariance recomputed for every window) should also score the
61+
target pixel as one. A bigger window is needed here than in
62+
`test_mf_windowed_target_eq_one` since, without a precomputed
63+
`cov`, the outer window must contain at least as many pixels as
64+
there are bands (220) to estimate a full-rank covariance.'''
65+
X = self.data[:25, :25, :]
66+
ij = (12, 12)
67+
y = spy.matched_filter(X, X[ij], window=(3, 21))
68+
assert np.allclose(1, y[ij])
69+
70+
def test_mf_whiten(self):
71+
'''`whiten` should match its documented closed-form definition.'''
72+
X = self.data[:5, :5, :].reshape(-1, self.data.shape[-1])
73+
whitened = self.mf.whiten(X)
74+
expected = (np.sqrt(self.mf.coef) * self.background.sqrt_inv_cov).dot(
75+
(X - self.background.mean).T).T
76+
assert np.allclose(whitened, expected)
77+
78+
def test_matched_filter_mutually_exclusive_raises(self):
79+
from spectral.algorithms.detectors import matched_filter
80+
(i, j) = self.target_ij
81+
with pytest.raises(ValueError):
82+
matched_filter(self.data, self.data[i, j],
83+
background=self.background, window=(3, 7))
84+
5885

5986
class TestRX:
6087
@pytest.fixture(autouse=True)
@@ -67,6 +94,49 @@ def test_rx_bg_eq_zero(self):
6794
stats = spy.calc_stats(self.data)
6895
np.testing.assert_approx_equal(rx(stats.mean, background=stats), 0)
6996

97+
def test_rx_call_without_background_computes_stats(self):
98+
from spectral.algorithms.detectors import RX
99+
detector = RX()
100+
assert detector.background is None
101+
scores = detector(self.data[:5, :5, :])
102+
assert detector.background is not None
103+
assert scores.shape == (5, 5)
104+
105+
def test_rx_call_rejects_non_ndarray(self):
106+
from spectral.algorithms.detectors import RX
107+
with pytest.raises(TypeError):
108+
RX()([1, 2, 3])
109+
110+
def test_rx_mutually_exclusive_raises(self):
111+
from spectral.algorithms.detectors import rx
112+
with pytest.raises(ValueError):
113+
rx(self.data, background=self.background, window=(3, 7))
114+
115+
def test_rx_windowed_flags_outlier(self):
116+
'''A pixel that's a clear spectral outlier relative to its
117+
surrounding window should score much higher than typical
118+
(non-outlier) pixels.'''
119+
from spectral.algorithms.detectors import rx
120+
img = np.random.RandomState(0).normal(
121+
size=(20, 20, 5)).astype(float)
122+
outlier_ij = (10, 10)
123+
img[outlier_ij] += 20.0
124+
scores = rx(img, window=(3, 9))
125+
assert scores[outlier_ij] > 10 * np.median(scores)
126+
127+
def test_rx_windowed_with_cov_flags_outlier(self):
128+
'''Same as `test_rx_windowed_flags_outlier`, but supplying a
129+
precomputed `cov` (only the background mean is recomputed per
130+
window).'''
131+
from spectral.algorithms.detectors import rx
132+
img = np.random.RandomState(0).normal(
133+
size=(20, 20, 5)).astype(float)
134+
outlier_ij = (10, 10)
135+
img[outlier_ij] += 20.0
136+
cov = spy.calc_stats(img).cov
137+
scores = rx(img, window=(3, 9), cov=cov)
138+
assert scores[outlier_ij] > 10 * np.median(scores)
139+
70140

71141
class TestACE:
72142
@pytest.fixture(autouse=True)
@@ -157,3 +227,76 @@ def test_ace_windowed_target_eq_one(self):
157227
ij = (10, 10)
158228
y = spy.ace(self.X, self.X[ij], window=(3, 7), cov=self.bg.cov)
159229
assert (np.allclose(1, y[ij]))
230+
231+
def test_ace_windowed_target_eq_one_no_cov(self):
232+
'''Same as `test_ace_windowed_target_eq_one`, but without a
233+
precomputed `cov` (background mean and covariance are both
234+
recomputed for every window). A bigger outer window is needed
235+
here since, without a precomputed `cov`, it must contain at least
236+
as many pixels as there are bands (220) to estimate a full-rank
237+
covariance.'''
238+
ij = (10, 10)
239+
y = spy.ace(self.X, self.X[ij], window=(3, 19))
240+
assert (np.allclose(1, y[ij]))
241+
242+
def test_ace_windowed_multi_targets_eq_one(self):
243+
'''Windowed ACE score of each target, for a list of multiple
244+
targets, should be one at that target's own pixel.'''
245+
ij1 = (5, 5)
246+
ij2 = (12, 14)
247+
y = spy.ace(self.X, [self.X[ij1], self.X[ij2]], window=(3, 19))
248+
assert y.shape == self.X.shape[:2] + (2,)
249+
assert np.allclose(1, y[ij1][0])
250+
assert np.allclose(1, y[ij2][1])
251+
252+
def test_ace_windowed_multi_targets_eq_one_with_cov(self):
253+
'''Same as `test_ace_windowed_multi_targets_eq_one`, but with a
254+
precomputed `cov`.'''
255+
ij1 = (5, 5)
256+
ij2 = (12, 14)
257+
y = spy.ace(self.X, [self.X[ij1], self.X[ij2]], window=(3, 7),
258+
cov=self.bg.cov)
259+
assert y.shape == self.X.shape[:2] + (2,)
260+
assert np.allclose(1, y[ij1][0])
261+
assert np.allclose(1, y[ij2][1])
262+
263+
def test_ace_set_target_none(self):
264+
from spectral.algorithms.detectors import ACE
265+
ij = (10, 10)
266+
detector = ACE(self.X[ij], background=self.bg)
267+
assert detector._P is not None
268+
detector.set_target(None)
269+
assert detector._target is None
270+
assert detector._P is None
271+
272+
def test_ace_call_rejects_non_ndarray(self):
273+
from spectral.algorithms.detectors import ACE
274+
ij = (10, 10)
275+
detector = ACE(self.X[ij], background=self.bg)
276+
with pytest.raises(TypeError):
277+
detector([1, 2, 3])
278+
279+
def test_ace_auto_background(self):
280+
'''Constructing an ACE detector without `background` and calling
281+
it directly should compute background stats from the given data.'''
282+
from spectral.algorithms.detectors import ACE
283+
ij = (10, 10)
284+
detector = ACE(self.X[ij])
285+
assert detector._background is None
286+
scores = detector(self.X)
287+
assert detector._background is not None
288+
assert np.allclose(1, scores[ij])
289+
290+
def test_ace_function_auto_background_multi_target(self):
291+
'''`spy.ace` with a list of targets and no `background`/`window`
292+
should compute background stats once from the given data.'''
293+
ij1 = (5, 5)
294+
ij2 = (12, 14)
295+
y = spy.ace(self.X, [self.X[ij1], self.X[ij2]])
296+
assert np.allclose(1, y[ij1][0])
297+
assert np.allclose(1, y[ij2][1])
298+
299+
def test_ace_function_mutually_exclusive_raises(self):
300+
ij = (10, 10)
301+
with pytest.raises(ValueError):
302+
spy.ace(self.X, self.X[ij], background=self.bg, window=(3, 7))

‎spectral/tests/test_perceptron.py‎

Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
'''Tests of the multilayer perceptron classifier.
2+
3+
Training tests seed `numpy.random` before constructing each `Perceptron`
4+
(whose layer weights are otherwise randomly initialized) so that training
5+
behavior is reproducible; the AND gate is used as training data since it's
6+
linearly separable and converges quickly and reliably.
7+
8+
The demo functions at the bottom of `perceptron.py` (`test_xor`, `test_and`,
9+
etc., and the `if __name__ == '__main__'` block) are standalone scripts
10+
that just forward to `Perceptron.train`, already covered thoroughly below;
11+
they're intentionally not exercised here.
12+
13+
To run the unit tests, type the following from the system command line:
14+
15+
# pytest spectral/tests/test_perceptron.py
16+
'''
17+
18+
import io
19+
20+
import numpy as np
21+
from numpy.testing import assert_allclose
22+
import pytest
23+
24+
from spectral.algorithms.perceptron import (Perceptron, PerceptronLayer,
25+
and_data)
26+
27+
AND_XY = list(zip(*and_data))
28+
29+
30+
class TestPerceptronLayer:
31+
32+
def test_shape(self):
33+
layer = PerceptronLayer((3, 2))
34+
assert layer.shape == (2, 4)
35+
assert layer.weights.shape == (2, 4)
36+
37+
def test_explicit_weights(self):
38+
weights = np.arange(8, dtype=float).reshape(2, 4)
39+
layer = PerceptronLayer((3, 2), weights=weights)
40+
assert_allclose(layer.weights, weights)
41+
42+
def test_explicit_weights_wrong_shape_raises(self):
43+
weights = np.zeros((2, 3))
44+
with pytest.raises(Exception):
45+
PerceptronLayer((3, 2), weights=weights)
46+
47+
def test_randomize_weights_unit_length(self):
48+
layer = PerceptronLayer((5, 4))
49+
norms = np.sqrt((layer.weights[:, 1:] ** 2).sum(axis=1))
50+
assert_allclose(norms, np.ones(4))
51+
52+
def test_input_and_activation(self):
53+
layer = PerceptronLayer((2, 1))
54+
layer.weights = np.array([[0.0, 1.0, -1.0]])
55+
y = layer.input([2.0, 5.0])
56+
expected = 1. / (1. + np.exp(3.0))
57+
assert_allclose(y, [expected])
58+
59+
def test_input_clip(self):
60+
layer = PerceptronLayer((2, 1))
61+
layer.weights = np.array([[100.0, 1.0, -1.0]]) # saturates near 1
62+
y = layer.input([100.0, 0.0], clip=0.1)
63+
assert_allclose(y, [0.9])
64+
65+
def test_dy_da(self):
66+
layer = PerceptronLayer((2, 1))
67+
layer.weights = np.array([[0.0, 1.0, -1.0]])
68+
layer.input([2.0, 5.0])
69+
expected = layer.k * layer.y * (1.0 - layer.y)
70+
assert_allclose(layer.dy_da(), expected)
71+
72+
73+
class TestPerceptronConstruction:
74+
75+
def test_invalid_layers_type_raises(self):
76+
with pytest.raises(Exception):
77+
Perceptron('not a list')
78+
79+
def test_too_few_layers_raises(self):
80+
with pytest.raises(Exception):
81+
Perceptron([3])
82+
83+
def test_layer_shapes(self):
84+
p = Perceptron([3, 4, 2])
85+
assert p.layers[0].shape == (4, 4)
86+
assert p.layers[1].shape == (2, 5)
87+
88+
89+
class TestPerceptronInputClassify:
90+
91+
@pytest.fixture
92+
def scaled_perceptron(self):
93+
'''A single-layer Perceptron with known weights and scaling set
94+
directly (bypassing `train`, which is what normally establishes
95+
`_scale`/`_offset`) so `input`/`classify` can be checked in
96+
isolation against a hand-computed expected value.'''
97+
p = Perceptron([2, 1])
98+
p._scale = np.array([1.0, 1.0])
99+
p._offset = np.array([0.0, 0.0])
100+
p.layers[0].weights = np.array([[0.0, 1.0, -1.0]])
101+
return p
102+
103+
def test_input(self, scaled_perceptron):
104+
result = scaled_perceptron.input([2.0, 5.0])
105+
expected = 1. / (1. + np.exp(3.0))
106+
assert_allclose(result, [expected])
107+
108+
def test_classify(self, scaled_perceptron):
109+
# Large positive z -> sigmoid saturates near 1 -> rounds to 1.
110+
assert scaled_perceptron.classify([10.0, 0.0]) == [1]
111+
# Large negative z -> sigmoid saturates near 0 -> rounds to 0.
112+
assert scaled_perceptron.classify([0.0, 10.0]) == [0]
113+
114+
115+
class TestSetScaling:
116+
117+
def test_handles_constant_feature(self):
118+
'''A feature that's constant across all training samples has zero
119+
range; `_set_scaling` should avoid a divide-by-zero for it rather
120+
than producing an inf/nan scale factor.'''
121+
np.random.seed(0)
122+
p = Perceptron([2, 1])
123+
X = [[1.0, 0.0], [1.0, 1.0], [1.0, 0.0], [1.0, 1.0]]
124+
p._set_scaling(X)
125+
assert np.all(np.isfinite(p._scale))
126+
127+
128+
class TestPerceptronTrain:
129+
130+
def test_converges_on_and_gate(self):
131+
np.random.seed(0)
132+
p = Perceptron([2, 1])
133+
out = io.StringIO()
134+
trained = p.train(*AND_XY, max_iterations=2000, stdout=out)
135+
assert trained is True
136+
assert p.accuracy == 100.0
137+
138+
@pytest.mark.parametrize('batch', [0, 1, 3])
139+
def test_batch_options_converge(self, batch):
140+
np.random.seed(0)
141+
p = Perceptron([2, 1])
142+
out = io.StringIO()
143+
trained = p.train(*AND_XY, max_iterations=3000, batch=batch,
144+
stdout=out)
145+
assert trained is True
146+
147+
def test_momentum_converges(self):
148+
np.random.seed(0)
149+
p = Perceptron([2, 1])
150+
out = io.StringIO()
151+
trained = p.train(*AND_XY, max_iterations=3000, momentum=0.5,
152+
stdout=out)
153+
assert trained is True
154+
155+
def test_accuracy_over_100_runs_full_iterations(self):
156+
'''Setting `accuracy` above 100 (the maximum achievable) forces
157+
`train` to run for the full `max_iterations` rather than stopping
158+
early once 100% accuracy is reached.'''
159+
np.random.seed(0)
160+
p = Perceptron([2, 1])
161+
out = io.StringIO()
162+
trained = p.train(*AND_XY, max_iterations=5, accuracy=200.0,
163+
stdout=out)
164+
assert trained is False
165+
166+
def test_stdout_none_is_suppressed(self):
167+
np.random.seed(0)
168+
p = Perceptron([2, 1])
169+
p.train(*AND_XY, max_iterations=5, stdout=None)
170+
171+
def test_on_iteration_callback_stops_training(self):
172+
np.random.seed(0)
173+
p = Perceptron([2, 1])
174+
calls = []
175+
176+
def on_iteration(perceptron):
177+
calls.append(perceptron.accuracy)
178+
return True
179+
180+
out = io.StringIO()
181+
trained = p.train(*AND_XY, max_iterations=100,
182+
on_iteration=on_iteration, stdout=out)
183+
assert trained is True
184+
assert len(calls) == 1
185+
186+
def test_keyboard_interrupt_during_training(self):
187+
np.random.seed(0)
188+
p = Perceptron([2, 1])
189+
190+
def raise_interrupt(perceptron):
191+
raise KeyboardInterrupt
192+
193+
out = io.StringIO()
194+
trained = p.train(*AND_XY, max_iterations=100,
195+
on_iteration=raise_interrupt, stdout=out)
196+
assert trained is False
197+
assert 'KeyboardInterrupt' in out.getvalue()
198+
199+
200+
class RaisingArray(np.ndarray):
201+
'''An ndarray whose in-place addition always raises KeyboardInterrupt,
202+
used to simulate a CTRL-C occurring mid weight-update.'''
203+
def __iadd__(self, other):
204+
raise KeyboardInterrupt
205+
206+
207+
class TestAdjustWeightsInterrupt:
208+
'''Tests `Perceptron._adjust_weights`'s own KeyboardInterrupt handling
209+
directly, isolated from the full `train` loop.'''
210+
211+
def _make_perceptron_with_raising_weights(self):
212+
np.random.seed(0)
213+
p = Perceptron([2, 1])
214+
layer = p.layers[0]
215+
layer.dW = np.ones_like(layer.weights) * 0.1
216+
original_weights = np.array(layer.weights)
217+
layer.weights = layer.weights.view(RaisingArray)
218+
return (p, layer, original_weights)
219+
220+
def test_restores_previous_weights_when_caching(self):
221+
(p, layer, original_weights) = \
222+
self._make_perceptron_with_raising_weights()
223+
out = io.StringIO()
224+
with pytest.raises(KeyboardInterrupt):
225+
p._adjust_weights(0.3, 0.0, 1, out)
226+
assert_allclose(np.asarray(layer.weights), original_weights)
227+
assert_allclose(layer.dW, 0)
228+
assert 'Restoring previous weights' in out.getvalue()
229+
230+
def test_leaves_weights_when_caching_disabled(self):
231+
(p, layer, original_weights) = \
232+
self._make_perceptron_with_raising_weights()
233+
p.cache_weights = False
234+
out = io.StringIO()
235+
with pytest.raises(KeyboardInterrupt):
236+
p._adjust_weights(0.3, 0.0, 1, out)
237+
assert 'cacheing was disabled' in out.getvalue()

0 commit comments

Comments
 (0)