diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 429bb80..f5715af 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1,6 +1,7 @@ on: push: branches: ["main", "dev", "v*"] + pull_request: name: build @@ -9,14 +10,17 @@ jobs: strategy: matrix: python: ["3.7", "3.10"] + extras: ["", "[RANSAC, qrandom, plots]"] runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python }} - - run: pip install . + - run: pip install ".${{ matrix.extras }}" - run: pip install codecov . - run: coverage run -m unittest nolds.test_measures - run: codecov - if: ${{ matrix.python == '3.10' }} + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + if: ${{ matrix.python == '3.10' && matrix.extras != '' }} diff --git a/.gitignore b/.gitignore index d555c2d..dc96a68 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,4 @@ _build # virtual environment .venv +.venv* diff --git a/CHANGELOG.md b/CHANGELOG.md index daa057c..21c291c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,18 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Changed ### Fixed +## [0.6.1] + +### Added + +* Regression tests for all major algorithms that check for small changes in the main output value. + +### Changed + +* Nolds now supports numpy 2.x as well as 1.x. + +### Fixed + ## [0.6.0] ### Added @@ -221,7 +233,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Hurst exponent (`hurst_rs`) - detrended fluctuation analysis (DFA) (`dfa`) -[Unreleased]: https://github.com/CSchoel/nolds/compare/0.6.0..HEAD +[Unreleased]: https://github.com/CSchoel/nolds/compare/0.6.1..HEAD +[0.6.1]: https://github.com/CSchoel/nolds/compare/0.6.0..0.6.1 [0.6.0]: https://github.com/CSchoel/nolds/compare/0.5.2..0.6.0 [0.5.2]: https://github.com/CSchoel/nolds/compare/0.5.1..0.5.2 [0.5.1]: https://github.com/CSchoel/nolds/compare/0.5.0..0.5.1 diff --git a/doc/source/conf.py b/doc/source/conf.py index 1c6a6e7..cfeec19 100644 --- a/doc/source/conf.py +++ b/doc/source/conf.py @@ -64,7 +64,7 @@ # The short X.Y version. version = '0.6' # The full version, including alpha/beta/rc tags. -release = '0.6.0' +release = '0.6.1' # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. diff --git a/nolds/datasets.py b/nolds/datasets.py index 739403c..65489a9 100644 --- a/nolds/datasets.py +++ b/nolds/datasets.py @@ -23,15 +23,19 @@ def lorenz_euler(length, sigma, rho, beta, dt=0.01, start=[1,1,1]): """ def lorenz(state, sigma, rho, beta): x, y, z = state + # NOTE: Numpy 1.x stores intermediate results as float64 + # => to achieve consistency between numpy versions, we have to use + # float32 for all values that enter the formula to simulate numpy 1.x + # behavior with numpy 2.x. return np.array([ - sigma * (y - x), - rho * x - y - x * z, - x * y - beta * z + np.float32(sigma) * (y - x), + np.float32(rho) * x - y - x * z, + x * y - np.float32(beta) * z ], dtype="float32") trajectory = np.zeros((length, 3), dtype="float32") trajectory[0] = start for i in range(1, length): - t = i * dt + # t = i * dt trajectory[i] = trajectory[i-1] + lorenz(trajectory[i-1], sigma, rho, beta) * dt return trajectory diff --git a/nolds/measures.py b/nolds/measures.py index 153df68..74dd4b1 100644 --- a/nolds/measures.py +++ b/nolds/measures.py @@ -1442,7 +1442,7 @@ def mfhurst_b(data, qvals=None, dists=None, fit='poly', Essentially, we can calculate c_q(d) of a discrete evenly sampled time series Y = [y_0, y_1, y_2, ... y_(N-1)] by taking the absolute differences - [|y_0 - y_d|, |y_1 - y_(d+1)|, ... , |y_(N-d-1) - y_(N-1)|] raising them to + [\|y_0 - y_d\|, \|y_1 - y_(d+1)\|, ... , \|y_(N-d-1) - y_(N-1)\|] raising them to the qth power and taking the mean. Now we take the logarithm on both sides of our relation c_q(d) ~ d^(q H_q) diff --git a/nolds/test_measures.py b/nolds/test_measures.py index 8f4fded..28a1acf 100644 --- a/nolds/test_measures.py +++ b/nolds/test_measures.py @@ -36,7 +36,7 @@ def assert_array_equals(self, expected, actual, print_arrays=False): print("==") print(expected) print() - self.assertTrue(np.alltrue(actual == expected)) + self.assertTrue(np.all(actual == expected)) def test_delay_embed_lag2(self): data = np.arange(10, dtype="float32") @@ -459,7 +459,7 @@ def test_lorenz(self): x = data[discard:,1] rvals = nolds.logarithmic_r(1, np.e, 1.1) # determined experimentally cd = nolds.corr_dim(x, emb_dim, fit="poly", rvals=rvals, lag=lag) - self.assertAlmostEqual(cd, 2.05, delta=0.1) + self.assertAlmostEqual(cd, 2.05, delta=0.2) def test_logistic(self): # TODO replicate tests with logistic map from grassberger-procaccia @@ -544,5 +544,132 @@ def test_sampen_lorenz(self): self.assertAlmostEqual(0.25, sz, delta=0.05) +class RegressionTests(unittest.TestCase): + """Regression tests for main algorithms. + + These tests are here to safeguard against accidental algorithmic changes such + as updates to core dependencies such as numpy or the Python standard library. + """ + + def test_sampen(self): + """Test hypothesis: The exact output of sampen() on random data hasn't changed since the last version.""" + data = datasets.load_qrandom()[:1000] + se = nolds.sampen(data, emb_dim=2, tolerance=None, lag=1, dist=nolds.rowwise_chebyshev, closed=False) + self.assertAlmostEqual(2.1876999522832743, se, places=15) + + def test_corr_dim(self): + """Test hypothesis: The exact output of corr_dim() with `fit=poly` on random data hasn't changed since the last version.""" + data = datasets.load_qrandom()[:1000] + cd = nolds.corr_dim(data, emb_dim=5, lag=1, rvals=None, dist=nolds.rowwise_euclidean, fit="poly") + self.assertAlmostEqual(1.303252839255068, cd, places=15) + + @unittest.skipUnless(SCIPY_AVAILABLE, "Tests with RANSAC require scipy.") + def test_corr_dim_RANSAC(self): + """Test hypothesis: The exact output of corr_dim() with `fit=RANSAC` on random data hasn't changed since the last version.""" + data = datasets.load_qrandom()[:1000] + sd = np.std(data, ddof=1) + # fix seed + np.random.seed(42) + # usa a too wide range for rvals to give RANSAC something to do ;) + rvals = nolds.logarithmic_r(0.01 * sd, 2 * sd, 1.03) + cd = nolds.corr_dim(data, emb_dim=5, lag=1, rvals=rvals, dist=nolds.rowwise_euclidean, fit="RANSAC") + self.assertAlmostEqual(0.44745494643404665, cd, places=15) + + def test_lyap_e(self): + """Test hypothesis: The exact output of lyap_e() on random data hasn't changed since the last version.""" + data = datasets.load_qrandom()[:1000] + le = nolds.lyap_e(data, emb_dim=10, matrix_dim=4, min_nb=10, min_tsep=1, tau=1) + expected = np.array([ 0.03779942603329712, -0.014314012551504982, -0.08436867977030214, -0.22316730257003717]) + for i in range(le.shape[0]): + self.assertAlmostEqual(expected[i], le[i], places=15, msg=f"{i+1}th Lyapunov exponent doesn't match") + + def test_lyap_r(self): + """Test hypothesis: The exact output of lyap_r() with `fit=poly` on random data hasn't changed since the last version.""" + data = datasets.load_qrandom()[:1000] + le = nolds.lyap_r(data, emb_dim=10, lag=1, min_tsep=1, tau=1, min_neighbors=10, trajectory_len=10, fit="poly") + expected = 0.094715945307378 + self.assertAlmostEqual(expected, le, places=15) + + @unittest.skipUnless(SCIPY_AVAILABLE, "Tests with RANSAC require scipy.") + def test_lyap_r_RANSAC(self): + """Test hypothesis: The exact output of lyap_r() with `fit=RANSAC` on random data hasn't changed since the last version.""" + data = datasets.load_qrandom()[:1000] + np.random.seed(42) + # set lag to 2 for weird duplicate lines + # set trajectory_len to 100 to get many datapoints for RANSAC to choose from + le = nolds.lyap_r(data, emb_dim=10, lag=2, min_tsep=1, tau=1, min_neighbors=10, trajectory_len=100, fit="RANSAC") + expected = 0.0003401212353253564 + self.assertAlmostEqual(expected, le, places=15) + + def test_hurst_rs(self): + """Test hypothesis: The exact output of hurst_rs() with `fit=poly` on random data hasn't changed since the last version.""" + data = datasets.load_qrandom()[:1000] + rs = nolds.hurst_rs(data, nvals=None, fit="poly", corrected=True, unbiased=True) + expected = 0.5123887964986258 + self.assertAlmostEqual(expected, rs, places=15) + + @unittest.skipUnless(SCIPY_AVAILABLE, "Tests with RANSAC require scipy.") + def test_hurst_rs_RANSAC(self): + """Test hypothesis: The exact output of hurst_rs() with `fit=RANSAC` on random data hasn't changed since the last version.""" + data = datasets.load_qrandom()[:1000] + np.random.seed(42) + # increase nsteps in nvals to have more data points for RANSAC to choose from + nvals = nolds.logmid_n(data.shape[0], ratio=1/4.0, nsteps=100) + rs = nolds.hurst_rs(data, nvals=nvals, fit="RANSAC", corrected=True, unbiased=True) + expected = 0.4805431939943321 + self.assertAlmostEqual(expected, rs, places=15) + + def test_dfa(self): + """Test hypothesis: The exact output of dfa() with `fit_exp=poly` on random data hasn't changed since the last version.""" + data = datasets.load_qrandom()[:1000] + h = nolds.dfa(data, nvals=None, overlap=True, order=1, fit_trend="poly", fit_exp="poly") + expected = 0.5450874638765073 + self.assertAlmostEqual(expected, h, places=15) + + @unittest.skipUnless(SCIPY_AVAILABLE, "Tests with RANSAC require scipy.") + def test_dfa_RANSAC(self): + """Test hypothesis: The exact output of dfa() with `fit_exp=RANSAC` on random data hasn't changed since the last version.""" + # adds trend to data to introduce a less clear line for fitting + data = datasets.load_qrandom()[:1000] + np.arange(1000) * 100 + np.random.seed(42) + # adds more steps and higher values to nvals to introduce some scattering for RANSAC to have an effect on + nvals = nolds.logarithmic_n(10, 0.9 * data.shape[0], 1.1) + h = nolds.dfa(data, nvals=nvals, overlap=True, order=1, fit_trend="poly", fit_exp="RANSAC") + expected = 1.1372303125405405 + self.assertAlmostEqual(expected, h, places=15) + + def test_mfhurst_b(self): + """Test hypothesis: The exact output of mfhurst_b() with `fit=poly` on random data hasn't changed since the last version.""" + data = datasets.load_qrandom()[:1000] + h = nolds.mfhurst_b(data, qvals=[1], dists=None, fit="poly") + expected = [-0.00559398934417339] + self.assertAlmostEqual(expected[0], h[0], places=15) + + @unittest.skipUnless(SCIPY_AVAILABLE, "Tests with RANSAC require scipy.") + def test_mfhurst_b_RANSAC(self): + """Test hypothesis: The exact output of mfhurst_b() with `fit=RANSAC` on random data hasn't changed since the last version.""" + data = datasets.load_qrandom()[:1000] + np.random.seed(42) + h = nolds.mfhurst_b(data, qvals=[1], dists=None, fit="RANSAC") + expected = [-0.009056463064211057] + self.assertAlmostEqual(expected[0], h[0], places=15) + + def test_mfhurst_dm(self): + """Test hypothesis: The exact output of mfhurst_dm() with `fit=poly` on random data hasn't changed since the last version.""" + data = datasets.load_qrandom()[:1000] + h, _ = nolds.mfhurst_dm(data, qvals=[1], max_dists=range(5, 20), detrend=True, fit="poly") + expected = [0.008762803881203145] + self.assertAlmostEqual(expected[0], h[0], places=15) + + @unittest.skipUnless(SCIPY_AVAILABLE, "Tests with RANSAC require scipy.") + def test_mfhurst_dm_RANSAC(self): + """Test hypothesis: The exact output of mfhurst_dm() with `fit=RANSAC` on random data hasn't changed since the last version.""" + data = datasets.load_qrandom()[:1000] + np.random.seed(42) + h, _ = nolds.mfhurst_dm(data, qvals=[1], max_dists=range(5, 20), detrend=True, fit="RANSAC") + expected = [0.005324834328837356] + self.assertAlmostEqual(expected[0], h[0], places=15) + + if __name__ == "__main__": unittest.main() diff --git a/setup.py b/setup.py index 9bd360a..27741dc 100644 --- a/setup.py +++ b/setup.py @@ -22,7 +22,7 @@ def run(self): with io.open("README.rst", "r", encoding="utf-8") as f: readme = f.read() -version = '0.6.0' +version = '0.6.1' setup( name='nolds', packages=['nolds'], @@ -57,12 +57,12 @@ def run(self): ], test_suite='nolds.test_measures', install_requires=[ - 'numpy<2.0', + 'numpy>1.0,<3.0', 'future', 'setuptools' ], extras_require={ - 'RANSAC': ['sklearn>=0.19'], + 'RANSAC': ['scikit-learn>=0.19'], 'qrandom': ['quantumrandom'], 'plots': ['matplotlib'] },