diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..60404dc --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.ipynb linguist-documentation \ No newline at end of file diff --git a/.gitignore b/.gitignore index 5025358..b953c5e 100644 --- a/.gitignore +++ b/.gitignore @@ -94,3 +94,6 @@ venv.bak/ # Changelog entry ENTRY.md + +# Jupyter Notebook checkpoints +*.ipynb_checkpoints/ diff --git a/README.md b/README.md index df0fd0c..f5d4338 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ About · Build Status · Features · + Installation · Documentation · Examples · Acknowledgments · @@ -96,6 +97,63 @@ Parameter estimation with the Baum-Welch algorithm and prediction with the forwa In most cases, the only necessary change is to add a `lengths` key-word argument to provide sequence length information, e.g. `fit(X, y, lengths=lengths)` instead of `fit(X, y)`. +### Similar libraries + +As DTW k-nearest neighbors is the core algorithm offered by Sequentia, below is a comparison of the DTW k-nearest neighbors algorithm features supported by Sequentia and similar libraries. + +||**`sequentia`**|[`aeon`](https://github.com/aeon-toolkit/aeon)|[`tslearn`](https://github.com/tslearn-team/tslearn)|[`sktime`](https://github.com/sktime/sktime)|[`pyts`](https://github.com/johannfaouzi/pyts)| +|-|:-:|:-:|:-:|:-:|:-:| +|Scikit-Learn compatible|✅|✅|✅|✅|✅| +|Multivariate sequences|✅|✅|✅|✅|❌| +|Variable length sequences|✅|✅|➖1|❌2|❌3| +|No padding required|✅|❌|➖1|❌2|❌3| +|Classification|✅|✅|✅|✅|✅| +|Regression|✅|✅|✅|✅|❌| +|Preprocessing|✅|✅|✅|✅|✅| +|Multiprocessing|✅|✅|✅|✅|✅| +|Custom weighting|✅|✅|✅|✅|✅| +|Sakoe-Chiba band constraint|✅|✅|✅|✅|✅| +|Itakura paralellogram constraint|❌|✅|✅|✅|✅| +|Dependent DTW (DTWD)|✅|✅|✅|✅|❌| +|Independent DTW (DTWI)|✅|❌|❌|❌|✅| +|Custom DTW measures|❌4|✅|❌|✅|✅| + +- 1`tslearn` supports variable length sequences with padding, but doesn't seem to mask the padding. +- 2`sktime` does not support variable length sequences, so they are padded (and padding is not masked). +- 3`pyts` does not support variable length sequences, so they are padded (and padding is not masked). +- 4`sequentia` only supports [`dtaidistance`](https://github.com/wannesm/dtaidistance), which is one of the fastest DTW libraries as it is written in C. + +### Benchmarks + +To compare the above libraries in runtime performance on dynamic time warping k-nearest neighbors classification tasks, a simple benchmark was performed on a univariate sequence dataset. + +The [Free Spoken Digit Dataset](https://sequentia.readthedocs.io/en/latest/sections/datasets/digits.html) was used for benchmarking and consists of: + +- 3000 recordings of 10 spoken digits (0-9) + - 50 recordings of each digit for each of 6 speakers + - 1500 used for training, 1500 used for testing (split via label stratification) +- 13 features ([MFCCs](https://en.wikipedia.org/wiki/Mel-frequency_cepstrum)) + - Only the first feature was used as not all of the above libraries support multivariate sequences +- Sequence length statistics: + - Minimum: 6 + - Median: 17 + - Maximum: 92 + +Each result measures the total time taken to complete training and prediction repeated 10 times. + +All of the above libraries support multiprocessing, and prediction was performed using 16 workers. + +*: `sktime`, `tslearn` and `pyts` seem to not mask padding, which may result in incorrect predictions. + + + +> **Device information**: +> - Product: ThinkPad T14s (Gen 6) +> - Processor: AMD Ryzen™ AI 7 PRO 360 (8 cores, 16 threads, 2-5GHz) +> - Memory: 64 GB LPDDR5X-7500MHz +> - Solid State Drive: 1 TB SSD M.2 2280 PCIe Gen4 Performance TLC Opal +> - Operating system: Fedora Linux 41 (Workstation Edition) + ## Installation The latest stable version of Sequentia can be installed with the following command: @@ -169,7 +227,13 @@ lengths = np.array([3, 5, 2]) # Sequence classes y = np.array([0, 1, 1]) -# Create a transformation pipeline that feeds into a KNNClassifier +# Train and predict (without preprocessing) +clf = KNNClassifier(k=1) +clf.fit(X, y, lengths=lengths) +y_pred = clf.predict(X, lengths=lengths) +acc = pipeline.score(X, y, lengths=lengths) + +# Create a preprocessing pipeline that feeds into a KNNClassifier # 1. Individually denoise each sequence by applying a median filter for each feature # 2. Individually standardize each sequence by subtracting the mean and dividing the s.d. for each feature # 3. Reduce the dimensionality of the data to a single feature by using PCA diff --git a/benchmarks/__init__.py b/benchmarks/__init__.py new file mode 100644 index 0000000..f8f49c1 --- /dev/null +++ b/benchmarks/__init__.py @@ -0,0 +1,8 @@ +# Copyright (c) 2019 Sequentia Developers. +# Distributed under the terms of the MIT License (see the LICENSE file). +# SPDX-License-Identifier: MIT +# This source code is part of the Sequentia project (https://github.com/eonu/sequentia). + +"""Collection of runtime benchmarks for Python packages +providing dynamic time warping k-nearest neighbors algorithms. +""" diff --git a/benchmarks/benchmark.svg b/benchmarks/benchmark.svg new file mode 100644 index 0000000..3f9a775 --- /dev/null +++ b/benchmarks/benchmark.svg @@ -0,0 +1,1621 @@ + + + + + + + + 2024-12-24T17:13:37.655962 + image/svg+xml + + + Matplotlib v3.10.0, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/benchmarks/plot.ipynb b/benchmarks/plot.ipynb new file mode 100644 index 0000000..9cf0eb1 --- /dev/null +++ b/benchmarks/plot.ipynb @@ -0,0 +1,104 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 2, + "id": "ed902379-677e-4c90-aa1c-95ef9dbb1d11", + "metadata": {}, + "outputs": [], + "source": [ + "import matplotlib.pyplot as plt\n", + "import numpy as np\n", + "\n", + "plt.style.use(\"ggplot\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c92bf960-ddb5-409f-bd3c-5bce0a03ccd0", + "metadata": {}, + "outputs": [], + "source": [ + "from sequentia import" + ] + }, + { + "cell_type": "code", + "execution_count": 79, + "id": "6649bf2d-7430-401d-8113-f3c1e1cf4779", + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "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RERUWVWoKxbHjx8H3j8EL6/Ro0fDy8sLKioquHv3LoKDg5GRkQFjY2M0atQIPXv2FMsqKSlh+vTp2LhxI2bNmgV1dXV4enrKPPfCzMwM06dPR2BgIIKDg2FsbIxRo0bxGRZERERERHKSCJXl2koFFBcXJzNa1OdKIpHAwsIC0dHRleZSHZUvthmSB9sNyYPtpvLotv1heYdQYe0f4FRudauqqpb4HosK1RWKiIiIiIgqJyYWRERERESkMCYWRERERESkMCYWRERERESkMCYWRERERESkMCYWRERERESkMCYWRERERESkMCYWRERERESkMCYWRERERESkMCYWRERERESkMCYWRERERESkMCYWRERERESkMCYWRERERESkMCYWRERERESkMCYWRERERESkMCYWRERERESkMCYWRERERESkMCYWRERERESkMCYWRERERESkMCYWRERERESkMCYWRERERESkMCYWRERERESkMCYWRERERESkMCYWRERERESkMCYWRERERESkMCYWRERERESkMCYWRERERESkMCYWRERERESkMCYWRERERESkMCYWRERERESkMCYWRERERESkMCYWRERERESkMCYWRERERESkMCYWRERERESkMCYWRERERESkMCYWRERERESkMCYWRERERESkMCYWRERERESkMCYWRERERESkMCYWRERERESkMCYWRERERESkMJXyDiCvvXv34sqVK4iKioKamhocHR0xcOBAWFpaimUyMzOxdetWXLp0CVlZWXB3d8eIESNgYGAglomPj8eGDRsQEhICDQ0NeHp6on///lBWVhbLhISEYOvWrXjx4gWMjY3Rq1cveHl5/ef7TERERET0KahQVyzu378PHx8fBAQEYNasWcjOzsaCBQuQnp4ulgkMDMT169cxefJkzJs3D4mJiVi+fLm4PCcnB4sWLYJUKsWCBQswZswYnD17Fjt37hTLxMbGYvHixXBxccHSpUvRqVMnrFu3Drdu3frP95mIiIiI6FNQoRILf39/eHl5oVq1arCxscGYMWMQHx+PsLAwAEBaWhpOnz6NwYMHw9XVFXZ2dhg9ejQePXqE0NBQAMDt27cRGRmJcePGwcbGBh4eHvDz88OxY8cglUoBAMePH4eZmRkGDRoEKysrtG/fHo0bN8bhw4fLdf+JiIiIiCqrCtUV6kNpaWkAAB0dHQBAWFgYsrOz4ebmJpapWrUqTExMEBoaCkdHR4SGhsLa2lqma1SdOnWwceNGvHjxAra2tnj8+LHMNgDA3d0dW7ZsKTCOrKwsZGVlidMSiQSampri35+73GPAY0ElxTZD8mC7IXmw3dCnoLK03wqbWOTk5GDLli2oWbMmrK2tAQBJSUlQUVGBtra2TFl9fX0kJSWJZfImFbnLc5fl/s6dl7fMu3fvkJmZCTU1NZlle/fuxe7du8VpW1tbLFmyBKampmW6z5Wdubl5eYdAlQzbDMmD7YbkwXZTGTwo7wAqLAsLi/IOoUQqbGKxadMmvHjxAvPnzy/vUNCjRw907txZnM7NGuPi4sTuVZ8ziUQCc3NzxMTEQBCE8g6HKgG2GZIH2w3Jg+2GPgXR0dHlVreKikqJv0yvkInFpk2bcOPGDcybNw/GxsbifAMDA0ilUqSmpspctXjz5o14lcLAwABPnjyR2d6bN2/EZbm/c+flLaOpqZnvagUAqKqqQlVVtcBYeZL6H0EQeDyoVNhmSB5sNyQPthuqzCpL261QN28LgoBNmzbhypUrmD17NszMzGSW29nZQVlZGXfv3hXnvXz5EvHx8XB0dAQAODo6IiIiQiZxuHPnDjQ1NWFlZQUAcHBwkNlGbpncbRARERERUelUqMRi06ZNOH/+PCZMmABNTU0kJSUhKSkJmZmZAAAtLS20bt0aW7duxb179xAWFoa1a9fC0dFRTArc3d1hZWWF1atXIzw8HLdu3cKOHTvg4+MjXnVo164dYmNjsW3bNkRFReHYsWO4fPkyOnXqVK77T0RERERUWUmECnRtxdfXt8D5o0ePFh9el/uAvIsXL0IqlRb4gLy4uDhs3LgRISEhUFdXh6enJwYMGJDvAXmBgYGIjIyU+wF5cXFxMqNFfa4kEgksLCwQHR1daS7VUflimyF5sN2QPNhuKo9u2x+WdwgV1v4BTuVWt6qqaonvsahQiUVlw8Ti//GkTaXFNkPyYLshebDdVB5MLApXWRKLCtUVioiIiIiIKicmFkREREREpDAmFkREREREpDAmFkREREREpLBSPyAvNjYW165dw8OHDxEVFYXk5GRIJBLo6uqiatWqcHJyQv369fM9g4KIiIiIiD5dJU4srl+/joMHD+Lhw4cQBAHm5uYwMzNDtWrVAACpqal4/vw5/v33XwQGBsLJyQldu3ZFvXr1Pmb8RERERERUAZQosfD390d4eDgaNGiASZMmwc3NDVpaWgWWTUtLw507d/DPP//gxx9/RPXq1REQEFDWcRMRERERUQVSosTCxcUFU6dOlXkIXWG0tLTQuHFjNG7cGElJSQgODi6LOImIiIiIqAIrUWLRv39/uTZuYGAg97pERERERFR5cFQoIiIiIiJSWKlHhQKAu3fv4tmzZ+jatas47/Tp0wgKCoJUKkWzZs0waNAgKCkxbyEiIiIi+hzI9ck/KCgI4eHh4nRERAQ2bNgAPT091KpVC0eOHMGBAwfKMk4iIiIiIqrA5EosoqKiYG9vL06fO3cOmpqamD9/PiZNmoQ2bdrg3LlzZRknERERERFVYHIlFunp6dDU1BSnb926hTp16kBdXR0AUKNGDcTFxZVdlEREREREVKHJlViYmJjg6dOnAICYmBi8ePECtWvXFpenpKRAVVW17KIkIiIiIqIKTa6bt5s3b47du3cjISEBkZGR0NbWRoMGDcTlYWFhsLCwKMs4iYiIiIioApMrsejZsyekUilu3rwJExMTjB49Gtra2sD7qxUhISHo2LFjWcdKREREREQVlFyJhbKyMvr164d+/frlW6ajo4MNGzaURWxERERERFRJ8EETRERERESksBIlFr/++itiY2NLvfGYmBj8+uuv8sRFRERERESVSIm6Qr1+/RoTJkyAm5sbmjZtCldXV5iYmBRYNjY2Fnfv3sXly5cREhIiM1oUERERERF9mkqUWMyYMQMPHz7EwYMHsX79euTk5EBXVxempqbQ0dGBIAhITU1FbGwsUlJSoKSkBA8PD8yZMwdOTk4ffy+IiIiIiKhclfjmbScnJzg5OSE5ORnXr19HaGgoXr58idevXwMAdHV10bBhQzg6OqJu3brQ19f/mHETEREREVEFUupRofT09NCqVSu0atXq40RERERERESVDkeFIiIiIiIihTGxICIiIiIihTGxICIiIiIihTGxICIiIiIihTGxICIiIiIihTGxICIiIiIihZV6uNm8QkNDERISgjdv3sDHxwcWFhbIyMhAVFQULC0toaGhUXaREhERERFRhSVXYiGVSvHTTz/h6tWr4rz69evDwsICEokEAQEB6NSpE3r27FmWsRIRERERUQUlV1eoHTt24Pr16xg5ciR++uknmWVqampo3LixTNJB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bRrq168PDQ0NDB06tEyHel25ciVWrFghTtvb26NJkybi8PpF1f/27VvMnz8fz58/h6qqKurWrYvFixeXWWxElZFEEAShtCuNGzcO3t7e6Nq168eJqpKIi4tDVlZWeYdR7iQSCSwsLBAdHQ05mhN9hthmSB5sNyQPtpvKo9v2h+UdQoW1f8B/0wWwIKqqqjA1NS1RWbmevN2tWzecOnUKaWlp8qxORERERESfGLm6QqWnp0NDQwPjx49H06ZNYWJiIo7ikFfnzp3LIkYiIiIiIqrg5Eosfv/9d/HvY8eOFVqOiQUREdHnqWJ1a3lQ3gGIyrNLC9HHJldisXr16rKPhIiIiIiIKi25EouS3sBBRERERESfB7lu3iYiIiIiIspLrisWY8aMgUQiKbKMRCLBzz//LG9cRERERERUiciVWNSqVStfYpGTk4O4uDg8evQI1apVg62tbVnFSEREREREFZzcVywKEx4ejoCAADRv3lyRuIiIiIiIqBIp83ssbGxs4O3tje3bt5f1pomIiIiIqIL6KDdv6+vrIzIy8mNsmoiIiIiIKqAyTyzevn2L06dPw9jYuKw3TUREREREFZRc91jMmzevwPlpaWmIioqCVCrF2LFjFY2NiIiIiIgqCbkSC0EQChxu1tTUFG5ubmjVqhWqVq1aFvEREREREVElIFdiMXfu3GLLFJZ8EBERERHRp6fM77GQSqU4efIkJk6cWNabJiIiIiKiCqpUVyykUimuXbuGmJgY6OjooG7dujAyMgIAZGRk4OjRowgODkZSUhKqVKnysWImIiIiIqIKpsSJRUJCAubNm4eYmBhxnpqaGr799luoqKhg1apVSEhIQI0aNTB06FA0atToY8VMREREREQVTIkTix07diA2NhbdunWDk5MTYmNj8ddff+HXX39FcnIyqlWrhnHjxqFWrVofN2IiIiIiIqpwSpxY3LlzB15eXujfv784z8DAAD/++CM8PDzw7bffQknpozxvj4iIiIiIKrgSJxZv3ryBg4ODzDxHR0cAQOvWrcskqbh//z4OHDiAZ8+eITExEVOmTEHDhg3F5YIgYNeuXTh16hRSU1Ph5OSEESNGwMLCQiyTkpKCzZs34/r165BIJGjUqBGGDh0KDQ0Nsczz58+xadMmPH36FHp6emjfvj26deumcPxERERERJ+rEmcDOTk5UFNTk5mn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D1FB2GYql4rVFapiYVeowrHdFIxtpnBsM4Urz3ajqqoqc+9kUdgVioiIiIiIFMbEgoiIiIiIFMZ7LIg+IxXrMjNH9yEiIvqU8IoFEREREREpjIkFEREREREpjIkFEREREREpjIkFEREREREpjIkFEREREREpjIkFEREREREpjIkFEREREREpjIkFEREREREpjIkFEREREREpjIkFEREREREpjIkFEREREREpjIkFEREREREpjIkFEREREREpjIkFEREREREpjIkFEREREREpjIkFEREREREpjIkFEREREREpjIkFEREREREpjIkFEREREREpjIkFEREREREpjIkFEREREREpjIkFEREREREpjIkFEREREREpjIkFEREREREpjIkFEREREREpjIkFEREREREpjIkFEREREREpjIkFEREREREpTKW8AyiNXbt2Yffu3TLzLC0t8dNPPwEAMjMzsXXrVly6dAlZWVlwd3fHiBEjYGBgIJaPj4/Hhg0bEBISAg0NDXh6eqJ///5QVlb+z/eHiIiIiOhTUakSCwCoVq0avvvuO3FaSel/F10CAwNx48YNTJ48GVpaWti0aROWL1+O77//HgCQk5ODRYsWwcDAAAsWLEBiYiJWr14NZWVl9O/fv1z2h4iIiIjoU1DpukIpKSnBwMBA/NHT0wMApKWl4fTp0xg8eDBcXV1hZ2eH0aNH49GjRwgNDQUA3L59G5GRkRg3bhxsbGzg4eEBPz8/HDt2DFKptJz3jIiIiIio8qp0iUVMTAy++uorjB07FqtWrUJ8fDwAICwsDNnZ2XBzcxPLVq1aFSYmJmJiERoaCmtra5muUXXq1MG7d+/w4sWLctgbIiIiIqJPQ6XqCuXg4IDRo0fD0tISiYmJ2L17N2bPno3ly5cjKSkJKioq0NbWlllHX18fSUlJAICkpCSZpCJ3ee6ywmRlZSErK0uclkgk0NTUFP/+3OUeAx4LqszYfkkebDdUWmwzJI/K0m4qVWLh4eEh/l29enUx0bh8+TLU1NQ+Wr179+6VuWnc1tYWS5Ysgamp6UerszIyNzcv7xCoWA/KO4AKy8LCorxDqMDYbgrDdlMUtpuCsM0UhW2mMJWl3VSqxOJD2trasLS0RExMDGrXrg2pVIrU1FSZqxZv3rwRr1IYGBjgyZMnMtt48+aNuKwwPXr0QOfOncXp3Kwx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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "fig, ax = plt.subplots(figsize=(8, 4))\n", + "\n", + "runtimes = [31.871, 828.855, 887.367, 1210.012, 2778.706]\n", + "labels = [\"sequentia\", \"sktime*\", \"aeon\", \"tslearn*\", \"pyts*\"]\n", + "\n", + "bars = ax.bar(labels, runtimes, width=0.5, color=\"C1\")\n", + "ax.set(xlabel=\"Package\", ylabel=\"Runtime (s)\")\n", + "ax.set_title(\"Univariate DTW-kNN performance (1,500 FSDD train/test sequences, 16 workers)\", fontsize=11)\n", + "\n", + "def fmt(s: float) -> str:\n", + " if s < 60:\n", + " return f\"{round(s)}s\"\n", + " m, s = divmod(s, 60)\n", + " return f\"{round(m)}m {round(s)}s\"\n", + "\n", + "for bar in bars:\n", + " plt.text(\n", + " bar.get_x() + bar.get_width() / 2, bar.get_height(),\n", + " fmt(bar.get_height()), ha='center', va='bottom', fontsize=9,\n", + " )\n", + "\n", + "for lab in ax.get_xticklabels():\n", + " if lab.get_text() == \"sequentia\":\n", + " lab.set_fontweight('bold')\n", + "\n", + "plt.tight_layout()\n", + "plt.savefig(\"benchmark.svg\")\n", + "plt.show()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "07aeb22f-d8be-4759-9012-1a3e9479343a", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.8" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/benchmarks/requirements.txt b/benchmarks/requirements.txt new file mode 100644 index 0000000..1353452 --- /dev/null +++ b/benchmarks/requirements.txt @@ -0,0 +1,6 @@ +# python==3.12.8 +sequentia==2.1.0 +aeon==1.0.0 +tslearn==0.6.3 +sktime==0.35.0 +pyts==0.13.0 diff --git a/benchmarks/run.sh b/benchmarks/run.sh new file mode 100755 index 0000000..ed732a8 --- /dev/null +++ b/benchmarks/run.sh @@ -0,0 +1,19 @@ +echo "sequentia" +python test_sequentia.py --n-jobs 16 --number 10 +echo + +echo "aeon" +python test_aeon.py --n-jobs 16 --number 10 +echo + +echo "tslearn" +python test_tslearn.py --n-jobs 16 --number 10 +echo + +echo "sktime" +python test_sktime.py --n-jobs 16 --number 10 +echo + +echo "pyts" +python test_pyts.py --n-jobs 16 --number 10 +echo diff --git a/benchmarks/test_aeon.py b/benchmarks/test_aeon.py new file mode 100644 index 0000000..a03f13e --- /dev/null +++ b/benchmarks/test_aeon.py @@ -0,0 +1,82 @@ +# Copyright (c) 2019 Sequentia Developers. +# Distributed under the terms of the MIT License (see the LICENSE file). +# SPDX-License-Identifier: MIT +# This source code is part of the Sequentia project (https://github.com/eonu/sequentia). + +"""Runtime benchmarks for aeon's dynamic time warping +k-nearest neighbors algorithm. +""" + +from __future__ import annotations + +import timeit +import typing as t + +import numpy as np +from aeon.classification.distance_based import KNeighborsTimeSeriesClassifier +from aeon.transformations.collection import Padder +from dtaidistance import dtw_ndim +from utils import load_dataset + +from sequentia.datasets.base import SequentialDataset + +np.random.seed(0) +random_state: np.random.RandomState = np.random.RandomState(0) + +DataSplit: t.TypeAlias = tuple[np.ndarray, np.ndarray] + + +def distance(s1: np.ndarray, s2: np.ndarray) -> float: + """DTAIDistance DTW measure - not used.""" + # need to transpose sequences again + return dtw_ndim.distance(s1.T, s2.T, use_c=True) + + +def prepare(data: SequentialDataset) -> DataSplit: + """Prepare the dataset - padding.""" + # transpose sequences and pad + X = [x.T for x, _ in data] + padder = Padder() + X_pad = padder.fit_transform(X) + # X_pad = X_pad.astype("float64") + return X_pad, data.y + + +def run(*, train_data: DataSplit, test_data: DataSplit, n_jobs: int) -> None: + """Fit and predict the classifier.""" + # initialize model + clf = KNeighborsTimeSeriesClassifier( + n_neighbors=1, + n_jobs=n_jobs, + distance="dtw", + # distance=distance, + ) + + # fit model + X_train, y_train = train_data + clf.fit(X_train, y_train) + + # predict model + X_test, _ = test_data + clf.predict(X_test) + + +if __name__ == "__main__": + import argparse + + parser: argparse.ArgumentParser = argparse.ArgumentParser() + parser.add_argument("--n-jobs", type=int, default=1) + parser.add_argument("--number", type=int, default=10) + args: argparse.Namespace = parser.parse_args() + + train_data, test_data = load_dataset(multivariate=False) + train_data, test_data = prepare(train_data), prepare(test_data) + + benchmark = timeit.timeit( + "run(train_data=train_data, test_data=test_data, n_jobs=args.n_jobs)", + globals=locals(), + number=args.number, + ) + + print(args) # noqa: T201 + print(f"{benchmark:.3f}s") # noqa: T201 diff --git a/benchmarks/test_pyts.py b/benchmarks/test_pyts.py new file mode 100644 index 0000000..2da3c57 --- /dev/null +++ b/benchmarks/test_pyts.py @@ -0,0 +1,79 @@ +# Copyright (c) 2019 Sequentia Developers. +# Distributed under the terms of the MIT License (see the LICENSE file). +# SPDX-License-Identifier: MIT +# This source code is part of the Sequentia project (https://github.com/eonu/sequentia). + +"""Runtime benchmarks for pyts's dynamic time warping +k-nearest neighbors algorithm. +""" + +from __future__ import annotations + +import timeit +import typing as t + +import numpy as np +from aeon.transformations.collection import Padder +from pyts.classification import KNeighborsClassifier +from utils import load_dataset + +from sequentia.datasets.base import SequentialDataset + +np.random.seed(0) +random_state: np.random.RandomState = np.random.RandomState(0) + +DataSplit: t.TypeAlias = tuple[np.ndarray, np.ndarray] + + +def prepare(data: SequentialDataset, length: int) -> DataSplit: + """Prepare the dataset - pad and flatten.""" + # transpose sequences and pad + X = [x.T for x, _ in data] + padder = Padder(pad_length=length) + X_pad = padder.fit_transform(X) + return X_pad[:, 0], data.y + + +def multivariate( + *, train_data: DataSplit, test_data: DataSplit, n_jobs: int +) -> None: + """Fit and predict the classifier.""" + # initialize model + clf = KNeighborsClassifier( + n_neighbors=1, + n_jobs=n_jobs, + metric="dtw", + ) + + # fit model + X_train, y_train = train_data + clf.fit(X_train, y_train) + + # predict model + X_test, _ = test_data + clf.predict(X_test) + + +if __name__ == "__main__": + import argparse + + parser: argparse.ArgumentParser = argparse.ArgumentParser() + parser.add_argument("--n-jobs", type=int, default=1) + parser.add_argument("--number", type=int, default=10) + args: argparse.Namespace = parser.parse_args() + + train_data, test_data = load_dataset(multivariate=False) + length = max(train_data.lengths.max(), test_data.lengths.max()) + train_data, test_data = ( + prepare(train_data, length=length), + prepare(test_data, length=length), + ) + + benchmark = timeit.timeit( + "func(train_data=train_data, test_data=test_data, n_jobs=args.n_jobs)", + globals=locals(), + number=args.number, + ) + + print(args) # noqa: T201 + print(f"{benchmark:.3f}s") # noqa: T201 diff --git a/benchmarks/test_sequentia.py b/benchmarks/test_sequentia.py new file mode 100644 index 0000000..8ba7e45 --- /dev/null +++ b/benchmarks/test_sequentia.py @@ -0,0 +1,61 @@ +# Copyright (c) 2019 Sequentia Developers. +# Distributed under the terms of the MIT License (see the LICENSE file). +# SPDX-License-Identifier: MIT +# This source code is part of the Sequentia project (https://github.com/eonu/sequentia). + +"""Runtime benchmarks for sequentia's dynamic time warping +k-nearest neighbors algorithm. +""" + +from __future__ import annotations + +import timeit + +import numpy as np +from utils import load_dataset + +import sequentia +from sequentia.datasets.base import SequentialDataset + +np.random.seed(0) +random_state: np.random.RandomState = np.random.RandomState(0) + + +def multivariate( + *, train_data: SequentialDataset, test_data: SequentialDataset, n_jobs: int +) -> None: + """Fit and predict the classifier.""" + # initialize model + clf = sequentia.models.KNNClassifier( + k=1, + use_c=True, + n_jobs=n_jobs, + random_state=random_state, + classes=train_data.classes, + ) + + # fit model + clf.fit(X=train_data.X, y=train_data.y, lengths=train_data.lengths) + + # predict model + clf.predict(X=test_data.X, lengths=test_data.lengths) + + +if __name__ == "__main__": + import argparse + + parser: argparse.ArgumentParser = argparse.ArgumentParser() + parser.add_argument("--n-jobs", type=int, default=1) + parser.add_argument("--number", type=int, default=10) + args: argparse.Namespace = parser.parse_args() + + train_data, test_data = load_dataset(multivariate=False) + + benchmark = timeit.timeit( + "func(train_data=train_data, test_data=test_data, n_jobs=args.n_jobs)", + globals=locals(), + number=args.number, + ) + + print(args) # noqa: T201 + print(f"{benchmark:.3f}s") # noqa: T201 diff --git a/benchmarks/test_sktime.py b/benchmarks/test_sktime.py new file mode 100644 index 0000000..e335a13 --- /dev/null +++ b/benchmarks/test_sktime.py @@ -0,0 +1,98 @@ +# Copyright (c) 2019 Sequentia Developers. +# Distributed under the terms of the MIT License (see the LICENSE file). +# SPDX-License-Identifier: MIT +# This source code is part of the Sequentia project (https://github.com/eonu/sequentia). + +"""Runtime benchmarks for sktime's dynamic time warping +k-nearest neighbors algorithm. +""" + +from __future__ import annotations + +import timeit +import typing as t + +import numpy as np +import pandas as pd +from dtaidistance import dtw_ndim +from sktime.classification.distance_based import KNeighborsTimeSeriesClassifier +from utils import load_dataset + +from sequentia.datasets.base import SequentialDataset + +np.random.seed(0) +random_state: np.random.RandomState = np.random.RandomState(0) + +DataSplit: t.TypeAlias = tuple[pd.Series, np.ndarray] + + +def distance(s1: pd.Series, s2: pd.Series) -> np.ndarray: + """DTAIDistance DTW measure - not used.""" + s1, s2 = s1.droplevel(1), s2.droplevel(1) + m = s1.index.max() + 1 + n = s2.index.max() + 1 + matrix = np.zeros((m, n)) + for i in range(m): + a = np.trim_zeros(s1.loc[i].to_numpy(dtype=np.float64)) + for j in range(n): + b = np.trim_zeros(s2.loc[j].to_numpy(dtype=np.float64)) + matrix[i][j] = dtw_ndim.distance(a, b, use_c=True) + return matrix + + +def pad(x: np.ndarray, length: int) -> np.ndarray: + """Pad a sequence with zeros.""" + return np.concat((x, np.zeros((length - len(x), x.shape[-1])))) + + +def prepare(data: SequentialDataset) -> DataSplit: + """Prepare the dataset - pad and convert to multi-indexed + Pandas DataFrame. + """ + # convert to padded pandas multi-index + length = data.lengths.max() + X = [pd.DataFrame(pad(x, length=length)) for x, _ in data] + X_pd = pd.concat(X, keys=range(len(X)), axis=0) + return X_pd, data.y + + +def multivariate( + *, train_data: DataSplit, test_data: DataSplit, n_jobs: int +) -> None: + """Fit and predict the classifier.""" + # initialize model + clf = KNeighborsTimeSeriesClassifier( + n_neighbors=1, + n_jobs=n_jobs, + distance="dtw", + # distance=distance, + ) + + # fit model + X_train, y_train = train_data + clf.fit(X_train, y_train) + + # predict model + X_test, _ = test_data + clf.predict(X_test) + + +if __name__ == "__main__": + import argparse + + parser: argparse.ArgumentParser = argparse.ArgumentParser() + parser.add_argument("--n-jobs", type=int, default=1) + parser.add_argument("--number", type=int, default=10) + args: argparse.Namespace = parser.parse_args() + + train_data, test_data = load_dataset(multivariate=False) + train_data, test_data = prepare(train_data), prepare(test_data) + + benchmark = timeit.timeit( + "func(train_data=train_data, test_data=test_data, n_jobs=args.n_jobs)", + globals=locals(), + number=args.number, + ) + + print(args) # noqa: T201 + print(f"{benchmark:.3f}s") # noqa: T201 diff --git a/benchmarks/test_tslearn.py b/benchmarks/test_tslearn.py new file mode 100644 index 0000000..b8e0306 --- /dev/null +++ b/benchmarks/test_tslearn.py @@ -0,0 +1,83 @@ +# Copyright (c) 2019 Sequentia Developers. +# Distributed under the terms of the MIT License (see the LICENSE file). +# SPDX-License-Identifier: MIT +# This source code is part of the Sequentia project (https://github.com/eonu/sequentia). + +"""Runtime benchmarks for tslearn's dynamic time warping +k-nearest neighbors algorithm. +""" + +from __future__ import annotations + +import timeit +import typing as t + +import numpy as np +from aeon.transformations.collection import Padder +from dtaidistance import dtw_ndim +from tslearn.neighbors import KNeighborsTimeSeriesClassifier +from utils import load_dataset + +from sequentia.datasets.base import SequentialDataset + +np.random.seed(0) +random_state: np.random.RandomState = np.random.RandomState(0) + +DataSplit: t.TypeAlias = tuple[np.ndarray, np.ndarray] + + +def distance(s1: np.ndarray, s2: np.ndarray) -> float: + """DTAIDistance DTW measure - not used.""" + return dtw_ndim.distance(s1, s2, use_c=True) + + +def prepare(data: SequentialDataset, length: int) -> DataSplit: + """Prepare the dataset - padding.""" + # pad sequences - zeros/nans are not ignored (!!!) + X = [x.T for x, _ in data] + padder = Padder(pad_length=length) + X_pad = padder.fit_transform(X) + # X_pad[(X_pad == 0).all(axis=1, keepdims=True)] = np.nan + return X_pad, data.y + + +def run(*, train_data: DataSplit, test_data: DataSplit, n_jobs: int) -> None: + """Fit and predict the classifier.""" + # initialize model + clf = KNeighborsTimeSeriesClassifier( + n_neighbors=1, + n_jobs=n_jobs, + ) + + # fit model + X_train, y_train = train_data + clf.fit(X_train, y_train) + + # predict model + X_test, _ = test_data + clf.predict(X_test) + + +if __name__ == "__main__": + import argparse + + parser: argparse.ArgumentParser = argparse.ArgumentParser() + parser.add_argument("--n-jobs", type=int, default=1) + parser.add_argument("--number", type=int, default=10) + args: argparse.Namespace = parser.parse_args() + + train_data, test_data = load_dataset(multivariate=False) + length = max(train_data.lengths.max(), test_data.lengths.max()) + train_data, test_data = ( + prepare(train_data, length=length), + prepare(test_data, length=length), + ) + + benchmark = timeit.timeit( + "run(train_data=train_data, test_data=test_data, n_jobs=args.n_jobs)", + globals=locals(), + number=args.number, + ) + + print(args) # noqa: T201 + print(f"{benchmark:.3f}s") # noqa: T201 diff --git a/benchmarks/utils.py b/benchmarks/utils.py new file mode 100644 index 0000000..7a52713 --- /dev/null +++ b/benchmarks/utils.py @@ -0,0 +1,60 @@ +# Copyright (c) 2019 Sequentia Developers. +# Distributed under the terms of the MIT License (see the LICENSE file). +# SPDX-License-Identifier: MIT +# This source code is part of the Sequentia project (https://github.com/eonu/sequentia). + +"""Utilities for benchmarking.""" + +from __future__ import annotations + +import numpy as np + +from sequentia.datasets.base import SequentialDataset +from sequentia.datasets.digits import load_digits + +__all__ = ["load_dataset"] + +np.random.seed(0) +random_state: np.random.RandomState = np.random.RandomState(0) + + +def load_dataset( + *, multivariate: bool +) -> tuple[SequentialDataset, SequentialDataset]: + """Loads the Free Spoken Digit Dataset.""" + # load data + data: SequentialDataset = load_digits() + + # split dataset + train_data, test_data = data.split( + test_size=0.5, + random_state=random_state, + shuffle=True, + stratify=True, + ) + + if multivariate: + # return untransformed data + return train_data, test_data + + # retrieve features + X_train, X_test = train_data.X, test_data.X + + # reduce to one dimension + X_train = X_train.mean(axis=-1, keepdims=True) + X_test = X_test.mean(axis=-1, keepdims=True) + + # return splits + train_split: SequentialDataset = SequentialDataset( + X=X_train, + y=train_data.y, + lengths=train_data.lengths, + classes=train_data.classes, + ) + test_split: SequentialDataset = SequentialDataset( + X=X_test, + y=test_data.y, + lengths=test_data.lengths, + classes=test_data.classes, + ) + return train_split, test_split