|
| 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