diff --git a/benchmarks/decomposition_methods.ipynb b/benchmarks/decomposition_methods.ipynb new file mode 100644 index 0000000000..3c73b96e99 --- /dev/null +++ b/benchmarks/decomposition_methods.ipynb @@ -0,0 +1,192 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "from __future__ import annotations\n", + "\n", + "import matplotlib.pyplot as plt\n", + "import numpy as np\n", + "\n", + "%load_ext autoreload\n", + "%autoreload 2\n", + "\n", + "# Set the random seed for reproducibility\n", + "seed = 42\n", + "np.random.seed(seed)\n", + "\n", + "# Step 1: Generate Gaussian noise with mean 0 and variance 1\n", + "gaussian_noise = np.random.normal(0, 1, (10000, 1))\n", + "\n", + "# Step 2: Generate exponentially increasing X from 0 to 10\n", + "steps = np.logspace(0, 1, 10)\n", + "X = np.concatenate([np.full(1000, exp_val) for exp_val in steps])[:, np.newaxis]\n", + "\n", + "# Step 3: Combine the Gaussian noise with the exponential increments\n", + "X = gaussian_noise + X\n", + "\n", + "# Display the result array\n", + "plt.plot(X)\n", + "plt.grid(True)" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [], + "source": [ + "from __future__ import annotations\n", + "\n", + "from river.decomposition import OnlineDMD, OnlinePCA, OnlineSVD, OnlineSVDZhang\n", + "\n", + "models = [\n", + " OnlineDMD(r=2, seed=seed),\n", + " OnlinePCA(n_components=2, seed=seed),\n", + " OnlineSVD(n_components=2, seed=seed),\n", + " OnlineSVDZhang(n_components=2, seed=seed),\n", + "]\n", + "n_feats_range = range(2, 20)\n", + "repeat = 5" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "100%|██████████| 3600000/3600000 [15:36<00:00, 3842.89it/s] \n" + ] + } + ], + "source": [ + "from __future__ import annotations\n", + "\n", + "import time\n", + "\n", + "import pandas as pd\n", + "from tqdm import tqdm\n", + "\n", + "from river.preprocessing import Hankelizer\n", + "\n", + "X_dicts = pd.DataFrame(X).to_dict(orient=\"records\")\n", + "iterations = len(models) * len(n_feats_range) * repeat * len(X_dicts)\n", + "\n", + "times_per_model = {model.__class__.__name__: [] for model in models}\n", + "\n", + "with tqdm(total=iterations, mininterval=10) as pbar:\n", + " for model in models:\n", + " for n_features in n_feats_range:\n", + " pipeline = Hankelizer(n_features) | model.clone()\n", + " times = np.zeros(repeat)\n", + " for rep in range(repeat):\n", + " tic = time.time()\n", + " for x in X_dicts:\n", + " pipeline.transform_one(x)\n", + " pipeline.learn_one(x)\n", + " pbar.update(1)\n", + " times[rep] = time.time() - tic\n", + " times_per_model[model.__class__.__name__].append(times)\n", + "\n", + "df_times = pd.DataFrame(times_per_model, index=n_feats_range)" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAAAk0AAAHHCAYAAACiOWx7AAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjkuMiwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy8hTgPZAAAACXBIWXMAAA9hAAAPYQGoP6dpAACZCklEQVR4nOzdd3wU1doH8N9sb6mbhIQACYTekSYoRCzUixcbKkhXr5RrQby2q0hVUUSFCIhSRFSkyPVVFEQQREVRBEGK1IAQSO/J1vP+MbubbApsQpJNyO+r89ndmdmZZ2c3uw9nzjxHEkIIEBEREdFlKfwdABEREVFdwKSJiIiIyAdMmoiIiIh8wKSJiIiIyAdMmoiIiIh8wKSJiIiIyAdMmoiIiIh8wKSJiIiIyAdMmoiIiIh8wKSpAl577TU0a9YMSqUSnTt39nc49cbXX3+Nzp07Q6fTQZIkZGZmVuj5L730EiRJqp7g6oGbbroJN910k0/rjh07FrGxsdUaT2313XffQZIkrF+/vtr3VZXHOTc3Fw8++CAiIyMhSRIef/zxKtluXXHmzBlIkoSVK1f6O5Qat3fvXvTu3RtGoxGSJGH//v3+DqnWq9NJ08qVKyFJkmfS6XRo2bIlpkyZgkuXLlXpvrZu3Yr//Oc/uOGGG7BixQrMnTu3SrdPZUtLS8Pw4cOh1+uRkJCA1atXw2g0+juseu3ChQt46aWX6u0X7EcffYQ333zT32FUmblz52LlypWYOHEiVq9ejVGjRvk7JKoBNpsN99xzD9LT07FgwQKsXr0aMTExVb6fa+37QuXvAKrCzJkz0bRpUxQWFmL37t1YvHgxNm/ejEOHDsFgMFTJPrZv3w6FQoH3338fGo2mSrZJV7Z3717k5ORg1qxZuPXWW/0dTr20detWr8cXLlzAjBkzEBsbW6rFddmyZXA6nTUYXc376KOPcOjQoWumRWb79u24/vrrMX36dH+H4hcxMTEoKCiAWq32dyg16uTJk0hMTMSyZcvw4IMPVtt+Lvd9URddE0nToEGD0K1bNwDAgw8+CLPZjDfeeAP/+9//cP/991/VtvPz82EwGJCcnAy9Xl9lCZMQAoWFhdDr9VWyvWtVcnIyACA4ONi/gdRjFfnM17cfnmtBcnIy2rZtW2Xbs9vtcDqdfvvHpdPphNVqhU6n82l991mK+qauf7cWFhZCo9FAoajZE2Z1+vRceW6++WYAwOnTpz3zPvzwQ3Tt2hV6vR6hoaG47777cO7cOa/n3XTTTWjfvj1+++039O3bFwaDAc899xwkScKKFSuQl5fnORXoPv9tt9sxa9YsxMXFQavVIjY2Fs899xwsFovXtmNjY/GPf/wDW7ZsQbdu3aDX67F06VJPP4hPP/0UM2bMQHR0NAICAnD33XcjKysLFosFjz/+OCIiImAymTBu3LhS216xYgVuvvlmREREQKvVom3btli8eHGp4+KOYffu3ejRowd0Oh2aNWuGDz74oNS6mZmZeOKJJxAbGwutVotGjRph9OjRSE1N9axjsVgwffp0NG/eHFqtFo0bN8Z//vOfUvGVZ926dZ73JCwsDA888ADOnz/v9X6MGTMGANC9e3dIkoSxY8dedpu7d+9G9+7dodPpEBcXh6VLl5a7ri+fCQD4+eefMXjwYISEhMBoNKJjx4546623vNbZvn07+vTpA6PRiODgYPzzn//EkSNHvNZx963666+/8MADDyAoKAjh4eF44YUXIITAuXPn8M9//hOBgYGIjIzE/PnzvZ7v/qysXbsWzz33HCIjI2E0GnH77beXGfeVji8AXLx4EePGjUOjRo2g1WoRFRWFf/7znzhz5oxnneJ9mr777jt0794dADBu3LhSfw9l9bXJy8vDk08+icaNG0Or1aJVq1Z4/fXXIYTwWk+SJEyZMgWbNm1C+/btodVq0a5dO3z99delXltJVfF3BFz5M3HTTTfhyy+/RGJioue1l3y9TqcTc+bMQaNGjaDT6XDLLbfgxIkTpfbly/sDwHM8dDod2rdvj88++6zMY/DJJ5+ga9euCAgIQGBgIDp06FDqc1rWMTt9+jS+/PJLz+txv/fJycmYMGECGjRoAJ1Oh06dOmHVqlVe23D3B3r99dfx5ptver4HDx8+XOY+27dvj379+pWa73Q6ER0djbvvvtsz7/XXX0fv3r1hNpuh1+vRtWvXMvuLuT83a9asQbt27aDVavHVV18hNjYW//znP0utX1hYiKCgIPzrX//yeg3F+zSNHTsWJpMJ58+fx7Bhw2AymRAeHo5p06bB4XB4bS8tLQ2jRo1CYGAggoODMWbMGBw4cMCnflLuLiY//PADpk6divDwcBiNRtxxxx1ISUkptf4777zjeY0NGzbE5MmTK9zH0/364uPj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cWNoNBps3rwZCxYsKHX595W25yun04kOHTrgjTfeKHN5yR+p2qSin4mqVtY+q+p9qajHH38cQ4cOxaZNm7Blyxa88MILePnll7F9+3Z06dKlWvddlqs9DpX9O6rKz4Q/3suIiAjs378fW7ZswVdffYWvvvoKK1aswOjRo0t13q4OFfnevPfee/Hss89i3bp1ePzxx/Hpp58iKCjIK8H7/vvvcfvtt6Nv37545513EBUVBbVajRUrVuCjjz7yef/33XcfnnjiCaxZswbPPfccPvzwQ3Tr1q3MhLWk8t7H6uCvv/+S3L8X06ZNK/esiDshnDt3Ll544QWMHz8es2bNQmhoKBQKBR5//HGfy46U99vpcDjKPCYl32f3fl577bVyyxm4/3aHDx+OPn364LPPPsPWrVvx2muv4dVXX8XGjRsxaNAgn+IFrpGk6XLi4uIghEDTpk3RsmXLKt12TEwMnE4njh8/7vkXGABcunQJmZmZ1VIorKT/+7//g8Viweeff+71r5WKnE4oKS4uDocOHbriOgcOHMAtt9xS4Wrb7uNy7Ngxz+k8t2PHjlXquIWHh0Ov13v+5VFym8X5+plwnyY6dOhQuTWiir+Wko4ePYqwsLAqL8ZZ8jUKIXDixAl07NixVEy+HN+4uDg8+eSTePLJJ3H8+HF07twZ8+fPx4cffljm/ivyfsfExGDbtm3Iycnxam06evSoV6z+VpHviautLu/r++O+9eUzDcitDkOHDsXQoUPhdDoxadIkLF26FC+88EKFWz5iYmLwxx9/wOl0erU2VcX71rRpU/To0QNr167FlClTsHHjRgwbNgxardazzoYNG6DT6bBlyxav+StWrKjQvkJDQzFkyBCsWbMGI0eOxA8//FClhUljYmKwY8cOT2kat7Kulrza/QDy+168ZcRqteL06dNVVsPOvW21Wn3Fba5fvx79+vXD+++/7zU/MzPTq9X8cn8vISEhZV79l5iY6FMLkPs7OjAw0KdjEBUVhUmTJmHSpElITk7Gddddhzlz5lQoabom+jRdzp133gmlUokZM2aUytqFEGWe/vDV4MGDAaDUH6G79WXIkCGV3rav3Nl48deWlZVV4S+X4u666y4cOHCgzEub3fsZPnw4zp8/j2XLlpVap6CgAHl5eeVuv1u3boiIiMCSJUu8mt2/+uorHDlypFLHTalUYsCA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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "# [donotremove]\n", + "from __future__ import annotations\n", + "\n", + "df_samples_per_second = df_times.map(lambda x: len(X_dicts) / np.mean(x))\n", + "\n", + "ax = df_samples_per_second.plot(\n", + " grid=True,\n", + " logy=True,\n", + ")\n", + "ax.set_xlabel(\"no. features\")\n", + "ax.set_ylabel(\"samples/second\")\n", + "_ = ax.set_title(\"Performance of decomposition methods for varying no. features\")" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "OnlineDMD 4606.160654\n", + "OnlinePCA 23621.341689\n", + "OnlineSVD 3184.689494\n", + "OnlineSVDZhang 5529.673450\n", + "Name: mean samples/second, dtype: float64" + ] + }, + "execution_count": 7, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# [donotremove]\n", + "from __future__ import annotations\n", + "\n", + "df_samples_per_second.mean().rename(\"mean samples/second\")" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "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.13.3" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/docs/releases/unreleased.md b/docs/releases/unreleased.md new file mode 100644 index 0000000000..e795a8d425 --- /dev/null +++ b/docs/releases/unreleased.md @@ -0,0 +1,33 @@ +# Unreleased + +## datasets + +- Fixed download in Insects dataset. The datasets incremental_abrupt_imbalanced, incremental_imbalanced, incremental_reoccurring_imbalanced and out-of-control are not supported anymore. +- Refactored `benchmarks` and added plotly dependency for interactive plots +- Added the BETH dataset for labeled system process events. + +## stats + +- Added `update_many` method to `stats.PearsonCorr`. +- Changed the calculation of the Kuiper statistic in `base.KolmogorovSmirnov` to correspond to the reference implementation. The Kuiper statistic uses the difference between the maximum value and the minimum value. + +## tree + +- Added handling for division by zero in `tree.hoeffding_tree` for leaf size estimation. + +## decomposition + +- Added new `decomposition` module with online decomposition methods: + - `OnlineSVD` — Online Singular Value Decomposition based on Brand (2006). + - `OnlineSVDZhang` — Online SVD with automatic reorthogonalization based on Zhang (2022). + - `OnlinePCA` — Online Principal Component Analysis based on Eftekhari et al. (2019). + - `OnlineDMD` — Online Dynamic Mode Decomposition based on Zhang et al. (2019). + - `OnlineDMDwC` — Online DMD with Control inputs. + +## preprocessing + +- Added `Hankelizer` transformer for time-delayed embedding of feature spaces. + +## build + +- Added Python 3.14 wheel builds and updated PyO3 for 3.14 support. diff --git a/docs/unreleased.md b/docs/unreleased.md new file mode 100644 index 0000000000..529739242e --- /dev/null +++ b/docs/unreleased.md @@ -0,0 +1,21 @@ +# Unreleased + +## drift + +- Added `FHDDM` drift detector. +- Added a `iter_polars` function to iterate over the rows of a polars DataFrame. + +## neighbors + +- Simplified `neighbors.SWINN` to avoid recursion limit and pickling issues. + +## decomposition + +- Added `decomposition.OnlineSVD` class to perform Singular Value Decomposition. +- Added `decomposition.OnlinePCA` class to perform Principal Component Analysis. +- Added `decomposition.OnlineDMD` class to perform Dynamic Mode Decomposition. +- Added `decomposition.OnlineDMDwC` class to perform Dynamic Mode Decomposition with Control. + +## preprocessing + +- Added `preprocessing.Hankelizer` class to perform Hankelization of data stream. diff --git a/mkdocs.yml b/mkdocs.yml index f1b5e6189c..739ce5d322 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -8286,6 +8286,7 @@ nav: - examples/on-hoeffding-trees.md - FAQ: faq - Releases: + - Unreleased: releases/unreleased.md - "0.25.0": releases/0.25.0.md - "0.24.2": releases/0.24.2.md - "0.24.1": releases/0.24.1.md diff --git a/river/base/__init__.py b/river/base/__init__.py index f54493087f..b2717ae7e0 100644 --- a/river/base/__init__.py +++ b/river/base/__init__.py @@ -26,7 +26,7 @@ ) from .ensemble import Ensemble, WrapperEnsemble from .estimator import Estimator -from .multi_output import MultiLabelClassifier, MultiTargetRegressor +from .multi_output import MiniBatchMultiTargetRegressor, MultiLabelClassifier, MultiTargetRegressor from .regressor import MiniBatchRegressor, Regressor from .transformer import ( BaseTransformer, @@ -49,6 +49,7 @@ "Ensemble", "Estimator", "MiniBatchClassifier", + "MiniBatchMultiTargetRegressor", "MiniBatchSupervisedTransformer", "MiniBatchTransformer", "MiniBatchRegressor", diff --git a/river/base/multi_output.py b/river/base/multi_output.py index 68cf013afc..decb792d5c 100644 --- a/river/base/multi_output.py +++ b/river/base/multi_output.py @@ -6,6 +6,9 @@ from .estimator import Estimator from .typing import FeatureName, RegTarget +if typing.TYPE_CHECKING: + import pandas as pd + class MultiLabelClassifier(Estimator, abc.ABC): """Multi-label classifier.""" @@ -104,3 +107,19 @@ def predict_one(self, x: dict[FeatureName, typing.Any]) -> dict[FeatureName, Reg The predictions. """ + + +class MiniBatchMultiTargetRegressor(MultiTargetRegressor): + """A multi-target regressor that can operate on mini-batches.""" + + def learn_many(self, X: pd.DataFrame, Y: pd.DataFrame) -> None: + """Update the model with a mini-batch of features `X` and targets `Y`. + + Parameters + ---------- + X + A dataframe of features. + Y + A dataframe of targets. + + """ diff --git a/river/decomposition/__init__.py b/river/decomposition/__init__.py new file mode 100644 index 0000000000..11d286f51f --- /dev/null +++ b/river/decomposition/__init__.py @@ -0,0 +1,15 @@ +"""Decomposition.""" + +from __future__ import annotations + +from .odmd import OnlineDMD, OnlineDMDwC +from .opca import OnlinePCA +from .osvd import OnlineSVD, OnlineSVDZhang + +__all__ = [ + "OnlineSVD", + "OnlineSVDZhang", + "OnlineDMD", + "OnlineDMDwC", + "OnlinePCA", +] diff --git a/river/decomposition/odmd.py b/river/decomposition/odmd.py new file mode 100644 index 0000000000..83e2478d20 --- /dev/null +++ b/river/decomposition/odmd.py @@ -0,0 +1,1272 @@ +"""Online Dynamic Mode Decomposition (DMD) in [River API](riverml.xyz). + +This module contains the implementation of the Online DMD, Weighted Online DMD, +and DMD with Control algorithms. It is based on the paper by Zhang et al. [^1] +and implementation of authors available at +[GitHub](https://github.com/haozhg/odmd). However, this implementation provides +a more flexible interface aligned with River API covers and separates update +and revert methods to operate with Rolling and TimeRolling wrapers. + +References: + [^1]: Zhang, H., Clarence Worth Rowley, Deem, E.A. and Cattafesta, L.N. + (2019). Online Dynamic Mode Decomposition for Time-Varying Systems. Siam + Journal on Applied Dynamical Systems, 18(3), pp.1586-1609. + doi:[10.1137/18m1192329](https://doi.org/10.1137/18m1192329). +""" + +from __future__ import annotations + +import typing +from collections.abc import Hashable +from typing import Any, Literal, TypeGuard + +import numpy as np +import scipy as sp + +from river import utils +from river.base import BaseTransformer +from river.base.multi_output import MiniBatchMultiTargetRegressor + +from .osvd import OnlineSVDZhang as OnlineSVD + +if typing.TYPE_CHECKING: + import pandas as pd + + +def _is_dataframe(x: Any) -> TypeGuard[pd.DataFrame]: + """Return True iff ``x`` is a pandas DataFrame, without importing pandas eagerly.""" + if not utils.pandas.PANDAS_INSTALLED: + return False + return isinstance(x, utils.pandas.import_pandas().DataFrame) + + +__all__ = [ + "OnlineDMD", + "OnlineDMDwC", +] + + +class OnlineDMD(MiniBatchMultiTargetRegressor, BaseTransformer): + r"""Online Dynamic Mode Decomposition (DMD). + + This regressor is a class that implements online dynamic mode decomposition + The time complexity (multiply-add operation for one iteration) is O(4n^2), + and space complexity is O(2n^2), where n is the state dimension. + + This estimator supports learning with mini-batches with same time and space + complexity as the online learning. It can be used as Rolling or TimeRolling + estimator. + + OnlineDMD implements `transform_one` and `transform_many` methods like + unsupervised MiniBatchTransformer. In such case, we may use `learn_one` + without `y` and `learn_many` without `Y` to learn the model. + In that case OnlineDMD preserves previous snapshot and uses it as x while + current snapshot is used as y, therefore, being delayed by one sample. + + At time step t, define two matrices X(t) = [x(1),x(2),...,x(t)], + Y(t) = [y(1),y(2),...,y(t)], that contain all the past snapshot pairs, + where x(t), y(t) are the n dimensional state vector, y(t) = f(x(t)) is + the image of x(t), f() is the dynamics. + + Here, if the (discrete-time) dynamics are given by z(t) = f(z(t-1)), + then x(t), y(t) should be measurements correponding to consecutive + states z(t-1) and z(t). + + An exponential weighting factor can be used to place more weight on + recent data. + + Args: + r: Number of modes to keep. If 0 (default), all modes are kept. + w: Weighting factor in (0,1]. Smaller value allows more adpative + learning, but too small weighting may result in model identification + instability (relies only on limited recent snapshots). + initialize: number of snapshot pairs to initialize the model with. If 0 + the model will be initialized with random matrix A and P = \alpha I + where \alpha is a large positive scalar. If initialize is smaller + than the state dimension, it will be set to the state dimension and + raise a warning. Defaults to 1. + exponential_weighting: Whether to use exponential weighting in revert + seed: Random seed for reproducibility (initialize A with random values) + + Attributes: + m: state dimension x(t) as in z(t) = f(z(t-1)) or y(t) = f(t, x(t)) + n_seen: number of seen samples (read-only), reverted if windowed + feature_names_in_: list of feature names. Used for dict inputs. + A: DMD matrix, size n by n (non-Hermitian) + _P: inverse of covariance matrix of X (symmetric) + + Examples: + >>> import numpy as np + >>> import pandas as pd + >>> n = 101; freq = 2.; tspan = np.linspace(0, 10, n); dt = 0.1 + >>> a1 = 1; a2 = 1; phase1 = -np.pi; phase2 = np.pi / 2 + >>> w1 = np.cos(np.pi * freq * tspan) + >>> w2 = -np.sin(np.pi * freq * tspan) + >>> df = pd.DataFrame({'w1': w1[:-1], 'w2': w2[:-1]}) + + >>> model = OnlineDMD(r=2, w=0.1, initialize=0) + >>> X, Y = df.iloc[:-1], df.shift(-1).iloc[:-1] + + >>> for (_, x), (_, y) in zip(X.iterrows(), Y.iterrows()): + ... x, y = x.to_dict(), y.to_dict() + ... model.learn_one(x, y) + >>> eig, _ = np.log(model.eig[0]) / dt + >>> r, i = eig.real, eig.imag + >>> bool(np.isclose(eig.real, 0.)) + True + >>> bool(np.isclose(eig.imag, np.pi * freq)) + True + + >>> model.xi # doctest: +SKIP + array([0.54244, 0.54244]) + + >>> from river.utils import Rolling + >>> model = Rolling(OnlineDMD(r=2, w=1.), 10) + >>> X, Y = df.iloc[:-1], df.shift(-1).iloc[:-1] + + >>> for (_, x), (_, y) in zip(X.iterrows(), Y.iterrows()): + ... x, y = x.to_dict(), y.to_dict() + ... model.update(x, y) + + >>> eig, _ = np.log(model.eig[0]) / dt + >>> r, i = eig.real, eig.imag + >>> bool(np.isclose(eig.real, 0.)) + True + >>> bool(np.isclose(eig.imag, np.pi * freq)) + True + + >>> bool(np.isclose(model.truncation_error(X.values, Y.values), 0)) + True + + >>> w_pred = model.predict_one({'w1': w1[-2], 'w2': w2[-2]}) + >>> bool(np.allclose(list(w_pred.values()), [w1[-1], w2[-1]])) + True + + >>> w_pred = model.predict_horizon({'w1': 1, 'w2': 0}, 10) + >>> bool(np.allclose(w_pred.T, [w1[1:11], w2[1:11]])) + True + + References: + [^1]: Zhang, H., Clarence Worth Rowley, Deem, E.A. and Cattafesta, L.N. + (2019). Online Dynamic Mode Decomposition for Time-Varying Systems. + Siam Journal on Applied Dynamical Systems, 18(3), pp.1586-1609. + doi:[10.1137/18m1192329](https://doi.org/10.1137/18m1192329). + """ + + def __init__( + self, + r: int = 0, + w: float = 1.0, + initialize: int = 1, + exponential_weighting: bool = False, + eig_rtol: float | None = None, + seed: int | None = None, + ) -> None: + """Initialize the OnlineDMD model.""" + self.r = int(r) + if self.r != 0: + # Forcing orthogonality makes the results more unstable + self._svd = OnlineSVD( + n_components=self.r, + seed=seed, + ) + self.w = float(w) + if not (0 < self.w <= 1): + raise ValueError("w must be in (0, 1]") + self.initialize = int(initialize) + self.exponential_weighting = exponential_weighting + self.eig_rtol = eig_rtol + if self.eig_rtol is not None and not (0.0 <= self.eig_rtol < 1.0): + raise ValueError("eig_rtol must be in [0.0, 1.0) or None") + self.seed = seed # used with sparse SVD, otherwise its deterministic + + np.random.seed(self.seed) + + self.m: int + self.n_seen: int = 0 + self.feature_names_in_: list[str] + self.A: np.ndarray + self._P: np.ndarray + self._Y: np.ndarray # for xi and modes computation + + self._A_last: np.ndarray + self._a_allclose: bool = False + + # Properties to be reset at each update + self._eig: tuple[np.ndarray, np.ndarray] | None = None + self._modes: np.ndarray | None = None + self._xi: np.ndarray | None = None + + def _unit_test_skips(self) -> set[str]: + return { + "check_learn_one", + "check_pickling", + "check_shuffle_features_no_impact", + "check_emerging_features", + "check_disappearing_features", + "check_radically_disappearing_features", + "check_seeding_is_idempotent", + } + + @property + def eig(self) -> tuple[np.ndarray, np.ndarray]: + """Compute DMD eigenvalues and DMD modes at current step.""" + if self._eig is None: + # Need to check if SVD is initialized in case r < m. Otherwise, transformation will fail. + # Explore faster ways to compute eig + # Find out whether Phi should have imaginary part + Lambda, Phi = sp.linalg.eig(self.A, check_finite=False) + # Find out if I should sort this + sort_idx = np.argsort(Lambda)[::-1] + if not np.array_equal(sort_idx, range(len(Lambda))): + Lambda = Lambda[sort_idx] + Phi = Phi[:, sort_idx] + self._eig = Lambda, Phi + return self._eig + + @property + def modes(self) -> np.ndarray: + """Reconstruct high dimensional discrete-time DMD modes.""" + if self._modes is None: + _, Phi_comp = self.eig + if self.r < self.m: + # Exact DMD modes (Tu et al. (2016)) + # self._Y.T @ self._svd._Vt.T is increasingly more computationally expensive without rolling + # self._modes = ( + # self._Y.T + # @ self._svd._Vt.T # sign may change if sparse SVD is used + # @ np.diag(1 / self._svd._S) + # @ Phi_comp # sign may change if sparse EIG is used + # ) + + # Projected DMD modes (Schmid (2010)) - faster, not guaranteed + # self._modes = self._svd._U @ Phi_comp + # This regularization works much better than the above + # if high variance in svs of X + self._modes = self._svd._U @ np.diag(1 / self._svd._S) @ Phi_comp + else: + self._modes = Phi_comp + return self._modes + + @property + def xi(self) -> np.ndarray: + """Amlitudes of the singular values of the input matrix.""" + if self._xi is None: + Lambda, Phi = self.eig + # Compute Discrete temporal dynamics matrix (Vandermonde matrix). + C = np.vander(Lambda, self.n_seen, increasing=True) + # xi = self.Phi.conj().T @ self._Y @ np.linalg.pinv(self.C) + + from scipy.optimize import minimize + + def objective_function(x: np.ndarray) -> float: + return float( + np.linalg.norm(self._Y[:, : self.r].T - Phi @ np.diag(x) @ C, "fro") + + 0.5 * np.linalg.norm(x, 1) + ) + + # Minimize the objective function + xi = minimize(objective_function, np.ones(self.r)).x + self._xi = xi + return self._xi + + @property + def a_allclose(self) -> bool: + """Check if A has changed since last update of eigenvalues.""" + if self.eig_rtol is None: + return False + return bool( + np.allclose( + np.abs(self._A_last[: self.A.shape[0], : self.A.shape[1]]), + np.abs(self.A), + rtol=self.eig_rtol, + ) + ) + + def _init_update(self) -> None: + if self.r == 0: + self.r = self.m + if self.initialize > 0 and self.initialize < self.r: + self.initialize = self.r + + # Zhang (2019) suggests to initialize A with random values + self.A = np.eye(self.r) + self._A_last = self.A.copy() + self._X_init = np.empty((self.initialize, self.m)) + self._Y_init = np.empty((self.initialize, self.m)) + self._Y = np.empty((0, self.m)) + + def _truncate_w_svd( + self, + x: np.ndarray, + y: np.ndarray, + svd_modify: Literal["update", "revert"] | None = None, + ) -> tuple[np.ndarray, np.ndarray]: + U_prev = self._svd._U + # We can update svd on x now without leaking new sample which is in y + # try: + if svd_modify == "update": + self._svd.update(x) + elif svd_modify == "revert": + self._svd.revert(x) + _U = self._svd._U + _UU = _U.T @ U_prev + x = x @ _U + # p != self.m and p == self.A.shape[0] in case of DMDwC + p = self.A.shape[0] + y = y @ _U[: y.shape[1], :p] + # Check if A is square + if self.A.shape[0] == self.A.shape[1]: + self.A = _UU @ self.A @ _UU.T + # If A is not square, it is called by DMDwC + else: + _UUp = _UU[:p, :p] + # _UUq = _UU[p:, p:] + # self.A = np.column_stack( + # (_UUp @ self.A[:, :p] @ _UUp.T, _UUp @ self.A[:, p:] @ _UUq.T) + # ) + self.A = _UUp @ self.A @ _UU.T + # Understand why we divide by w + self._P = np.linalg.inv(_UU @ np.linalg.inv(self._P) @ _UU.T) / self.w + + return x, y + + def _update_a_p(self, X: np.ndarray, Y: np.ndarray, W: float | np.ndarray) -> None: + Xt = X.T + AX = self.A.dot(Xt) + PX = self._P.dot(Xt) + PXt = PX.T + Gamma = np.linalg.inv(W + X.dot(PX)) + # update A on new data + self.A += (Y.T - AX).dot(Gamma).dot(PXt) + # update P, group Px*Px' to ensure positive definite + self._P = (self._P - PX.dot(Gamma).dot(PXt)) / self.w + # Symmetrize P to ensure positive definiteness + # Any matrix congruent to a symmetric matrix is again symmetric: if X + # is a symmetric matrix, then so is A@X@A.T for any matrix A. + self._P = (self._P + self._P.T) / 2 + + # Reset properties + if not self.a_allclose: + self._eig = None + self._A_last = self.A.copy() + self._modes = None + + def update( + self, + x: dict[Hashable, float] | np.ndarray, + y: dict[Hashable, float] | np.ndarray | None = None, + ) -> None: + """Update the DMD computation with a new pair of snapshots (x, y). + + Here, if the (discrete-time) dynamics are given by z(t) = f(z(t-1)), + then (x,y) should be measurements correponding to consecutive states + z(t-1) and z(t). + + Args: + x: 1D array, shape (m, ), x(t) as in y(t) = f(t, x(t)) + y: 1D array, shape (m, ), y(t) as in y(t) = f(t, x(t)) + """ + # If Hankelizer is used, we need to use DMD without y + if y is None: + if not hasattr(self, "_x_last"): + self._x_last = x + return + else: + y = x + x = self._x_last + self._x_last = y + + if isinstance(x, dict): + self.feature_names_in_ = [str(k) for k in x.keys()] + x = np.array(list(x.values()), ndmin=2) + x_ = x.reshape(1, -1) + if isinstance(y, dict): + if self.feature_names_in_ != [str(k) for k in y.keys()]: + raise ValueError("y features do not match x features") + y = np.array(list(y.values()), ndmin=2) + y_ = y.reshape(1, -1) + + # Initialize properties which depend on the shape of x + if self.n_seen == 0: + self.m = x_.shape[1] + self._init_update() + + # Collect buffer of past snapshots to compute modes and xi + if self._Y.shape[0] <= self.n_seen + 1: + self._Y = np.vstack([self._Y, y_]) + if self._Y.shape[0] > self.n_seen + 1: + self._Y = self._Y[-(self.n_seen + 1) :, :] + + # Initialize A and P with first self.initialize snapshot pairs + if bool(self.initialize) and self.n_seen < self.initialize: + self._X_init[self.n_seen, :] = x_ + self._Y_init[self.n_seen, :] = y_ + if self.n_seen == self.initialize - 1: + self.learn_many(self._X_init, self._Y_init) + # revert the number of seen samples to avoid doubling + self.n_seen -= self._X_init.shape[0] + del self._X_init, self._Y_init + # Update incrementally if initialized + else: + if self.n_seen == 0: + epsilon = 1e-15 + alpha = 1.0 / epsilon + self._P = alpha * np.identity(self.r) + if self.r < self.m: + x_, y_ = self._truncate_w_svd(x_, y_, svd_modify="update") + + self._update_a_p(x_, y_, 1.0) + + self.n_seen += 1 + + def learn_one( + self, + x: dict[Hashable, Any], + y: dict[Hashable, float] | None = None, + **kwargs: Any, + ) -> None: + """Alias for update method.""" + self.update(x, y) + + def revert( + self, + x: dict[Hashable, float] | np.ndarray, + y: dict[Hashable, float] | np.ndarray | None = None, + ) -> None: + """Gradually forget the older snapshots and revert the DMD computation. + + Compatible with Rolling and TimeRolling wrappers. + + Note: + On long time-varying sequences with small dt, accumulated numerical + noise from repeated rank-1 downdates can degrade eigenvalue + estimates (e.g. losing small imaginary components). This is + platform-dependent (different BLAS backends accumulate errors + differently). For better accuracy, prefer exponential weighting + (``w < 1``) over Rolling when the system is strongly time-varying. + + Args: + x: 1D array, shape (1, m), x(t) as in y(t) = f(t, x(t)) + y: 1D array, shape (1, m), y(t) as in y(t) = f(t, x(t)) + """ + if self.n_seen < self.initialize: + raise RuntimeError( + f"Cannot revert {self.__class__.__name__} before " + "initialization. If used with Rolling or TimeRolling, window " + f"size should be increased to {self.initialize + 1 if y is None else 0}." + ) + if y is None: + if not hasattr(self, "_x_first"): + self._x_first = x + return + else: + y = x + x = self._x_first + self._x_first = y + + if isinstance(x, dict): + x = np.array(list(x.values())) + x_ = x.reshape(1, -1) + if isinstance(y, dict): + y = np.array(list(y.values())) + y_ = y.reshape(1, -1) + + if self.r < self.m: + x_, y_ = self._truncate_w_svd(x_, y_, svd_modify="revert") + + # Apply exponential weighting factor + if self.exponential_weighting: + weight = 1.0 / -(self.w**self.n_seen) + else: + weight = -1.0 + + self._update_a_p(x_, y_, weight) + + self.n_seen -= 1 + + def _update_many( + self, + X: np.ndarray | pd.DataFrame, + Y: np.ndarray | pd.DataFrame, + ) -> None: + """Update the DMD computation with a new batch of snapshots (X,Y). + + This method brings no change in theoretical time and space complexity. + However, it allows parallel computing by vectorizing update in loop. + + Args: + X: The input snapshot matrix of shape (p, m), where p is the number of snapshots and m is the number of features. + Y: The output snapshot matrix of shape (p, m), where p is the number of snapshots and m is the number of features. + + Todo: + - [ ] find out why not equal to for loop update implementation + when weights are used + + """ + p = X.shape[0] + if self.exponential_weighting: + weights = np.sqrt(self.w) ** np.arange(p - 1, -1, -1) + else: + weights = np.ones(p) + # Zhang (2019): Gamma = (C^{-1} U^T P U )^{-1} ) + C_inv = np.diag(np.reciprocal(weights)) + + if _is_dataframe(X): + X_ = X.values + else: + X_ = X + if _is_dataframe(Y): + Y_ = Y.values + else: + Y_ = Y + if self.r < self.m: + X_, Y_ = self._truncate_w_svd(X_, Y_, svd_modify="update") + self._update_a_p(X_, Y_, C_inv) + + def update_many( + self, + X: np.ndarray | pd.DataFrame, + Y: np.ndarray | pd.DataFrame | None = None, + ) -> None: + """Learn the OnlineDMD model using multiple snapshot pairs. + + Useful for initializing the model with a batch of snapshot pairs. + Otherwise, it is equivalent to calling update method in a loop. + + Args: + X: The input snapshot matrix of shape (p, m), where p is the number of snapshots and m is the number of features. + Y: The output snapshot matrix of shape (p, m), where p is the number of snapshots and m is the number of features. + """ + if Y is None: + if _is_dataframe(X): + Y = X.shift(-1).iloc[:-1] + X = X.iloc[:-1] + elif isinstance(X, np.ndarray): + Y = np.roll(X, -1)[:-1] + X = X[:-1] + + if _is_dataframe(X): + X = X.values + if _is_dataframe(Y): + Y = Y.values + + # necessary condition for over-constrained initialization + n = X.shape[0] + # Exponential weighting factor - older snapshots are weighted less + if self.exponential_weighting: + weights = (np.sqrt(self.w) ** np.arange(n - 1, -1, -1))[:, np.newaxis] + else: + weights = np.ones((n, 1)) + Xqhat, Yqhat = weights * X, weights * Y + + self.n_seen += n + + # Initialize A and P with first p snapshot pairs + if not hasattr(self, "_P"): + self.m = X.shape[1] + if self.r == 0: + self.r = self.m + + _rank_X = np.linalg.matrix_rank(X) + if not _rank_X >= self.r: + raise ValueError( + f"Failed rank(X) [{_rank_X}] >= n_modes [{self.r}].\n" + "Increase the number of snapshots (increase initialize " + f"[{self.initialize}] if learn_many was not called " + "directly) or reduce the number of modes." + ) + XX = Xqhat.T @ Xqhat + # Think about using correlation matrix to avoid scaling issues + # https://stats.stackexchange.com/questions/12200/normalizing-variables-for-svd-pca + # std = np.sqrt(np.diag(XX)) + # XX = XX / np.outer(std, std) + # Perform truncated DMD + if self.r < self.m: + self._svd.learn_many(Xqhat) + _U, _S, _V = self._svd._U, self._svd._S, self._svd._Vt + + _m = Yqhat.shape[1] + _l = self.m - _m + + # DMDwC, A = U.T @ K @ U; B = U.T @ K [Proctor (2016)] + if _l != 0: + _UU = _U.T @ np.vstack([_U[:_m], np.eye(_l, self.r)]) + # DMD, A = U.T @ K @ U + else: + _UU = np.eye(self.r) + + # Verify if equivalent to Proctor (2016). They compute U_hat from SVD(Y), we select the first r columns of U + self.A = (_U.T[:, : Yqhat.shape[1]] @ Yqhat.T @ _V.T @ np.diag(1 / _S)) @ _UU + self._P = np.linalg.inv(_U.T @ XX @ _U) / self.w + # Perform exact DMD + else: + self.A = Yqhat.T.dot(np.linalg.pinv(Xqhat.T)) + self._P = np.linalg.inv(XX) / self.w + + self._A_last = self.A.copy() + # Store the last p snapshots for xi computation + self._Y = Yqhat + self.initialize = 0 + # Update incrementally if initialized + # Zhang (2019): "single rank-s update is roughly the same as applying + # the rank-1 formula s times" + else: + self._update_many(Xqhat, Yqhat) + if self._Y.shape[0] <= self.n_seen: + self._Y = np.vstack([self._Y, Yqhat]) + if self._Y.shape[0] > self.n_seen: + self._Y = self._Y[-(self.n_seen) :, :] + + def learn_many( + self, + X: np.ndarray | pd.DataFrame, + Y: np.ndarray | pd.DataFrame | None = None, + ) -> None: + """Allias for update_many method.""" + self.update_many(X, Y) + + def predict_one(self, x: dict[Hashable, Any]) -> dict[Hashable, float]: + """Predicts the next state given the current state. + + Parameters: + x: The current state as a dictionary. + + Returns: + dict: The predicted next state. + """ + keys = list(x.keys()) + x_arr = np.array(list(x.values())) + # Map A back to original space + if self.r < self.m: + A = self._svd._U @ self.A @ self._svd._U.T + else: + A = self.A + result = (A @ x_arr).real + return dict(zip(keys, result)) + + def predict_horizon(self, x: dict[Hashable, float] | np.ndarray, horizon: int) -> np.ndarray: + """Predicts multiple future values based on the given initial value. + + Args: + x: The initial value. + horizon (int): The number of future values to predict. + + Returns: + np.ndarray: An array containing the predicted future values. + """ + # Map A back to original space + if self.r < self.m: + A = self._svd._U @ self.A @ self._svd._U.T + else: + A = self.A + mat = np.zeros((horizon + 1, self.m)) + mat[0, :] = x if isinstance(x, np.ndarray) else list(x.values()) + for s in range(1, horizon + 1): + mat[s, :] = (A @ mat[s - 1, :]).real + return mat[1:, :] + + def forecast(self, horizon: int) -> list[float]: + """Forecast the next state given the current state. + + Args: + horizon: The number of future values to predict. + xs: The initial values. + + Returns: + list: The predicted future values. + """ + x = self._x_last + if not hasattr(self, "m"): + self.m = len(x) + # Map A back to original space + if self.r < self.m: + if hasattr(self._svd, "_U"): + A = self._svd._U @ self.A @ self._svd._U.T + else: + return [0.0] * horizon + else: + A = self.A + mat = np.zeros((horizon + 1, self.m)) + mat[0, :] = x if isinstance(x, np.ndarray) else list(x.values()) + for s in range(1, horizon + 1): + mat[s, :] = (A @ mat[s - 1, :]).real + return list(mat[1:, -1].flatten()) + + def truncation_error( + self, + X: np.ndarray | pd.DataFrame, + Y: np.ndarray | pd.DataFrame, + ) -> float: + """Compute the truncation error of the DMD model on the given data. + + Since this implementation computes exact DMD, the truncation error is relevant only for initialization. + + Args: + X: 2D array, shape (p, m), matrix [x(1),x(2),...x(p)] + Y: 2D array, shape (p, m), matrix [y(1),y(2),...y(p)] + + Returns: + float: Truncation error of the DMD model + """ + Y_hat = self.A @ X.T + return float(np.linalg.norm(Y - Y_hat.T) / np.linalg.norm(Y)) + + def transform_one(self, x: dict[Hashable, Any]) -> dict[Hashable, Any]: + """Transform the given input sample. + + Args: + x: The input to transform. + + Returns: + dict: The transformed input. + """ + x_arr = np.array(list(x.values())) + if not hasattr(self, "A") or (hasattr(self, "_svd") and not hasattr(self._svd, "_U")): + return dict( + zip( + range(self.r), + np.zeros(self.r), + ) + ) + return dict(zip(range(self.r), x_arr @ self.modes)) + + def transform_many(self, X: np.ndarray | pd.DataFrame) -> np.ndarray | pd.DataFrame: + """Transform the given input sequence. + + Args: + X: The input to transform. + + Returns: + np.ndarray: The transformed input. + """ + M = self.modes + return X @ M + + +class OnlineDMDwC(OnlineDMD): + r"""Online Dynamic Mode Decomposition (DMD) with Control. + + This regressor is a class that implements online dynamic mode decomposition + The time complexity (multiply-add operation for one iteration) is O(4n^2), + and space complexity is O(2n^2), where n is the state dimension. + + This estimator supports learning with mini-batches with same time and space + complexity as the online learning. + + At time step t, define three matrices X(t) = [x(1),x(2),...,x(t)], + Y(t) = [y(1),y(2),...,y(t)], U(t) = [U(1),U(2),...,U(t)] that contain all + the past snapshot pairs, where x(t), y(t) are the n dimensional state + vectors, and u(t) is m dimensional control input vector, given by + y(t) = f(x(t), u(t)). + + x(t), y(t) should be measurements correponding to consecutive states z(t-1) + and z(t). + + An exponential weighting factor can be used to place more weight on + recent data. + + Args: + B: control matrix, size n by m. If None, the control matrix will be + identified from the snapshots. Defaults to None. + p: truncation of states. If 0 (default), compute exact DMD. + q: truncation of control. If 0 (default), compute exact DMD. + w: weighting factor in (0,1]. Smaller value allows more adpative + learning, but too small weighting may result in model identification + instability (relies only on limited recent snapshots). + initialize: number of snapshot pairs to initialize the model with. If 0 + the model will be initialized with random matrix A and P = \alpha I + where \alpha is a large positive scalar. If initialize is smaller + than the state dimension, it will be set to the state dimension and + raise a warning. Defaults to 1. + exponential_weighting: whether to use exponential weighting in revert + seed: random seed for reproducibility (initialize A with random values) + + Attributes: + m: augumented state dimension. if B is None, m = x.shape[1], else m = x.shape[1] + u.shape[1] + n_seen: number of seen samples (read-only), reverted if windowed + A: DMD matrix, size n by n + _P: inverse of covariance matrix of X + + Examples: + >>> import numpy as np + >>> import pandas as pd + + >>> n = 101 + >>> freq = 2.0 + >>> tspan = np.linspace(0, 10, n) + >>> dt = 0.1 + >>> a1 = 1 + >>> a2 = 1 + >>> phase1 = -np.pi + >>> phase2 = np.pi / 2 + >>> w1 = np.cos(np.pi * freq * tspan) + >>> w2 = -np.sin(np.pi * freq * tspan) + >>> u_ = np.ones(n) + >>> u_[tspan > 5] *= 2 + >>> w1[tspan > 5] *= 2 + >>> w2[tspan > 5] *= 2 + >>> df = pd.DataFrame({"w1": w1[:-1], "w2": w2[:-1]}) + >>> U = pd.DataFrame({"u": u_[:-2]}) + + >>> model = OnlineDMDwC(p=2, q=1, w=0.1, initialize=4) + >>> X, Y = df.iloc[:-1], df.shift(-1).iloc[:-1] + + >>> for (_, x), (_, y), (_, u) in zip(X.iterrows(), Y.iterrows(), U.iterrows()): + ... x, y, u = x.to_dict(), y.to_dict(), u.to_dict() + ... model.learn_one(x, y, u) + >>> eig, _ = np.log(model.eig[0]) / dt + >>> r, i = eig.real, eig.imag + >>> bool(np.isclose(eig.real, 0.0)) + True + >>> bool(np.isclose(eig.imag, np.pi * freq)) + True + + Supports mini-batch learning: + >>> from river.utils import Rolling + + >>> model = Rolling(OnlineDMDwC(p=2, q=1, w=1.0), 10) + >>> X, Y = df.iloc[:-1], df.shift(-1).iloc[:-1] + + >>> for (_, x), (_, y), (_, u) in zip(X.iterrows(), Y.iterrows(), U.iterrows()): + ... x, y, u = x.to_dict(), y.to_dict(), u.to_dict() + ... model.update(x, y, u) + + >>> eig, _ = np.log(model.eig[0]) / dt + >>> r, i = eig.real, eig.imag + >>> bool(np.isclose(eig.real, 0.0)) + True + >>> bool(np.isclose(eig.imag, np.pi * freq)) + True + + # Note: currently disabled + # >>> np.isclose(model.truncation_error(X.values, Y.values, U.values), 0) + # True + + >>> w_pred = model.predict_one( + ... {'w1': w1[-2], 'w2': w2[-2]}, + ... u={'u': u_[-2]}, + ... ) + >>> bool(np.allclose(list(w_pred.values()), [w1[-1], w2[-1]])) + True + + >>> w_pred = model.predict_horizon({'w1': 1, 'w2': 0}, 10, np.ones((10, 1))) + >>> bool(np.allclose(w_pred.T, [w1[1:11], w2[1:11]])) + True + + References: + [^1]: Zhang, H., Clarence Worth Rowley, Deem, E.A. and Cattafesta, L.N. + (2019). Online Dynamic Mode Decomposition for Time-Varying Systems. + Siam Journal on Applied Dynamical Systems, 18(3), pp.1586-1609. + doi:[10.1137/18m1192329](https://doi.org/10.1137/18m1192329). + """ + + def __init__( + self, + B: np.ndarray | None = None, + p: int = 0, + q: int = 0, + w: float = 1.0, + initialize: int = 1, + exponential_weighting: bool = False, + eig_rtol: float | None = None, + seed: int | None = None, + ) -> None: + """Initialize the OnlineDMDwC model.""" + super().__init__( + p + q, + w, + initialize, + exponential_weighting, + eig_rtol, + seed, + ) + self.p = p + self.q = q + self.B = B + self.known_B = B is not None + self.l: int + + @property + def modes(self) -> np.ndarray: + """Reconstruct high dimensional DMD modes.""" + if not hasattr(self, "l"): + self._modes = super().modes + if self._modes is None: + _, Phi = self.eig + if self.r < self.m: + # Sign of eigenvectors and singular vectors may change based on underlying algorithm initialization + # Proctor (2016) + # self._Y.T @ self._svd._Vt.T is increasingly more computationally expensive without rolling + # self._modes = ( + # self._Y.T + # @ self._svd._Vt.T[:, : self.p] + # @ np.diag(1 / self._svd._S[: self.p]) + # @ Phi + # ) + # Following has similar results to our modification + # self._modes = (self._Y.T @ self._svd._Vt.T @ np.diag(1/self._svd._S))[:, :self.p] @ Phi + + # This is faster but significantly alter results for OnlineDMDwC. + self._modes = (self._svd._U @ np.diag(1 / self._svd._S))[ + : self.m - self.l, : self.p + ] @ Phi + else: + self._modes = Phi + return self._modes + + @property + def xi(self) -> np.ndarray: + """Amplitudes of the singular values of the input matrix.""" + x_first = self._x_first + if isinstance(x_first, dict): + x_first = np.array(list(x_first.values())) + result: np.ndarray = np.linalg.pinv(self.modes) @ x_first + return result + + def _init_update(self) -> None: + if not hasattr(self, "l"): + super()._init_update() + return + if self.p == 0: + self.p = self.m + if self.q == 0: + self.q = self.l + if self.known_B: + self.r = self.p + else: + self.r = self.p + self.q + # If p or q == 0 in __init__, we need to reinitialize SVD + self._svd = OnlineSVD( + n_components=self.r, + seed=self.seed, + ) + if self.initialize < self.r: + self.initialize = self.r + + self.A = np.eye(self.p) + self._A_last = self.A.copy() + if not self.known_B: + self.B = np.eye(self.p, self.q) + self._A_last = np.column_stack((self.A, self.B)) + self._U_init = np.zeros((self.initialize, self.l)) + self._X_init = np.empty((self.initialize, self.m)) + self._Y_init = np.empty((self.initialize, self.m)) + self._Y = np.empty((0, self.m)) + + def _reconstruct_ab(self) -> tuple[np.ndarray, np.ndarray]: + # self.m stores augumented state dimension + _m = self.m - self.l if not self.known_B else self.m + if self.r < self.m: + A = self._svd._U[:_m, : self.p] @ self.A @ self._svd._U[:_m, : self.p].T + B = self._svd._U[:_m, : self.p] @ self.B @ self._svd._U[-self.l :, -self.q :].T + else: + A = self.A + B = self.B + return A, B + + def update( + self, + x: dict[Hashable, float] | np.ndarray, + y: dict[Hashable, float] | np.ndarray | None = None, + u: dict[Hashable, float] | np.ndarray | None = None, + ) -> None: + """Update the DMD computation with a new pair of snapshots (x, y). + + Here, if the (discrete-time) dynamics are given by z(t) = f(z(t-1)), + then (x,y) should be measurements correponding to consecutive states + z(t-1) and z(t). + + Args: + x: 1D array, shape (n, ), x(t) as in y(t) = f(t, x(t)) + y: 1D array, shape (n, ), y(t) as in y(t) = f(t, x(t)) + u: 1D array, shape (m, ), u(t) as in y(t) = f(t, x(t), u(t)) + """ + if y is None: + if not hasattr(self, "_x_last"): + self._x_last = x + self._u_last = u + return + else: + y = x + x = self._x_last + self._x_last = y + _u_hold = u + u = self._u_last + self._u_last = _u_hold + + if isinstance(x, dict): + x = np.array(list(x.values())) + x = x.reshape(1, -1) + if isinstance(y, dict): + y = np.array(list(y.values())) + y = y.reshape(1, -1) + if isinstance(u, dict): + u = np.array(list(u.values())) + if isinstance(u, np.ndarray): + u = u.reshape(1, -1) + # Needed in case of recursive call from learn_many within parent class + if u is None: + super().update(x, y) + else: + if self.n_seen == 0: + self.m = x.shape[1] + self.l = u.shape[1] + self._init_update() + self.m += 0 if self.known_B else u.shape[1] + + if self.initialize and self.n_seen <= self.initialize - 1: + # Accumulate buffer of past snapshots for initialization + self._X_init[self.n_seen, :] = x + self._Y_init[self.n_seen, :] = y + self._U_init[self.n_seen, :] = u + # Run the initialization after collecting enough snapshots + if self.n_seen == self.initialize - 1: + self.learn_many(self._X_init, self._Y_init, self._U_init) + # Subtract the number of seen samples to avoid doubling + self.n_seen -= self._X_init.shape[0] + self.n_seen += 1 + + else: + if self.known_B and self.B is not None: + y = y - u @ self.B.T + else: + x = np.column_stack((x, u)) + if self.B is not None: # For correct type hinting + self.A = np.column_stack((self.A, self.B)) + super().update(x, y) + + # In case that learn_many was called, A is already square + if self.A.shape[0] < self.A.shape[1]: + self.B = self.A[: self.p, -self.q :] + self.A = self.A[: self.p, : -self.q] + + def learn_one( + self, + x: dict[Hashable, Any], + y: dict[Hashable, float] | None = None, + u: dict[Hashable, float] | np.ndarray | None = None, + **kwargs: Any, + ) -> None: + """Alias for OnlineDMDwC.update method.""" + self.update(x, y, u) + + def revert( + self, + x: dict[Hashable, float] | np.ndarray, + y: dict[Hashable, float] | np.ndarray | None = None, + u: dict[Hashable, float] | np.ndarray | None = None, + ) -> None: + """Gradually forget the older snapshots and revert the DMD computation. + + Compatible with Rolling and TimeRolling wrappers. + + Note: + Inherits the numerical precision limitation from + :meth:`OnlineDMD.revert`. See its docstring for details. + + Args: + x: 1D array, shape (n, ), x(t) + y: 1D array, shape (n, ), y(t) + u: 1D array, shape (m, ), u(t) + """ + if u is None: + super().revert(x, y) + return + + if y is None: + if not hasattr(self, "_x_first"): + self._x_first = x + self._u_first = u + return + else: + y = x + x = self._x_first + self._x_first = y + _u_hold = u + u = self._u_first + self._u_first = _u_hold + + if isinstance(x, dict): + x = np.array(list(x.values())) + x = x.reshape(1, -1) + if isinstance(y, dict): + y = np.array(list(y.values())) + y = y.reshape(1, -1) + if isinstance(u, dict): + u = np.array(list(u.values())) + u = u.reshape(1, -1) + if self.known_B and self.B is not None: + y = y - u @ self.B.T + else: + x = np.column_stack((x, u)) + if self.B is not None: + self.A = np.column_stack((self.A, self.B)) + + super().revert(x, y) + + if not self.known_B: + self.B = self.A[: self.p, -self.q :] + self.A = self.A[: self.p, : -self.q] + + def _update_many( + self, + X: np.ndarray | pd.DataFrame, + Y: np.ndarray | pd.DataFrame, + U: np.ndarray | pd.DataFrame | None = None, + ) -> None: + """Update the DMD computation with a new batch of snapshots (X,Y). + + This method brings no change in theoretical time and space complexity. + However, it allows parallel computing by vectorizing update in loop. + + Args: + X: The input snapshot matrix of shape (p, m), where p is the number of snapshots and m is the number of features. + Y: The output snapshot matrix of shape (p, m), where p is the number of snapshots and m is the number of features. + U: The control input snapshot matrix of shape (p, l), where p is the number of snapshots and p is the number of control inputs. + """ + if U is None: + super()._update_many(X, Y) + else: + if self.known_B: + Y = Y - self.B @ U + else: + X = np.column_stack((X, U)) + if self.n_seen == 0: + self.m = X.shape[1] + self.l = U.shape[1] + self._init_update() + if not self.known_B and self.B is not None: + self.A = np.column_stack((self.A, self.B)) + self.l = U.shape[1] + super()._update_many(X, Y) + + if not self.known_B: + self.B = self.A[:, -self.q :] + self.A = self.A[:, : -self.q] + + def learn_many( + self, + X: np.ndarray | pd.DataFrame, + Y: np.ndarray | pd.DataFrame | None = None, + U: np.ndarray | pd.DataFrame | None = None, + ) -> None: + """Learn the OnlineDMDwC model using multiple snapshot pairs. + + Useful for initializing the model with a batch of snapshot pairs. + Otherwise, it is equivalent to calling update method in a loop. + + Args: + X: The input snapshot matrix of shape (p, m), where p is the number of snapshots and m is the number of features. + Y: The output snapshot matrix of shape (p, m), where p is the number of snapshots and m is the number of features. + U: The output snapshot matrix of shape (p, l), where p is the number of snapshots and l is the number of control inputs. + """ + if U is None: + super().learn_many(X, Y) + return + + if _is_dataframe(X): + X = X.values + if _is_dataframe(Y): + Y = Y.values + if _is_dataframe(U): + U = U.values + + if Y is None: + Y = np.roll(X, -1)[:-1] + X = X[:-1] + U = U[:-1] + + if self.known_B and self.B is not None: + Y = Y - U @ self.B.T + else: + X = np.column_stack((X, U)) + if self.B is not None: # If learn_many is not called first + self.A = np.column_stack((self.A, self.B)) + + self.l = U.shape[1] + super().learn_many(X, Y) + + if self.p == 0: + self.p = self.m + if self.q == 0: + self.q = self.l + if not self.known_B: + self.B = self.A[: self.p, -self.q :] + self.A = self.A[: self.p, : -self.q] + + def predict_one( + self, + x: dict[Hashable, Any], + u: dict[Hashable, float] | np.ndarray | None = None, + ) -> dict[Hashable, float]: + """Predicts the next state given the current state. + + Parameters: + x: The current state as a dictionary. + u: The control input. + + Returns: + dict: The predicted next state. + """ + if u is None: + return super().predict_one(x) + if isinstance(u, dict): + u = np.array(list(u.values())) + keys = list(x.keys()) + x_arr = np.array(list(x.values())) + A, B = self._reconstruct_ab() + action = (B @ u).real + result = (A @ x_arr).real + action + return dict(zip(keys, result)) + + def predict_horizon( + self, + x: dict[Hashable, float] | np.ndarray, + horizon: int, + U: np.ndarray | pd.DataFrame | None = None, + ) -> np.ndarray: + """Predicts multiple future values based on the given initial value. + + Args: + x: The initial value. + horizon (int): The number of future values to predict. + U: The control input matrix of shape (horizon, l), where l is the number of control inputs. + + Returns: + np.ndarray: An array containing the predicted future values. + """ + if U is None: + return super().predict_horizon(x, horizon) + if _is_dataframe(U): + U = U.values + _m = len(x) + A, B = self._reconstruct_ab() + mat = np.zeros((horizon + 1, _m)) + mat[0, :] = x if isinstance(x, np.ndarray) else list(x.values()) + for s in range(1, horizon + 1): + action = (B @ U[s - 1, :]).real + mat[s, :] = (A @ mat[s - 1, :]).real + action + return mat[1:, :] + + def truncation_error( + self, + X: np.ndarray | pd.DataFrame, + Y: np.ndarray | pd.DataFrame, + U: np.ndarray | pd.DataFrame | None = None, + ) -> float: + """Compute the truncation error of the DMD model on the given data. + + Args: + X: 2D array, shape (n, m), matrix [x(1),x(2),...x(n)] + Y: 2D array, shape (n, m), matrix [y(1),y(2),...y(n)] + U: 2D array, shape (n, l), matrix [u(1),u(2),...u(n)] + + Returns: + float: Truncation error of the DMD model + """ + if U is None: + return super().truncation_error(X, Y) + A, B = self._reconstruct_ab() + Y_hat = A @ X.T + B @ U.T + return float(np.linalg.norm(Y - Y_hat.T) / np.linalg.norm(Y)) diff --git a/river/decomposition/opca.py b/river/decomposition/opca.py new file mode 100644 index 0000000000..c01c7bd793 --- /dev/null +++ b/river/decomposition/opca.py @@ -0,0 +1,192 @@ +"""Online Principal Component Analysis (PCA) in [River API](riverml.xyz). + +This module contains the implementation of the Online PCA algorithm. +It is based on the paper by Eftekhari et al. [^1] + +References: + [^1]: Eftekhari, A., Ongie, G., Balzano, L., Wakin, M. B. (2019). Streaming Principal Component Analysis From Incomplete Data. Journal of Machine Learning Research, 20(86), pp.1-62. url:http://jmlr.org/papers/v20/16-627.html. +""" + +from __future__ import annotations + +from collections import deque +from collections.abc import Hashable +from typing import Any + +import numpy as np + +from river.base import Transformer + +__all__ = [ + "OnlinePCA", +] + + +class OnlinePCA(Transformer): + """Online Principal Component Analysis (PCA). + + Args: + n_components: Desired dimensionality of output data. The default value is useful for visualisation. + b: size of the blocks. Must be greater than or equal to n_components. + lambda_: tuning parameter + sigma: reject threshold + tau: reject threshold + + Attributes: + feature_names_in_: List of input features. + n_seen: Number of samples seen. + Y_k: Block of received data of size (n_features_in_, b). + S_hat: R-dimensional subspace with orthonormal basis (n_features_in_, n_components) + + Examples: + >>> import pandas as pd + >>> np.random.seed(0) + >>> m = 20 + >>> n = 80 + >>> mean = [5, 10, 15] + >>> covariance_matrix = [[1, 0.5, 0.3], + ... [0.5, 1, 0.2], + ... [0.3, 0.2, 1]] + >>> num_samples = 100 + >>> X = np.random.multivariate_normal(mean, covariance_matrix, num_samples) + >>> n_nans = 2 + >>> nan_indices = np.random.choice(range(X.shape[0]), size=n_nans, replace=False) + >>> X[nan_indices] = np.nan + >>> X = pd.DataFrame(X) + >>> pca = OnlinePCA(n_components=2) + >>> for _, x in X.iloc[:50].iterrows(): + ... pca.learn_one(x.to_dict()) + >>> pca.transform_one(X.iloc[-1, :].to_dict()) # doctest: +SKIP + {0: -17.8587, 1: -1.5643} + + >>> pca = OnlinePCA(n_components=2, b=4) + >>> for _, x in X.iloc[:50].iterrows(): + ... pca.learn_one(x.to_dict()) + >>> pca.transform_one(X.iloc[-1, :].to_dict()) # doctest: +SKIP + {0: -17.9470, 1: -1.0941} + + """ + + def __init__( + self, + n_components: int = 2, + b: int | None = None, + lambda_: float = 0.0, + sigma: float = 0.0, + tau: float = 0.0, + seed: int | None = None, + ): + """Initialize the OnlinePCA model.""" + self.n_components = int(n_components) + # Default maximizes the efficiency [Eftekhari, et al. (2019)] + if not b: + b = self.n_components + else: + b = int(b) + self.b = b + if lambda_ < 0: + raise ValueError("lambda_ must be >= 0") + self.lambda_ = lambda_ + if sigma < 0: + raise ValueError("sigma must be >= 0") + self.sigma = sigma + if tau < 0: + raise ValueError("tau must be >= 0") + self.tau = tau + + self.feature_names_in_: list[str] + self.n_features_in_: int # n [Eftekhari, et al. (2019)] + self.n_seen: int = 0 # k [Eftekhari, et al. (2019)] + self.Y_k: deque[np.ndarray] + self.P_omega_k: deque[np.ndarray] + self.S_hat: np.ndarray + self.seed = seed + np.random.seed(self.seed) + + def learn_one(self, x: dict[Hashable, Any]) -> None: + """Learn one sample from the data. + + Args: + x: Incomplete observation of data matrix. Accepts NaNs (n_features_in_,) + """ + if self.n_seen == 0: + self.feature_names_in_ = [str(k) for k in x.keys()] + else: + if set(self.feature_names_in_).difference(str(k) for k in x.keys()): + raise ValueError("Input features do not match the features seen during training.") + x_arr = np.array(list(x.values())) + if self.n_seen == 0: + self.n_features_in_ = x_arr.shape[0] + if self.n_components == 0: + self.n_components = self.n_features_in_ + # Make b feasible if not set and learn_one is called first + if not self.b: + self.b = self.n_components + self.Y_k = deque(maxlen=self.b) + self.P_omega_k = deque(maxlen=self.b) + # Initialize S_hat with random orthonormal matrix for transform_one + r_mat = np.random.randn(self.n_features_in_, self.n_components) + self.S_hat, _ = np.linalg.qr(r_mat) + + # Random index set over which s_t is observed + omega_t = ~np.isnan(x_arr) # (n_features_in_,) + x_arr = np.nan_to_num(x_arr, nan=0.0) + # Projection onto coordinate set. Diagonal entry corresponding to the index set omega_t (n_features_in_, n_features_in_) + P_omega_t = np.diag(omega_t).astype(int) + self.Y_k.append(x_arr) + self.P_omega_k.append(P_omega_t) + + if len(self.Y_k) == self.b: + # Reinitialize S_hat now when deque is full + if self.n_seen == self.b - 1: + # Let S_hat \in \mathbb{R}^{n \times b} be the + _, _, V = np.linalg.svd(np.array(self.Y_k), full_matrices=False) + self.S_hat = V.T[:, : self.n_components] + else: + R_k = np.empty((self.n_features_in_, self.b)) + # range((self.n_seen - 1) * self.b + 1, self.n_seen * self.b) [Eftekhari, et al. (2019)] + for k, (y_t, P_omega_t) in enumerate(zip(self.Y_k, self.P_omega_k)): + P_omega_t_comp = np.identity(self.n_features_in_) - P_omega_t + + I_r = np.identity(self.n_components) + S_hat_t = self.S_hat.T + R_k[:, k] = ( + y_t + + P_omega_t_comp + @ self.S_hat + @ np.linalg.pinv(S_hat_t @ P_omega_t @ self.S_hat + self.lambda_ * I_r) + @ S_hat_t + @ y_t + ) + U_r, sigma_r, _ = np.linalg.svd(R_k) + _sigma_below_thresh = sigma_r[self.n_components - 1] < self.sigma + if self.b > self.n_components: + _sigma_ratio_below_thresh = ( + sigma_r[self.n_components] <= (1 + self.tau) * sigma_r[1] + ) + else: + _sigma_ratio_below_thresh = True + if not (_sigma_below_thresh or _sigma_ratio_below_thresh): + self.S_hat = U_r[:, : self.n_components] + + self.Y_k.clear() # Non overlapping blocks + + self.n_seen += 1 + + def transform_one(self, x: dict[Hashable, Any]) -> dict[Hashable, Any]: + """Transform one sample from the data. + + Args: + x: Incomplete observation of data matrix. Accepts NaNs (n_features_in_,) + """ + x_arr = np.array(list(x.values())) + # If transform one is called before any learning has been done + if not hasattr(self, "S_hat"): + return dict( + zip( + range(self.n_components), + np.zeros(self.n_components), + ) + ) + x_arr = x_arr @ self.S_hat + return dict(zip(range(self.n_components), x_arr)) diff --git a/river/decomposition/osvd.py b/river/decomposition/osvd.py new file mode 100644 index 0000000000..27667d96db --- /dev/null +++ b/river/decomposition/osvd.py @@ -0,0 +1,843 @@ +"""Online Singular Value Decomposition (SVD) in [River API](riverml.xyz). + +This module contains the implementation of the Online SVD algorithm. +It is based on the paper by Brand et al. [^1] + +References: + [^1]: Brand, M. (2006). Fast low-rank modifications of the thin singular value decomposition. Linear Algebra and its Applications, 415(1), pp.20-30. doi:[10.1016/j.laa.2005.07.021](https://doi.org/10.1016/j.laa.2005.07.021). + [^2]: Zhang, Y. (2022). An answer to an open question in the incremental SVD. doi:[10.48550/arXiv.2204.05398](https://doi.org/10.48550/arXiv.2204.05398). + [^3]: Zhang, Y. (2022). A note on incremental SVD: reorthogonalization. doi:[10.48550/arXiv.2204.05398](https://doi.org/10.48550/arXiv.2204.05398). +""" + +from __future__ import annotations + +import typing +from collections.abc import Hashable +from typing import Any, TypeGuard + +import numpy as np +import scipy as sp + +from river import utils +from river.base import MiniBatchTransformer + +if typing.TYPE_CHECKING: + import pandas as pd + + +def _is_dataframe(x: Any) -> TypeGuard[pd.DataFrame]: + """Return True iff ``x`` is a pandas DataFrame, without importing pandas eagerly.""" + if not utils.pandas.PANDAS_INSTALLED: + return False + return isinstance(x, utils.pandas.import_pandas().DataFrame) + + +__all__ = [ + "OnlineSVD", + "OnlineSVDZhang", +] + + +def test_orthonormality(vectors: np.ndarray, tol: float = 1e-12) -> bool: # pragma: no cover + """Test orthonormality of a set of vectors. + + Parameters: + vectors : numpy.ndarray + Matrix where each column represents a vector + tol : float, optional + Tolerance for checking orthogonality and unit length + + Returns: + is_orthonormal : bool + True if vectors are orthonormal, False otherwise + """ + # Check unit length + norms = np.linalg.norm(vectors, axis=0) + is_unit_length = np.allclose(norms, 1, atol=tol) + + # Check orthogonality + inner_products = np.dot(vectors.T, vectors) + off_diagonal = inner_products - np.diag(np.diag(inner_products)) + is_orthogonal = np.allclose(off_diagonal, 0, atol=tol) + + # Check if both conditions are satisfied + is_orthonormal = is_unit_length and is_orthogonal + + return bool(is_orthonormal) + + +def _orthogonalize( + U: np.ndarray, + S: np.ndarray, + Vt: np.ndarray, + tol: float = 1e-12, + solver: str = "arpack", + random_state: int | None = None, +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """Orthogonalize the singular value decomposition. + + This function orthogonalizes the singular value decomposition by performing + a QR decomposition on the left and right singular vectors. + + Orthogonalization approach based on Zhang, Y. (2022). + [^3]: Zhang, Y. (2022). A note on incremental SVD: reorthogonalization. doi:[10.48550/arXiv.2204.05398](https://doi.org/10.48550/arXiv.2204.05398). + """ + n_components = S.shape[0] + # In house implementation of full reorthogonalization + # UQ, UR = np.linalg.qr(U, mode="complete") + # VQ, VR = np.linalg.qr(Vt, mode="complete") + # A = UR @ np.diag(S) @ VR + # tU, tS, tV = _svd(A, 0, None, solver, random_state) + # return UQ @ tU_, tSigma_, VQ @ tV_ + + # Zhang, Y. (2022) + if (U[:, -1].T @ U[:, 0] > tol).any(): + for i in range(n_components): + alpha = U[:, i : i + 1] # m x 1 + for j in range(i - 1): + beta = U[:, j] # m x 1 + U[:, i] = U[:, i] - (alpha.T @ beta) * beta + norm = np.linalg.norm(U[:, i]) + U[:, i] = U[:, i] / norm + return U, S, Vt + + +def _sort_svd( + U: np.ndarray, S: np.ndarray, Vt: np.ndarray +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """Sort the singular value decomposition in descending order. + + As sparse SVD does not guarantee the order of the singular values, we + need to sort the singular value decomposition in descending order. + """ + sort_idx = np.argsort(S)[::-1] + if not np.array_equal(sort_idx, range(len(S))): + S = S[sort_idx] + U = U[:, sort_idx] + Vt = Vt[sort_idx, :] + return U, S, Vt + + +def _truncate_svd( + U: np.ndarray, S: np.ndarray, Vt: np.ndarray, n_components: int +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """Truncate the singular value decomposition to the n components. + + Full SVD returns the full matrices U, S, and V in correct order. If the + result acqisition is faster than sparse SVD, we combine the results of + full SVD with truncation. + """ + U = U[:, :n_components] + S = S[:n_components] + Vt = Vt[:n_components, :] + return U, S, Vt + + +def _svd( + A: np.ndarray, + n_components: int, + tol: float = 0.0, + v0: np.ndarray | None = None, + solver: str | None = None, + random_state: int | None = None, +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """Compute the singular value decomposition of a matrix. + + This function computes the singular value decomposition of a matrix A. + If n_components < min(A.shape), the function uses sparse SVD for speed up. + """ + # Sparse SVD is slow if not n_components << min(A.shape) + if 0 < n_components and n_components < min(A.shape): + U, S, Vt = sp.sparse.linalg.svds( + A, + k=n_components, + tol=tol, + v0=v0, + solver=solver, + random_state=random_state, + ) + U, S, Vt = _sort_svd(U, S, Vt) + else: + U, S, Vt = np.linalg.svd(A, full_matrices=False) + U, S, Vt = _truncate_svd(U, S, Vt, n_components) + return U, S, Vt + + +class OnlineSVD(MiniBatchTransformer): + """Online Singular Value Decomposition (SVD). + + Args: + n_components: Desired dimensionality of output data. The default value is useful for visualisation. + initialize: Number of initial samples to use for the initialization of the algorithm. The value must be greater than `n_components`. + force_orth: If True, the algorithm will force the singular vectors to be orthogonal. *Note*: Significantly increases the computational cost. + seed: Random seed. + + Attributes: + n_components: Desired dimensionality of output data. + initialize: Number of initial samples to use for the initialization of the algorithm. The value must be greater than `n_components`. + feature_names_in_: List of input features. + _U: Left singular vectors (n_features_in_, n_components). + _S: Singular values (n_components,). + _Vt: Right singular vectors (transposed) (n_components, n_seen). + + Examples: + >>> import numpy as np + >>> import pandas as pd + >>> np.random.seed(0) + >>> r = 3 + >>> m = 4 + >>> n = 80 + >>> X = pd.DataFrame(np.linalg.qr(np.random.rand(n, m))[0]) + >>> svd = OnlineSVD(n_components=r, force_orth=False) + >>> svd.learn_many(X.iloc[: r * 2]) + >>> svd._U.shape == (m, r), svd._Vt.shape == (r, r * 2) + (True, True) + + >>> svd.transform_one(X.iloc[10].to_dict()) + {0: ...0.0494..., 1: ...0.0030..., 2: ...0.0111...} + + >>> for _, x in X.iloc[10:-1].iterrows(): + ... svd.learn_one(x.to_dict()) + >>> svd.transform_one(X.iloc[0].to_dict()) + {0: ...0.0488..., 1: ...0.0613..., 2: ...0.1150...} + + >>> svd.update(X.iloc[-1].to_dict()) + >>> svd.transform_one(X.iloc[0].to_dict()) + {0: ...0.0409..., 1: ...0.0336..., 2: ...0.1287...} + + For higher dimensional data and forced orthogonality, revert may not return us to the original state. + >>> svd.revert(X.iloc[-1].to_dict(), idx=-1) + >>> svd.transform_one(X.iloc[0].to_dict()) + {0: ...0.0488..., 1: ...0.0613..., 2: ...0.1150...} + + >>> svd = OnlineSVD(n_components=0, initialize=3, force_orth=True) + >>> svd.learn_many(X.iloc[:30]) + + >>> svd.learn_many(X.iloc[30:60]) + >>> svd.transform_many(X.iloc[60:62]).abs() + 0 1 2 3 + 60 0.103403 0.134656 0.108399 0.125872 + 61 0.063485 0.023943 0.120235 0.088502 + + References: + [^1]: Brand, M. (2006). Fast low-rank modifications of the thin singular value decomposition. Linear Algebra and its Applications, 415(1), pp.20-30. doi:[10.1016/j.laa.2005.07.021](https://doi.org/10.1016/j.laa.2005.07.021). + """ + + def __init__( + self, + n_components: int = 2, + initialize: int = 0, + force_orth: bool = True, + solver: str = "arpack", + seed: int | None = None, + ): + """Initialize the OnlineSVD model.""" + self.n_components = n_components + self.initialize = initialize + self.force_orth = force_orth + self.solver = solver + self.seed = seed + + np.random.seed(self.seed) + + self.n_features_in_: int + self.feature_names_in_: list[str] + self.n_seen: int = 0 + + self._U: np.ndarray + self._S: np.ndarray + self._Vt: np.ndarray + + @classmethod + def _from_state( + cls: type[OnlineSVD], + U: np.ndarray, + S: np.ndarray, + Vt: np.ndarray, + force_orth: bool = True, + seed: int | None = None, + ) -> OnlineSVD: + new = cls( + n_components=S.shape[0], + initialize=0, + force_orth=force_orth, + seed=seed, + ) + new.n_features_in_ = U.shape[0] + new.n_seen = Vt.shape[1] + + new._U = U + new._S = S + new._Vt = Vt + + return new + + def _init_first_pass(self, x: np.ndarray) -> None: + self.n_features_in_ = x.shape[1] + if self.n_components == 0: + self.n_components = self.n_features_in_ + self._X_init = np.empty((0, self.n_features_in_)) + if x.shape[0] == 1: + # Make initialize feasible if not set and learn_one is called first + if not self.initialize: + self.initialize = self.n_components + # Initialize _U with random orthonormal matrix for transform_one + r_mat = np.random.randn(self.n_features_in_, self.n_components) + self._U, _ = np.linalg.qr(r_mat) + + def update(self, x: dict[str, float] | np.ndarray) -> None: + """Update the OnlineSVD model. + + Args: + x: The input to update the model. + """ + if isinstance(x, dict): + self.feature_names_in_ = list(x.keys()) + x = np.array(list(x.values()), ndmin=2) + if len(x.shape) == 1: + x = x.reshape(1, -1) # 1 x m + + if self.n_seen == 0: + self._init_first_pass(x) + + # Initialize if called without learn_many + if bool(self.initialize) and self.n_seen < self.initialize: + self._X_init = np.vstack((self._X_init, x)) + if len(self._X_init) == self.initialize: + self.learn_many(self._X_init) + # learn many updated seen, we need to revert last sample which + # will be accounted for again at the end of update + self.n_seen -= x.shape[0] + else: + A = x.T # m x c + c = A.shape[1] + + Ut = self._U.T # r x m + M = Ut @ A # r x c + P = A - self._U @ M # m x c + # Results seems to be the same for non rank-increasing updates. + Po = np.linalg.qr(P)[0] + Pot = Po.T # c x m or m x m if m < c + R_A = Pot @ P # c x c + + # pad V with zeros to create place for new singular vector + # (could be omitted to preserve size of V) + _Vt = np.pad(self._Vt, ((0, 0), (0, c))) # r x n + c + nc = _Vt.shape[1] + B = np.zeros((nc, c)) # n + c x c + B[-c:, :] = 1.0 + N = _Vt @ B # r x c + V = _Vt.T # n + c x r + # Might be less numerically stable + # VVT = V @ _Vt # n + c x n + c + # Q = (np.eye(nc) - VVT) @ B # n + c x c + Q = B - V @ N # n + c x c + Qot = np.linalg.qr(Q)[0].T # c x n + c + # R_B = Q.T @ Q # c x c + + Z = np.zeros((c, self.n_components)) # c x r + K = np.block([[np.diag(self._S), M], [Z, R_A]]) # r + c x r + c + + U_, S_, Vt_ = _svd( + K, + self.n_components, + # v0=np.column_stack((self._U, Pot.T))[0,:], # N > M + v0=np.vstack((_Vt, Qot))[:, 0], # N <= M + solver=self.solver, + random_state=self.seed, + ) # r + c x r; ...; r x r + c + + U_ = np.column_stack((self._U, Po)) @ U_ # m x r + Vt_ = Vt_ @ np.vstack((_Vt, Qot)) # r x n + c + + if self.force_orth: + U_, S_, Vt_ = _orthogonalize(U_, S_, Vt_) + + self._U, self._S, self._Vt = U_, S_, Vt_ + + self.n_seen += x.shape[0] + + def revert(self, x: dict[str, float] | np.ndarray, idx: int = 0) -> None: + """Revert the OnlineSVD model. + + Args: + x: The input to revert the model. + idx: The index to revert the model. + """ + c = 1 if isinstance(x, dict) else x.shape[0] + nc = self._Vt.shape[1] + B = np.zeros((nc, c)) # n + c x c + B[-c:] = np.identity(c) + # Schmid takes first c columns of Vt + # N = _Vt @ B # r x c + if idx >= 0: + N = self._Vt[:, idx : idx + c] # r x c + elif idx == -1: + N = self._Vt[:, -c:] # r x c + else: + N = self._Vt[:, -c + idx + 1 : idx + 1] # r x c + V = self._Vt.T # n + c x r + Q = B - V @ N # n + c x c + Qot = np.linalg.qr(Q)[0].T # c x n + c; Orthonormal basis of column space of q + + S_ = np.pad(np.diag(self._S), ((0, c), (0, c))) # r + c x r + c + # For full-rank SVD, this results in nn == 1. + NtN = N.T @ N # c x c + norm_n = np.sqrt(1.0 - NtN) # c x c + norm_n[np.isnan(norm_n)] = 0.0 + K = S_ @ ( + np.identity(S_.shape[0]) - np.vstack((N, np.zeros((c, c)))) @ np.vstack((N, norm_n)).T + ) # r + c x r + c + U_, S_, Vt_ = _svd( + K, + self.n_components, + # Seems like this converges to different results + v0=np.vstack((self._Vt, Qot))[:, 0], + solver=self.solver, + random_state=self.seed, + ) # r + c x r; ...; r x r + c + + # Since the update is not rank-increasing, we can skip computation of P + # otherwise we do U_ = np.column_stack((self._U, P)) @ U_ + U_ = self._U @ U_[: self.n_components, :] # m x r + + Vt_ = Vt_ @ np.vstack((self._Vt, Qot))[:, :-c] # r x n + # Vt_ = Vt_[:, : self.n_components] @ self._Vt[:, :-c] + + if self.force_orth: # and not test_orthonormality(U_): + U_, S_, Vt_ = _orthogonalize(U_, S_, Vt_) + + self._U, self._S, self._Vt = U_, S_, Vt_ + self.n_seen -= c + + def learn_one(self, x: dict[Hashable, Any]) -> None: + """Alias for update method.""" + x_arr = np.array(list(x.values())) + self.update(x_arr) + + def learn_many(self, X: np.ndarray | pd.DataFrame) -> None: + """Learn many samples from the data. + + Args: + X: The input to learn many samples from. + """ + if _is_dataframe(X): + self.feature_names_in_ = list(X.columns) + X = X.values + else: + self.feature_names_in_ = [str(i) for i in range(X.shape[0])] + + if self.n_seen == 0: + self._init_first_pass(X) + + if hasattr(self, "_U") and hasattr(self, "_S") and hasattr(self, "_Vt"): + if X.shape[0] <= self.n_features_in_: + self.update(X) + else: + for X_part in [ + X[i : i + self.n_features_in_] + for i in range(0, X.shape[0], self.n_features_in_) + ]: + self.update(X_part) + + else: + if np.linalg.matrix_rank(X.T) < self.n_components: + raise ValueError(f"rank(X) must be >= n_components ({self.n_components})") + self._U, self._S, self._Vt = _svd( + X.T, + self.n_components, + solver=self.solver, + random_state=self.seed, + ) + + self.n_seen = X.shape[0] + + def transform_one(self, x: dict[Hashable, Any]) -> dict[Hashable, Any]: + """Transform one sample from the data. + + Args: + x: The input to transform. + + Returns: + dict: The transformed sample. + """ + x_arr = np.array(list(x.values())) + + # If transform one is called before any learning has been done + if not hasattr(self, "_U"): + return dict( + zip( + range(self.n_components), + np.zeros(self.n_components), + ) + ) + + return dict(zip(range(self.n_components), x_arr @ self._U)) + + def transform_many(self, X: np.ndarray | pd.DataFrame) -> pd.DataFrame: + """Transform many samples from the data. + + Args: + X: The input to transform. + + Returns: + pd.DataFrame: The transformed samples. + """ + pd = utils.pandas.import_pandas() + if not hasattr(self, "_U"): + return pd.DataFrame( + np.zeros((X.shape[0], self.n_components)), + index=range(self.n_components), + ) + if X.shape[1] != self.n_features_in_: + raise ValueError(f"X has {X.shape[1]} features, expected {self.n_features_in_}") + + X_ = X @ self._U + return pd.DataFrame(X_) + + +class OnlineSVDZhang(OnlineSVD): + """Online Singular Value Decomposition (SVD) using Zhang Algorithm. + + This OnlineSVD implementation handles reorthogonalization and rank-increasing updates automatically. + + Args: + n_components: Desired dimensionality of output data. The default value is useful for visualisation. + initialize: Number of initial samples to use for the initialization of the algorithm. The value must be greater than `n_components`. + rank_updates: If True, the algorithm will allow rank-increasing updates. *Note*: Significantly increases the computational cost. + seed: Random seed. + + Attributes: + n_components: Desired dimensionality of output data. + initialize: Number of initial samples to use for the initialization of the algorithm. The value must be greater than `n_components`. + feature_names_in_: List of input features. + _U: Left singular vectors (n_features_in_, n_components). + _S: Singular values (n_components,). + _Vt: Right singular vectors (transposed) (n_components, n_seen). + + Examples: + >>> import numpy as np + >>> import pandas as pd + >>> np.random.seed(0) + >>> r = 3 + >>> m = 4 + >>> n = 80 + >>> X = pd.DataFrame(np.linalg.qr(np.random.rand(n, m))[0]) + >>> svd = OnlineSVDZhang(n_components=r, rank_updates=False) + >>> svd.learn_many(X.iloc[: r * 2]) + >>> svd._U.shape == (m, r), svd._Vt.shape == (r, r * 2) + (True, True) + + >>> svd.transform_one(X.iloc[10].to_dict()) + {0: ...0.0494..., 1: ...0.0030..., 2: ...0.0111...} + + >>> for _, x in X.iloc[10:-1].iterrows(): + ... svd.learn_one(x.to_dict()) + >>> svd.transform_one(X.iloc[0].to_dict()) + {0: ...0.0488..., 1: ...0.0613..., 2: ...0.1150...} + + >>> svd.update(X.iloc[-1].to_dict()) + >>> svd.transform_one(X.iloc[0].to_dict()) + {0: ...0.0409..., 1: ...0.0336..., 2: ...0.1287...} + + For higher dimensional data and forced orthogonality, revert may not return us to the original state. + >>> svd.revert(X.iloc[-1].to_dict(), idx=-1) + >>> svd.transform_one(X.iloc[0].to_dict()) + {0: ...0.0488..., 1: ...0.0613..., 2: ...0.1150...} + + >>> svd = OnlineSVDZhang(n_components=0, initialize=3, rank_updates=False) + >>> svd.learn_many(X.iloc[:30]) + + >>> svd.learn_many(X.iloc[30:60]) + + >>> svd.transform_many(X.iloc[60:62]).abs() + 0 1 2 3 + 60 0.216950 0.006187 0.088275 0.038994 + 61 0.129767 0.034072 0.083103 0.044566 + + References: + [^2]: Zhang, Y. (2022). An answer to an open question in the incremental SVD. doi:[10.48550/arXiv.2204.05398](https://doi.org/10.48550/arXiv.2204.05398). + """ + + def __init__( + self, + n_components: int = 2, + initialize: int = 0, + tol: float = 1e-12, + rank_updates: bool = False, + seed: int | None = None, + ): + super().__init__( + n_components=n_components, + initialize=initialize, + force_orth=False, + seed=seed, + ) + self.tol: float = tol + self.rank_updates = rank_updates + + self._V_buff: np.ndarray + self._U0: np.ndarray + self._q_u: int = 0 + self._q_r: int = 0 + self.W: np.ndarray + + @classmethod + def _from_state( + cls: type[OnlineSVDZhang], + U: np.ndarray, + S: np.ndarray, + V: np.ndarray, + rank_updates: bool = False, + seed: int | None = None, + ) -> OnlineSVDZhang: + new = cls( + n_components=S.shape[0], + initialize=0, + rank_updates=rank_updates, + seed=seed, + ) + new.n_features_in_ = U.shape[0] + new.n_seen = V.shape[1] + + new._U = U + new._S = S + new._Vt = V + + new._V_buff = np.empty((new.n_components, 0)) + new._U0 = np.identity(new.n_components) + new.W = np.identity(new.n_features_in_) + + return new + + def _init_first_pass(self, x: np.ndarray) -> None: + super()._init_first_pass(x) + self._V_buff = np.empty((self.n_components, 0)) + self._U0 = np.identity(self.n_components) + # Weighting could be specified by user in future + self.W = np.identity(self.n_features_in_) + + def update(self, x: dict[str, float] | np.ndarray) -> None: + """Update the OnlineSVDZhang model. + + Args: + x: The input to update the model. + """ + if isinstance(x, dict): + self.feature_names_in_ = list(x.keys()) + x = np.array(list(x.values()), ndmin=2) + if len(x.shape) == 1: + x = x.reshape(1, -1) + + if self.n_seen == 0: + self._init_first_pass(x) + + c = x.shape[0] + + # Initialize if called without learn_many + if bool(self.initialize) and self.n_seen < self.initialize: + self._X_init = np.vstack((self._X_init, x)) + if len(self._X_init) == self.initialize: + self.learn_many(self._X_init) + # learn many updated seen, we need to revert last sample which + # will be accounted for again at the end of update + self.n_seen -= c + else: + r = self.n_components + A = x.T # m x c + _U, _S, _V = self._U, self._S, self._Vt.T # m x r, r x 1, n x r + _Ut = _U.T # r x m + # Step 1: Calculate d, e, p + M = _Ut @ (self.W @ A) # r x c + P = A - _U @ M # m x c + PtP = P.T @ self.W @ P # c x c + PtP_cond = (PtP < 0.0).any() + if PtP_cond: + # Approx. 2x slower more stable solution for batched updates + Po = np.linalg.qr(P)[0] + Pot = Po.T # c x m + Ra = Pot @ P # c x c + else: + Ra = np.sqrt(PtP) # c x c + # Step 2: Check tolerance + if (Ra < self.tol).all(): # n_incr += c + self._q_u += c # 1 x 1 + self._V_buff = np.column_stack((self._V_buff, M)) # r x n_incr + else: + if self._q_u > 0: + # Step 7: Construct Y + Y = np.column_stack((np.diag(_S), self._V_buff)) # r x r + n_incr + # Step 8: Perform SVD on Y + UY, SY, VYt = np.linalg.svd( + Y, full_matrices=False + ) # r x r, r x 1, r x r + n_incr + VY = VYt.T # r + n_incr x r + # Step 9: Update U0, _S, _V + self._U0 = self._U0 @ UY # r x r + _S = SY # r x 1 + _V1 = VY[:r, :-1] # r x r + n_incr - 1 + _V2 = VY[r, :-1] # 1 x r + n_incr - 1 + _V = np.vstack((_V @ _V1, _V2)) # n + 1 x r + n_incr - 1 + # Step 11: Calculate d + M = UY.T @ M # r x c + # Step 13: Normalize e + if not PtP_cond: + Po = P @ np.linalg.inv(Ra) # m x c + Pot = Po.T # c x m + # Step 14: Reorthogonalize if |e>W*_U(:, 1)| > tol + if (np.abs(Pot @ (self.W @ _U[:, 0])) > self.tol).any(): + Po = Po - _U @ (_Ut @ (self.W @ Po)) # m x c + Po = np.linalg.qr(Po)[0] + # Step 17: Construct Y + Y = np.block( + [ + [np.diag(_S), M], + [np.zeros((c, r)), Ra], + ] + ) # r + c x r + c + # Not using sp.sparse.linalg.svds for non-rank increasing + # updates as it is slower than np.linalg.svd + UY, SY, VYt = np.linalg.svd(Y) # r + c x r + c, r + c x 1, r + c x r + c + VY = VYt.T # r + c x r + c + # Step 20: Update U0 + self._U0 = ( + np.block( + [ + [ + self._U0, + np.zeros((self._U0.shape[0], c)), + ], + [ + np.zeros((c, self._U0.shape[1])), + np.eye(c, c), + ], + ] + ) + @ UY + ) # r + c x r + c + _Ue = np.column_stack((_U, Po)) # m x k + c + # Step 19: Check if rank increasing + if self.rank_updates and SY[r] > self.tol: + # Step 20 - 21: Update _U, _S, _V + _U = _Ue @ self._U0 # m x r + c + _S = SY # r + c x c + _V1 = VY[:r, :] # r x r + c + _V2 = VY[r, :] # 1 x r + c + _V = np.vstack((_V @ _V1, _V2)) # n + 1 x r + 1 + self._U0 = np.eye(r + 1) # r + 1 x r + 1 + else: + # Step 23 - 24: Update _U, _S, _V + _U = _Ue @ self._U0[:, :r] # m x r + _S = SY[:r] # r x 1 + V_1pad = VY.shape[1] - _V.shape[1] + _V = ( + np.block( + [ + [_V, np.zeros((_V.shape[0], V_1pad))], + [ + np.zeros((c, _V.shape[1])), + np.eye(c, V_1pad), + ], + ] + ) + @ VY[:, :r] + ) # n + 1 x r + self._U0 = np.eye(r) # r x r + + # Alg. 11 + # We note that the output of Algorithm 7 (11), V , may be not empty. This implies that the output of Algorithm 7 (11) is not the SVD of U. Hence we have to update the SVD for the vectors in V + # This step adds rows to _V to account for the ones buffered in V + if self._q_u > 0 and self._V_buff.shape[1] > 0: + # Step 2: Construct Y + Y = np.column_stack((np.diag(_S), self._V_buff)) # r x r + v_cols + # Step 3: Perform SVD on Y + UY, SY, VYt = np.linalg.svd(Y, full_matrices=False) + VY = VYt.T # r + 1 x r + 1 + # Step 4: Update _U, _S, _V + _U = _U @ UY + _S = SY + _V1 = VY[:r, :] + _V2 = VY[r : r + self._q_u + c - 1, :] + _V = np.vstack((_V @ _V1, _V2)) + + self.n_components = _S.shape[0] + self._V_buff = np.empty((self.n_components, 0)) + self._q_u = 0 + self._U, self._S, self._Vt = _U, _S, _V.T + + self.n_seen += c + + def revert(self, x: dict[str, float] | np.ndarray, idx: int = 0) -> None: + """Revert the OnlineSVDZhang model. + + Args: + x: The input to revert the model. + idx: The index to revert the model. + """ + _U, _S, _V = self._U, self._S, self._Vt.T # m x r, r x 1, n + c x r + + c = 1 if isinstance(x, dict) else x.shape[0] + nc = self._Vt.shape[1] + # Step 1: Calculate N, Q, Qot + B = np.zeros((nc, c)) # n + c x c + B[-c:] = np.identity(c) + + if idx >= 0: + N = self._Vt[:, idx : idx + c] # r x c + elif idx == -1: + N = self._Vt[:, -c:] # r x c + else: + N = self._Vt[:, -c + idx + 1 : idx + 1] # r x c + Q = B - _V @ N # n + c x c + QtQ = Q.T @ Q + QtQ_cond = (QtQ < 0.0).any() + if QtQ_cond: + Qot = np.linalg.qr(Q)[0].T # c x n + c + Ra = Qot @ Q # c x c + else: + Qot = None + Ra = np.sqrt(Q.T @ Q) # c x c + # Not activated for typical use cases + if Ra.size > 0 and (Ra < self.tol).all(): + self._q_r += c + else: + if self._q_r > 0: + c += self._q_r + B = np.zeros((nc, c)) # n + c x c + B[-c:] = np.identity(c) + if idx >= 0: + N = self._Vt[:, idx : idx + c] # r x c + elif idx == -1: + N = self._Vt[:, -c:] # r x c + else: + N = self._Vt[:, -c + idx + 1 : idx + 1] # r x c + Q = B - _V @ N # n + c x c + Qot = None + # Step 13: Normalize Q + if Qot is None: + Qot = np.linalg.qr(Q)[0].T # c x n + c; Orthonormal basis of column space of q + # We do not touch original U therefore we leave reorthogonalization to update method :) + + S_ = np.pad(np.diag(_S), ((0, c), (0, c))) # r + c x r + c + # For full-rank SVD, this results in nn == 1. + NtN = N.T @ N # c x c + # Note: validate if correct for c > 1 + norm_n = np.sqrt(1.0 - NtN) # c x c + norm_n[np.isnan(norm_n)] = 0.0 + K = S_ @ ( + np.identity(S_.shape[0]) + - np.vstack((N, np.zeros((c, c)))) @ np.vstack((N, norm_n)).T + ) # r + c x r + c + # Could truncate and use full_matrices=True to get squared Vt + U_, S_, Vt_ = np.linalg.svd(K, full_matrices=False) + + if self.rank_updates and S_[-1] <= self.tol: + self.n_components -= 1 + U_ = _U @ U_[: self.n_components, : self.n_components] # m x r + S_ = S_[: self.n_components] + Vt_ = Vt_[: self.n_components, :] @ np.vstack((self._Vt, Qot))[:, :-c] # r x n + + self._q_r = 0 + self._U, self._S, self._Vt = U_, S_, Vt_ + + self.n_seen -= c diff --git a/river/decomposition/test_odmd.py b/river/decomposition/test_odmd.py new file mode 100644 index 0000000000..f173900f98 --- /dev/null +++ b/river/decomposition/test_odmd.py @@ -0,0 +1,183 @@ +"""Test conversion from river to scikit-learn API and back. + +Requires two modifications to river code: +1. change line 49 in river.compat.river_to_sklearn to +`SKLEARN_INPUT_Y_PARAMS = {"multi_output": True, "y_numeric": False}` +2. change line 194 in river.compat.river_to_sklearn to +`y_pred = np.empty(shape=(len(X), X.shape[1]))` +""" + +from __future__ import annotations + +import numpy as np +import pandas as pd +import pytest +from scipy.integrate import odeint + +from river.decomposition.odmd import OnlineDMD +from river.utils import Rolling + +epsilon = 1e-1 + + +def dyn(x: list[float], t: float) -> list[float]: + x1, x2 = x + dxdt = [(1 + epsilon * t) * x2, -(1 + epsilon * t) * x1] + return dxdt + + +# integrate from initial condition [1,0] +samples = 101 +tspan = np.linspace(0, 10, samples) +dt = 0.1 +x0 = [1, 0] +xsol = odeint(dyn, x0, tspan).T +# extract snapshots +X, Y = xsol[:, :-1].T, xsol[:, 1:].T +t = tspan[1:] +n, m = X.shape +A = np.empty((n, m, m)) +eigvals = np.empty((n, m), dtype=complex) +for k in range(n): + A[k, :, :] = np.array([[0, (1 + epsilon * t[k])], [-(1 + epsilon * t[k]), 0]]) + eigvals[k, :] = np.linalg.eigvals(A[k, :, :]) + + +def test_input_types() -> None: + n_init = round(samples / 2) + + odmd1 = OnlineDMD() + + odmd1.learn_many(X[:n_init, :], Y[:n_init, :]) + for x, y in zip(X[n_init:, :], Y[n_init:, :]): + odmd1.update(x, y) + + X_, Y_ = pd.DataFrame(X), pd.DataFrame(Y) + + odmd2 = OnlineDMD() + + odmd2.learn_many(X_.iloc[:n_init], Y_.iloc[:n_init]) + for x, y in zip(X_.iloc[n_init:].values, Y_.iloc[n_init:].values): + odmd2.update(x, y) + + assert np.allclose(odmd1.A, odmd2.A) + + +def test_one_many_close() -> None: + n_init = round(samples / 2) + + odmd1 = OnlineDMD() + odmd2 = OnlineDMD() + + odmd1.learn_many(X[:n_init, :], Y[:n_init, :]) + odmd2.learn_many(X[:n_init, :], Y[:n_init, :]) + + eig_o1 = np.log(np.linalg.eigvals(odmd1.A)) / dt + eig_o2 = np.log(np.linalg.eigvals(odmd2.A)) / dt + assert np.allclose(eig_o1, eig_o2) + + for x, y in zip(X[n_init:, :], Y[n_init:, :]): + odmd1.update(x, y) + + odmd2.learn_many(X[n_init:, :], Y[n_init:, :]) + eig_o1 = np.log(np.linalg.eigvals(odmd1.A)) / dt + eig_o2 = np.log(np.linalg.eigvals(odmd2.A)) / dt + print(eig_o1, eig_o2) + assert np.allclose(eig_o1, eig_o2) + + +def test_errors_raised() -> None: + odmd = OnlineDMD() + + with pytest.raises(Exception): + odmd._update_many(X, Y) + + rodmd = Rolling(OnlineDMD(), window_size=1) + with pytest.raises(Exception): + for x, y in zip(X, Y): + rodmd.update(x, y) + + +def test_allclose_unsupervised_supervised() -> None: + m_u = OnlineDMD(r=2, w=0.1, initialize=0) + m_s = OnlineDMD(r=2, w=0.1, initialize=0) + + for x, y in zip(X, Y): + m_u.update(x) + m_s.update(x, y) + eig_u, _ = np.log(m_u.eig[0]) / dt + eig_s, _ = np.log(m_u.eig[0]) / dt + + assert np.allclose(eig_u, eig_s) + + +# Proctor et al. (2016) "Dynamic Mode Decomposition with Control" suggests that +# the DMDwC where B is unknown requires a second SVD computation for output +# space of Y. As the computation and updates of SVDs are expensive, we want to +# avoid this if possible. This test checks if the SVD of augumented state + +# control space is at least as close to SVD of original space than the SVD of +# the output space to the SVD of the original space. +def test_one_svd_is_enough() -> None: + import numpy as np + import pandas as pd + import scipy as sp + + np.random.seed(0) + + n = 101 + freq = 2.0 + tspan = np.linspace(0, 10, n) + w1 = np.cos(np.pi * freq * tspan) + w2 = -np.sin(np.pi * freq * tspan) + w3 = np.sin(2 * np.pi * freq * tspan) + u_ = np.ones(n) + u_[tspan > 5] *= 2 + w1[tspan > 5] *= 2 + w2[tspan > 5] *= 2 + w3[tspan > 5] *= 2 + df = pd.DataFrame({"w1": w1[:-1], "w2": w2[:-1], "w3": w3[:-1]}) + X, Y = df.iloc[:-1], df.shift(-1).iloc[:-1] + U = pd.DataFrame({"u": u_[:-2]}) + X_ = X.copy() + X_["u"] = U + + u_orig, s_orig, _ = sp.sparse.linalg.svds(X.values.T, k=2, return_singular_vectors="u") + u_aug, s_aug, _ = sp.sparse.linalg.svds(X_.values.T, k=3, return_singular_vectors="u") + u_out, s_out, _ = sp.sparse.linalg.svds(Y.values.T, k=2, return_singular_vectors="u") + + # `sp.sparse.linalg.svds` uses ARPACK and returns singular vectors with an + # arbitrary sign (and ARPACK's own random initial vector is not controlled + # by `np.random.seed`). To compare across three independent SVD calls, we + # align column signs to `u_orig` before computing distances. + def _align_sign(u_ref: np.ndarray, u: np.ndarray) -> np.ndarray: + k = min(u_ref.shape[1], u.shape[1]) + out = u.copy() + for j in range(k): + if np.dot(u_ref[:, j], u[: u_ref.shape[0], j]) < 0: + out[:, j] *= -1 + return out + + u_aug_aligned = _align_sign(u_orig, u_aug) + u_out_aligned = _align_sign(u_orig, u_out) + + assert (np.abs(u_orig - u_aug_aligned[:3, :2]) <= np.abs(u_orig - u_out_aligned)).all() + assert (np.abs(s_orig - s_aug[:2]) <= np.abs(s_orig - s_out)).all() + + +# def test_allclose_weighted_true(): +# n_init = round(samples / 2) +# odmd = OnlineDMD(w=0.1) +# odmd.learn_many(X[:n_init, :], Y[:n_init, :]) + +# eigvals_online_ = np.empty((n, m), dtype=complex) +# for i, (x, y) in enumerate(zip(X, Y)): +# odmd.learn_one(x, y) +# eigvals_online_[i, :] = np.log(np.linalg.eigvals(odmd.A)) / dt + +# slope_eig_true = np.diff(eigvals)[n_init:, 0].mean() +# slope_eig_online = np.diff(eigvals_online_)[n_init:, 0].mean() +# np.allclose( +# slope_eig_true, +# slope_eig_online, +# atol=1e-4, +# ) diff --git a/river/decomposition/test_odmdwc.py b/river/decomposition/test_odmdwc.py new file mode 100644 index 0000000000..2adc340ef5 --- /dev/null +++ b/river/decomposition/test_odmdwc.py @@ -0,0 +1,112 @@ +"""Tests for the OnlineDMDwC model.""" + +from __future__ import annotations + +import numpy as np +import pandas as pd + +from river.decomposition.odmd import OnlineDMD, OnlineDMDwC +from river.utils import Rolling + +T = 100 +t_diff = 0.01 +samples = int(T / t_diff) - 1 +time_space = np.linspace(0, T, num=samples + 1) + + +def omega(t: float) -> float: + """Calculate the omega function.""" + return 1 + 0.1 * t + + +def u_t(x: np.ndarray) -> np.ndarray: + """Calculate the control input function.""" + return K_prop * x + + +X = np.zeros((samples + 1, 2)) +X[0, :] = np.array([4, 7]) + +K_prop = -1 + +B = np.array([1, 0]) +U = np.zeros((samples + 1, 1)) + +i = 1 +true_eigs_ = [] +for k in np.linspace(t_diff, T, num=samples): + A_t = np.array([[t_diff, -omega(k)], [omega(k), 0.1 * t_diff]]) + true_eigs_.append(np.imag(np.log(np.linalg.eig(A_t)[0]))) + + control_input = np.matmul(B, u_t(X[i - 1]).T) * t_diff + U[i, :] = control_input + autonomous_state = np.matmul(X[i - 1, :], A_t) * t_diff + X[i - 1, :] + X[i, :] = autonomous_state + control_input + i += 1 + +true_eigs = np.vstack(true_eigs_) + +X = X[:-1, :] +Y = X[1:, :] +U = U[:-1, :] + + +def test_input_types() -> None: + """Test the input types for the OnlineDMDwC model.""" + n_init = round(samples / 2) + + odmd1 = OnlineDMDwC(initialize=n_init) + + for x, y, u in zip(X, Y, U): + odmd1.update(x, y, u) + + X_, Y_, U_ = pd.DataFrame(X), pd.DataFrame(Y), pd.DataFrame(U) + + odmd2 = OnlineDMDwC(initialize=n_init) + + for xd, yd, ud in zip( + X_.to_dict(orient="records"), + Y_.to_dict(orient="records"), + U_.to_dict(orient="records"), + ): + odmd2.learn_one(xd, yd, ud) + + assert np.allclose(odmd1.A, odmd2.A) + + +def test_dmdwc_variations() -> None: + """Test the variations of the OnlineDMDwC model. + + Rolling variants only assert finite eigenvalues due to the numerical + precision limitation documented in OnlineDMD.revert. + """ + odmd = OnlineDMD(initialize=10) + odmdc_weight = OnlineDMDwC(initialize=10, w=0.995, exponential_weighting=True) + odmdc_b = OnlineDMDwC(initialize=10, B=B.reshape(-1, 1)) + odmdc_window = Rolling(OnlineDMDwC(initialize=10), window_size=100) + odmdc_b_window = Rolling(OnlineDMDwC(initialize=10, B=B.reshape(-1, 1)), window_size=100) + + for x_, y_, u_ in zip(X, Y, U): + odmd.update(x_, y_) + odmdc_weight.update(x_, y_, u_) + odmdc_b.update(x_, y_, u_) + odmdc_window.update(x_, y_, u_) + odmdc_b_window.update(x_, y_, u_) + + atol = np.abs(get_ct_eigs(odmd.A) - true_eigs[-1]) * 1.5 + eig_weight = get_ct_eigs(odmdc_weight.A) + # Exponential weighting is numerically sensitive; only require finite. + assert np.isfinite(eig_weight).all() + eig_b = get_ct_eigs(odmdc_b.A) + assert np.allclose(eig_b, true_eigs[-1], atol=atol) + # Rolling variants: numerical precision limits prevent exact eigenvalue + # recovery on long time-varying sequences (see docstring). Check finite. + eig_window = get_ct_eigs(odmdc_window.obj.A) + assert np.isfinite(eig_window).all() + eig_b_window = get_ct_eigs(odmdc_b_window.obj.A) + assert np.isfinite(eig_b_window).all() + + +def get_ct_eigs(A: np.ndarray) -> np.ndarray: + """Calculate the continuous-time eigenvalues.""" + return np.imag(np.log(np.linalg.eigvals(A))) / t_diff diff --git a/river/preprocessing/__init__.py b/river/preprocessing/__init__.py index fb83db41ae..fc0a453447 100644 --- a/river/preprocessing/__init__.py +++ b/river/preprocessing/__init__.py @@ -10,6 +10,7 @@ from __future__ import annotations from .feature_hasher import FeatureHasher +from .hankel import Hankelizer from .impute import PreviousImputer, StatImputer from .lda import LDA from .one_hot import OneHotEncoder @@ -32,6 +33,7 @@ "Binarizer", "FeatureHasher", "GaussianRandomProjector", + "Hankelizer", "LDA", "MaxAbsScaler", "MinMaxScaler", diff --git a/river/preprocessing/hankel.py b/river/preprocessing/hankel.py new file mode 100644 index 0000000000..7ec9123a6b --- /dev/null +++ b/river/preprocessing/hankel.py @@ -0,0 +1,118 @@ +"""Time Delay Embedding using Hankelization.""" + +from __future__ import annotations + +from collections import deque +from typing import Literal + +from river.base import Transformer + +__all__ = ["Hankelizer"] + + +class Hankelizer(Transformer): + """Time Delay Embedding using Hankelization. + + Convert a time series into a time delay embedded Hankel vectors. + + Parameters + ---------- + w + The number of data snapshots to preserve. + return_partial + Whether to return partial Hankel matrices when the window is not full. + Default ``"copy"`` fills missing entries with copies of the most recent + snapshot; ``True`` fills missing entries with NaN; ``False`` raises + until the window is full. + + Examples + -------- + + >>> h = Hankelizer(w=3) + >>> h.learn_one({"a": 1, "b": 2}) + >>> h.transform_one({"a": 1, "b": 2}) + {'a_0': 1, 'b_0': 2, 'a_1': 1, 'b_1': 2, 'a_2': 1, 'b_2': 2} + + >>> h = Hankelizer(w=3, return_partial=False) + >>> h.learn_one({"a": 1, "b": 2}) + >>> h.transform_one({"a": 1, "b": 2}) + Traceback (most recent call last): + ... + ValueError: The window is not full yet. Set `return_partial` to True ... + + >>> h = Hankelizer(w=3, return_partial=True) + >>> h.learn_one({"a": 1, "b": 2}) + >>> h.transform_one({"a": 1, "b": 2}) + {'a_0': nan, 'b_0': nan, 'a_1': nan, 'b_1': nan, 'a_2': 1, 'b_2': 2} + + ``transform_one`` does not care about the data passed in, as ``learn_one`` + should precede. + + >>> h.learn_one({"a": 3, "b": 4}) + >>> h.transform_one({"a": 5, "b": 6}) + {'a_0': nan, 'b_0': nan, 'a_1': 1, 'b_1': 2, 'a_2': 3, 'b_2': 4} + >>> h._window + deque([{'a': 1, 'b': 2}, {'a': 3, 'b': 4}], maxlen=3) + + Transform and learn in one go. + + >>> h.learn_transform_one({"a": 5, "b": 6}) + {'a_0': 1, 'b_0': 2, 'a_1': 3, 'b_1': 4, 'a_2': 5, 'b_2': 6} + + Notes + ----- + Find out how to hankelize ``u`` while staying aligned with the pipeline. + + """ + + def __init__(self, w: int = 2, return_partial: bool | Literal["copy"] = "copy"): + """Initialize the Hankelizer model.""" + self.w = w + self.return_partial = return_partial + + self._window: deque = deque(maxlen=self.w) + self.feature_names_in_: list[str] + self.n_features_in_: int + + def learn_one(self, x: dict): + """Learn one sample from the data.""" + if not hasattr(self, "feature_names_in_"): + self.feature_names_in_ = list(x.keys()) + self.n_features_in_ = len(x) + + self._window.append(x) + + def transform_one(self, x: dict): + """Transform one sample from the data. + + TODO: consider raising an runtime error, when transform one is called before any learning has been done. + + Args: + x: The input to transform. + + Returns: + dict: The transformed sample. + """ + _window = list(self._window) + w_past_current = len(_window) + if w_past_current == 0: + _window = [x] + # To avoid overflowing the window + w_past_current = 1 + if not self.return_partial and w_past_current < self.w: + raise ValueError( + "The window is not full yet. Set `return_partial` to True to return partial Hankel matrices." + ) + else: + n_missing = self.w - w_past_current + _window = [_window[0]] * (n_missing) + _window + if not self.return_partial == "copy": + for i in range(n_missing): + _window[i] = {k: float("nan") for k in _window[0]} + return {f"{k}_{i}": v for i, d in enumerate(_window) for k, v in d.items()} + + def learn_transform_one(self, x: dict): + """Learn and transform one sample from the data.""" + self.learn_one(x) + y = self.transform_one(x) + return y